Skip to content

Commit ed929cd

Browse files
committed
Add root query source and page filters
1 parent fdbcf56 commit ed929cd

14 files changed

Lines changed: 522 additions & 15 deletions

nemo_retriever/src/nemo_retriever/cli/query/app.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from nemo_retriever.common.vdb.records import RetrievalHit
2323
from nemo_retriever.query.options import (
2424
QueryEmbedOptions,
25+
QueryFilterOptions,
2526
QueryRerankOptions,
2627
QueryRequest,
2728
QueryRetrievalOptions,
@@ -122,13 +123,32 @@ def _retrieval_options(
122123
)
123124

124125

126+
def _filter_options(
127+
*,
128+
source_id: str | None,
129+
source: str | None,
130+
page_number: int | None,
131+
where: str | None,
132+
) -> QueryFilterOptions:
133+
return QueryFilterOptions(
134+
source_id=source_id,
135+
source=source,
136+
page_number=page_number,
137+
where=where,
138+
)
139+
140+
125141
@app.command("_local", hidden=True)
126142
def _local_command(
127143
query: opts.QueryArgument,
128144
top_k: opts.TopKOption = 10,
129145
candidate_k: opts.CandidateKOption = None,
130146
page_dedup: opts.PageDedupOption = False,
131147
content_types: opts.ContentTypesOption = None,
148+
source_id: opts.SourceIdOption = None,
149+
source: opts.SourceOption = None,
150+
page_number: opts.PageNumberOption = None,
151+
where: opts.WhereOption = None,
132152
lancedb_uri: opts.LanceDbUriOption = "lancedb",
133153
table_name: opts.TableNameOption = "nemo-retriever",
134154
embed_invoke_url: opts.EmbedInvokeUrlOption = None,
@@ -163,6 +183,12 @@ def _request(use_hybrid: bool) -> QueryRequest:
163183
content_types=content_types,
164184
hybrid=use_hybrid,
165185
),
186+
filters=_filter_options(
187+
source_id=source_id,
188+
source=source,
189+
page_number=page_number,
190+
where=where,
191+
),
166192
embed=QueryEmbedOptions(
167193
embed_invoke_url=embed_invoke_url,
168194
embed_model_name=embed_model_name,
@@ -207,6 +233,10 @@ def _service_command(
207233
candidate_k: opts.CandidateKOption = None,
208234
page_dedup: opts.PageDedupOption = False,
209235
content_types: opts.ContentTypesOption = None,
236+
source_id: opts.SourceIdOption = None,
237+
source: opts.SourceOption = None,
238+
page_number: opts.PageNumberOption = None,
239+
where: opts.WhereOption = None,
210240
output_format: opts.OutputFormatOption = "hits",
211241
max_text_chars: opts.MaxTextCharsOption = None,
212242
) -> None:
@@ -223,6 +253,12 @@ def _service_command(
223253
page_dedup=page_dedup,
224254
content_types=content_types,
225255
),
256+
filters=_filter_options(
257+
source_id=source_id,
258+
source=source,
259+
page_number=page_number,
260+
where=where,
261+
),
226262
service=QueryServiceOptions(
227263
service_url=service_url,
228264
service_api_token=service_api_token,

nemo_retriever/src/nemo_retriever/cli/query/options.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,35 @@
3636
help="Comma-separated content types to keep, such as text,table; untyped hits are excluded.",
3737
),
3838
]
39+
SourceIdOption = Annotated[
40+
str | None,
41+
typer.Option(
42+
"--source-id",
43+
help="Only search chunks from this source identifier.",
44+
),
45+
]
46+
SourceOption = Annotated[
47+
str | None,
48+
typer.Option(
49+
"--source",
50+
help="Only search chunks from this source name or identifier.",
51+
),
52+
]
53+
PageNumberOption = Annotated[
54+
int | None,
55+
typer.Option(
56+
"--page-number",
57+
min=0,
58+
help="Only search chunks from this page number.",
59+
),
60+
]
61+
WhereOption = Annotated[
62+
str | None,
63+
typer.Option(
64+
"--where",
65+
help="Advanced: raw LanceDB SQL predicate appended to structured query filters.",
66+
),
67+
]
3968
LanceDbUriOption = Annotated[
4069
str,
4170
typer.Option(
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES.
2+
# All rights reserved.
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
"""Translate root query filter options into LanceDB's native ``where`` predicate.
6+
7+
LanceDB owns predicate execution. This module only maps stable root query
8+
options such as source and page filters onto the table columns used by both
9+
older local indexes and the VectorDB service, where ``metadata`` and ``source``
10+
are JSON strings at rest.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
import json
16+
from typing import Any
17+
18+
from nemo_retriever.query.options import QueryFilterOptions
19+
20+
21+
def _clean_str(value: str | None) -> str | None:
22+
cleaned = (value or "").strip()
23+
return cleaned or None
24+
25+
26+
def _sql_string(value: str) -> str:
27+
return "'" + value.replace("'", "''") + "'"
28+
29+
30+
def _like_clause(column: str, pattern: str, *, escape: bool = False) -> str:
31+
clause = f"{column} LIKE {_sql_string(pattern)}"
32+
if escape:
33+
clause += " ESCAPE '\\'"
34+
return clause
35+
36+
37+
def _like_pattern_literal(value: str) -> str:
38+
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
39+
40+
41+
def _json_member_patterns(key: str, value: str) -> list[str]:
42+
encoded_value = _like_pattern_literal(json.dumps(value, ensure_ascii=False))
43+
return [
44+
f'%"{key}":{encoded_value}%',
45+
f'%"{key}": {encoded_value}%',
46+
]
47+
48+
49+
def _source_json_member_clause(key: str, value: str) -> str:
50+
return " OR ".join(_like_clause("source", pattern, escape=True) for pattern in _json_member_patterns(key, value))
51+
52+
53+
def _source_id_clause(source_id: str) -> str:
54+
clauses = [
55+
_source_json_member_clause("source_id", source_id),
56+
f"source = {_sql_string(source_id)}",
57+
]
58+
return "(" + " OR ".join(clauses) + ")"
59+
60+
61+
def _source_clause(source: str) -> str:
62+
clauses = [
63+
_source_json_member_clause("source_id", source),
64+
_source_json_member_clause("source_name", source),
65+
f"source = {_sql_string(source)}",
66+
]
67+
return "(" + " OR ".join(clauses) + ")"
68+
69+
70+
def _page_number_clause(page_number: int) -> str:
71+
page = int(page_number)
72+
if page < 0:
73+
raise ValueError("page_number must be greater than or equal to 0.")
74+
patterns = [
75+
f'%"page_number":{page},%',
76+
f'%"page_number":{page}}}%',
77+
f'%"page_number": {page},%',
78+
f'%"page_number": {page}}}%',
79+
f'%"page_number":"{page}"%',
80+
f'%"page_number": "{page}"%',
81+
]
82+
return "(" + " OR ".join(_like_clause("metadata", pattern) for pattern in patterns) + ")"
83+
84+
85+
def build_query_where_clause(options: QueryFilterOptions) -> str | None:
86+
"""Build the LanceDB predicate for root query filters."""
87+
clauses: list[str] = []
88+
89+
source_id = _clean_str(options.source_id)
90+
if source_id is not None:
91+
clauses.append(_source_id_clause(source_id))
92+
93+
source = _clean_str(options.source)
94+
if source is not None:
95+
clauses.append(_source_clause(source))
96+
97+
if options.page_number is not None:
98+
clauses.append(_page_number_clause(options.page_number))
99+
100+
where = _clean_str(options.where)
101+
if where is not None:
102+
clauses.append(where if not clauses else f"({where})")
103+
104+
if not clauses:
105+
return None
106+
return " AND ".join(clauses)
107+
108+
109+
def query_filter_payload(options: QueryFilterOptions) -> dict[str, Any]:
110+
"""Return the public service query filter payload for non-empty fields."""
111+
payload: dict[str, Any] = {}
112+
113+
source_id = _clean_str(options.source_id)
114+
if source_id is not None:
115+
payload["source_id"] = source_id
116+
117+
source = _clean_str(options.source)
118+
if source is not None:
119+
payload["source"] = source
120+
121+
if options.page_number is not None:
122+
page_number = int(options.page_number)
123+
if page_number < 0:
124+
raise ValueError("page_number must be greater than or equal to 0.")
125+
payload["page_number"] = page_number
126+
127+
where = _clean_str(options.where)
128+
if where is not None:
129+
payload["where"] = where
130+
131+
return payload

nemo_retriever/src/nemo_retriever/query/options.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,14 @@ class QueryRetrievalOptions:
1919
hybrid: bool = False
2020

2121

22+
@dataclass(frozen=True)
23+
class QueryFilterOptions:
24+
source_id: str | None = None
25+
source: str | None = None
26+
page_number: int | None = None
27+
where: str | None = None
28+
29+
2230
@dataclass(frozen=True)
2331
class QueryEmbedOptions:
2432
embed_invoke_url: str | None = None
@@ -50,6 +58,7 @@ class QueryServiceOptions:
5058
class QueryRequest:
5159
query: str
5260
retrieval: QueryRetrievalOptions = field(default_factory=QueryRetrievalOptions)
61+
filters: QueryFilterOptions = field(default_factory=QueryFilterOptions)
5362
embed: QueryEmbedOptions = field(default_factory=QueryEmbedOptions)
5463
rerank: QueryRerankOptions = field(default_factory=QueryRerankOptions)
5564
storage: QueryStorageOptions = field(default_factory=QueryStorageOptions)
@@ -59,4 +68,5 @@ class QueryRequest:
5968
class ServiceQueryRequest:
6069
query: str
6170
retrieval: QueryRetrievalOptions = field(default_factory=QueryRetrievalOptions)
71+
filters: QueryFilterOptions = field(default_factory=QueryFilterOptions)
6272
service: QueryServiceOptions = field(default_factory=QueryServiceOptions)

nemo_retriever/src/nemo_retriever/query/service.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ def query_documents(request: ServiceQueryRequest) -> list[RetrievalHit]:
2727
base_url=request.service.service_url,
2828
api_token=request.service.service_api_token,
2929
)
30-
raw_result_sets = client.query(request.query, top_k=retrieval_top_k)
30+
raw_result_sets = client.query(request.query, top_k=retrieval_top_k, filters=request.filters)
3131
raw_hits = raw_result_sets[0]
3232
return shape_query_hits(
3333
raw_hits,

nemo_retriever/src/nemo_retriever/query/workflow.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing import Any
88

99
from nemo_retriever.common.params import build_embed_option_kwargs
10+
from nemo_retriever.query.filters import build_query_where_clause
1011
from nemo_retriever.query.options import QueryRequest, QueryRerankOptions
1112
from nemo_retriever.graph.retriever import Retriever
1213
from nemo_retriever.common.remote_auth import resolve_remote_api_key
@@ -59,9 +60,12 @@ def _build_retriever_kwargs(request: QueryRequest) -> dict[str, Any]:
5960
def query_documents(request: QueryRequest) -> list[RetrievalHit]:
6061
"""Run the SDK query path used by the root CLI."""
6162
retriever = Retriever(**_build_retriever_kwargs(request))
62-
return retriever.query(
63-
request.query,
64-
candidate_k=request.retrieval.candidate_k,
65-
page_dedup=request.retrieval.page_dedup,
66-
content_types=request.retrieval.content_types,
67-
)
63+
query_kwargs: dict[str, Any] = {
64+
"candidate_k": request.retrieval.candidate_k,
65+
"page_dedup": request.retrieval.page_dedup,
66+
"content_types": request.retrieval.content_types,
67+
}
68+
where_clause = build_query_where_clause(request.filters)
69+
if where_clause is not None:
70+
query_kwargs["vdb_kwargs"] = {"where": where_clause}
71+
return retriever.query(request.query, **query_kwargs)

nemo_retriever/src/nemo_retriever/service/client.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@
5050
TimeRemainingColumn,
5151
)
5252

53+
from nemo_retriever.query.filters import query_filter_payload
54+
from nemo_retriever.query.options import QueryFilterOptions
5355
from nemo_retriever.service.query_schema import QueryResponse
5456

5557
logger = logging.getLogger(__name__)
@@ -216,11 +218,19 @@ def _auth_headers(self) -> dict[str, str]:
216218
# Query
217219
# ------------------------------------------------------------------
218220

219-
def query(self, query: str | list[str], *, top_k: int) -> list[list[dict[str, Any]]]:
221+
def query(
222+
self,
223+
query: str | list[str],
224+
*,
225+
top_k: int,
226+
filters: QueryFilterOptions | None = None,
227+
) -> list[list[dict[str, Any]]]:
220228
"""Search ingested documents through ``POST /v1/query``."""
221229
url = f"{self._base_url}/v1/query"
222230
expected_results = len(query) if isinstance(query, list) else 1
223231
payload: dict[str, Any] = {"query": query, "top_k": int(top_k)}
232+
if filters is not None:
233+
payload.update(query_filter_payload(filters))
224234
try:
225235
with httpx.Client(
226236
timeout=httpx.Timeout(300.0, connect=30.0),

nemo_retriever/src/nemo_retriever/service/query_schema.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@
1212
class QueryRequest(BaseModel):
1313
query: str | list[str]
1414
top_k: int = Field(default=10, ge=1, le=1000)
15+
source_id: str | None = None
16+
source: str | None = None
17+
page_number: int | None = Field(default=None, ge=0)
18+
where: str | None = None
1519

1620

1721
class QueryResult(BaseModel):

0 commit comments

Comments
 (0)