Skip to content

Commit 14e31cb

Browse files
committed
Add support for indexed_only and quantization parameters in search queries
- Introduced `indexed_only` and `quantization` options in the `WITH` clause for search statements. - Updated relevant documentation to reflect new search capabilities. - Enhanced parser and executor to handle new parameters. - Added tests to ensure correct functionality of new features.
1 parent 8a754c3 commit 14e31cb

10 files changed

Lines changed: 243 additions & 16 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,11 @@ INSERT BULK INTO COLLECTION articles VALUES [{'text': '...'}, {'text': '...'}]
101101
-- Search
102102
SEARCH articles SIMILAR TO 'query' LIMIT 10
103103
SEARCH articles SIMILAR TO 'query' LIMIT 10 WHERE year >= 2020
104+
SEARCH articles SIMILAR TO 'query' LIMIT 10 WHERE active = true
104105
SEARCH articles SIMILAR TO 'query' LIMIT 10 USING HYBRID
105106
SEARCH articles SIMILAR TO 'query' LIMIT 10 USING HYBRID FUSION 'dbsf'
107+
SEARCH articles SIMILAR TO 'query' LIMIT 10 WITH { indexed_only: true }
108+
SEARCH articles SIMILAR TO 'query' LIMIT 10 WITH { quantization: { ignore: true, oversampling: 2 } }
106109
SEARCH articles SIMILAR TO 'query' LIMIT 10 USING HYBRID RERANK
107110

108111
-- Scroll

docs/filters.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ The `WHERE` clause lets you filter on any payload field using SQL-style predicat
1212
-- Exact match
1313
SEARCH articles SIMILAR TO 'ml' LIMIT 10 WHERE category = 'paper'
1414

15+
-- Boolean match
16+
SEARCH articles SIMILAR TO 'ml' LIMIT 10 WHERE active = true
17+
1518
-- Not equal
1619
SEARCH articles SIMILAR TO 'ml' LIMIT 10 WHERE status != 'draft'
1720
```
@@ -43,6 +46,7 @@ SEARCH articles SIMILAR TO 'history of ai' LIMIT 10 WHERE year BETWEEN 2018 AND
4346
```sql
4447
SEARCH articles SIMILAR TO 'retrieval' LIMIT 10 WHERE status IN ('published', 'reviewed')
4548
SEARCH articles SIMILAR TO 'retrieval' LIMIT 10 WHERE status NOT IN ('deleted', 'archived')
49+
SEARCH articles SIMILAR TO 'retrieval' LIMIT 10 WHERE active IN (true, false)
4650
```
4751

4852
---

docs/programmatic.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ class ExecutionResult:
138138
| INSERT BULK | `None` (count in `result.message`) |
139139
| SELECT | `{"id": str, "payload": dict}` or `None` when not found |
140140
| SEARCH | `[{"id": str, "score": float, "payload": dict}, ...]` |
141-
| SCROLL | `{"points": [{"id": str, "payload": dict}, ...], "next_offset": str \| None}` |
141+
| SCROLL | `{"points": [{"id": str, "payload": dict}, ...], "next_offset": str \| int \| None}` |
142142
| RECOMMEND | `[{"id": str, "score": float, "payload": dict}, ...]` |
143143
| SHOW COLLECTIONS | `["name1", "name2", ...]` |
144144
| SHOW COLLECTION | `{"name": str, "status": str, "points_count": int \| None, "indexed_vectors_count": int \| None, "segments_count": int, "topology": str, "vectors": dict, "sparse_vectors": dict \| None, "quantization": str \| None, "hnsw_config": dict, "payload_schema": dict \| None, "sharding": dict}` |

docs/search.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ SEARCH <collection_name> SIMILAR TO '<query_text>' LIMIT <n> USING HYBRID
1717
SEARCH <collection_name> SIMILAR TO '<query_text>' LIMIT <n> USING HYBRID [FUSION 'rrf|dbsf'] [DENSE MODEL '<model>'] [SPARSE MODEL '<model>'] [WHERE <filter>]
1818
SEARCH <collection_name> SIMILAR TO '<query_text>' LIMIT <n> USING SPARSE [MODEL '<sparse_model>']
1919
SEARCH <collection_name> SIMILAR TO '<query_text>' LIMIT <n> EXACT
20-
SEARCH <collection_name> SIMILAR TO '<query_text>' LIMIT <n> [USING ...] [WHERE <filter>] [RERANK] WITH { hnsw_ef: <n>, exact: true|false, acorn: true|false }
20+
SEARCH <collection_name> SIMILAR TO '<query_text>' LIMIT <n> [USING ...] [WHERE <filter>] [RERANK] WITH { hnsw_ef: <n>, exact: true|false, acorn: true|false, indexed_only: true|false, quantization: { ignore: true|false, rescore: true|false, oversampling: <n> } }
2121
SEARCH <collection_name> SIMILAR TO '<query_text>' LIMIT <n> [USING ...] [WHERE <filter>] RERANK [MODEL '<reranker_model>']
2222
```
2323

@@ -102,10 +102,12 @@ Use these when you want to debug retrieval quality or tune recall without changi
102102
| `WITH { hnsw_ef: 128 }` | Increase HNSW exploration at query time |
103103
| `WITH { exact: true }` | Force exact KNN explicitly |
104104
| `WITH { acorn: true }` | Enable ACORN for filtered queries |
105+
| `WITH { indexed_only: true }` | Restrict the query to indexed segments only |
106+
| `WITH { quantization: { ... } }` | Tune quantized-search behavior at query time |
105107

106108
- `EXACT` can appear after `LIMIT` or after `RERANK`
107109
- `WITH { ... }` can appear after `WHERE` and/or `RERANK`
108-
- Supported `WITH` keys are only `hnsw_ef`, `exact`, and `acorn`
110+
- Supported top-level `WITH` keys are `hnsw_ef`, `exact`, `acorn`, `indexed_only`, and `quantization`
109111

110112
```sql
111113
-- Exact KNN baseline
@@ -116,6 +118,12 @@ SEARCH articles SIMILAR TO 'transformers' LIMIT 10 WITH { hnsw_ef: 256 }
116118

117119
-- Filtered search with ACORN
118120
SEARCH articles SIMILAR TO 'RAG' LIMIT 10 WHERE tag = 'li' WITH { acorn: true }
121+
122+
-- Restrict to indexed segments only
123+
SEARCH articles SIMILAR TO 'retrieval' LIMIT 10 WITH { indexed_only: true }
124+
125+
-- Quantized-search tuning
126+
SEARCH articles SIMILAR TO 'vector db' LIMIT 10 WITH { quantization: { ignore: true, oversampling: 2 } }
119127
```
120128

121129
---
@@ -142,6 +150,7 @@ SCROLL FROM articles AFTER 'cursor-id' LIMIT 50
142150
**Behavior:**
143151
- Returns points in ID order with payloads.
144152
- Returns a `next_offset` cursor when more points are available.
153+
- `next_offset` preserves the native point-id type (`string` or integer).
145154
- Use `AFTER <next_offset>` to fetch the next page.
146155

147156
---
@@ -230,7 +239,7 @@ RECOMMEND FROM <collection_name> POSITIVE IDS (<id>, ...) STRATEGY '<strategy>'
230239
RECOMMEND FROM <collection_name> POSITIVE IDS (<id>, ...) LIMIT <n> WHERE <filter>
231240
RECOMMEND FROM <collection_name> POSITIVE IDS (<id>, ...) LIMIT <n> OFFSET <n>
232241
RECOMMEND FROM <collection_name> POSITIVE IDS (<id>, ...) LIMIT <n> SCORE THRESHOLD <f>
233-
RECOMMEND FROM <collection_name> POSITIVE IDS (<id>, ...) LIMIT <n> WITH { exact: true, hnsw_ef: <n> }
242+
RECOMMEND FROM <collection_name> POSITIVE IDS (<id>, ...) LIMIT <n> WITH { exact: true, hnsw_ef: <n>, indexed_only: true|false, quantization: { ignore: true|false, rescore: true|false, oversampling: <n> } }
234243
RECOMMEND FROM <collection_name> POSITIVE IDS (<id>, ...) LIMIT <n> LOOKUP FROM <collection>
235244
RECOMMEND FROM <collection_name> POSITIVE IDS (<id>, ...) LIMIT <n> LOOKUP FROM <collection> VECTOR '<name>'
236245
RECOMMEND FROM <collection_name> POSITIVE IDS (<id>, ...) LIMIT <n> USING '<vector_name>'

src/qql/ast_nodes.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ class SearchWith:
2727
hnsw_ef: int | None = None
2828
exact: bool = False
2929
acorn: bool = False
30+
indexed_only: bool = False
31+
quantization: "QuantizationSearchWith | None" = None
32+
33+
34+
@dataclass(frozen=True)
35+
class QuantizationSearchWith:
36+
ignore: bool | None = None
37+
rescore: bool | None = None
38+
oversampling: float | None = None
3039

3140

3241
# ── Filter expression leaf nodes ──────────────────────────────────────────────
@@ -36,7 +45,7 @@ class CompareExpr:
3645
"""field op literal — covers =, !=, >, >=, <, <="""
3746
field: str
3847
op: str # one of: "=", "!=", ">", ">=", "<", "<="
39-
value: str | int | float
48+
value: str | int | float | bool
4049

4150

4251
@dataclass(frozen=True)
@@ -51,14 +60,14 @@ class BetweenExpr:
5160
class InExpr:
5261
"""field IN (v1, v2, ...)"""
5362
field: str
54-
values: tuple[str | int | float, ...]
63+
values: tuple[str | int | float | bool, ...]
5564

5665

5766
@dataclass(frozen=True)
5867
class NotInExpr:
5968
"""field NOT IN (v1, v2, ...)"""
6069
field: str
61-
values: tuple[str | int | float, ...]
70+
values: tuple[str | int | float | bool, ...]
6271

6372

6473
@dataclass(frozen=True)

src/qql/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@
7070
Optional: [yellow]WHERE[/yellow] <filter> (e.g. WHERE year > 2020 AND status = 'ok')
7171
Optional: [yellow]RERANK[/yellow] [MODEL '<model>'] rerank results with a cross-encoder
7272
Optional: [yellow]EXACT[/yellow] bypass HNSW and perform exact search
73-
Optional: [yellow]WITH[/yellow] { hnsw_ef: <int>, exact: <bool>, acorn: <bool> } search parameters
73+
Optional: [yellow]WITH[/yellow] { hnsw_ef: <int>, exact: <bool>, acorn: <bool>, indexed_only: <bool>, quantization: { ignore: <bool>, rescore: <bool>, oversampling: <n> } } search parameters
7474
Optional: [yellow]GROUP BY[/yellow] <field> [[yellow]GROUP_SIZE[/yellow] <n>]
7575
Group results by a payload field value (default GROUP_SIZE: 3).
7676
Field must be keyword or integer type. RERANK and GROUP BY cannot be combined.

src/qql/executor.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
Prefetch,
3636
ProductQuantization,
3737
ProductQuantizationConfig,
38+
QuantizationSearchParams,
3839
Range,
3940
RecommendInput,
4041
RecommendQuery,
@@ -559,7 +560,7 @@ def _execute_scroll(self, node: ScrollStmt) -> ExecutionResult:
559560
return ExecutionResult(
560561
success=True,
561562
message=f"Scrolled {len(points)} point(s) from '{node.collection}'",
562-
data={"points": points, "next_offset": None if next_offset is None else str(next_offset)},
563+
data={"points": points, "next_offset": next_offset},
563564
)
564565

565566
def _execute_select(self, node: SelectStmt) -> ExecutionResult:
@@ -678,6 +679,7 @@ def _execute_search(self, node: SearchStmt) -> ExecutionResult:
678679
using="sparse",
679680
limit=fetch_limit,
680681
query_filter=qdrant_filter,
682+
search_params=search_params,
681683
)
682684
except UnexpectedResponse as e:
683685
raise QQLRuntimeError(f"Qdrant error during SEARCH: {e}") from e
@@ -825,9 +827,18 @@ def _execute_recommend(self, node: RecommendStmt) -> ExecutionResult:
825827
def _build_search_params(self, with_clause: SearchWith | None) -> SearchParams | None:
826828
if with_clause is None:
827829
return None
830+
quantization = None
831+
if with_clause.quantization is not None:
832+
quantization = QuantizationSearchParams(
833+
ignore=with_clause.quantization.ignore,
834+
rescore=with_clause.quantization.rescore,
835+
oversampling=with_clause.quantization.oversampling,
836+
)
828837
return SearchParams(
829838
hnsw_ef=with_clause.hnsw_ef,
830839
exact=with_clause.exact,
840+
quantization=quantization,
841+
indexed_only=True if with_clause.indexed_only else None,
831842
acorn=AcornSearchParams(enable=True) if with_clause.acorn else None,
832843
)
833844

src/qql/parser.py

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
NotExpr,
2424
NotInExpr,
2525
OrExpr,
26+
QuantizationSearchWith,
2627
QuantizationConfig,
2728
QuantizationType,
2829
RecommendStmt,
@@ -414,6 +415,8 @@ def _parse_search(self) -> SearchStmt:
414415
hnsw_ef=with_clause.hnsw_ef,
415416
exact=True,
416417
acorn=with_clause.acorn,
418+
indexed_only=with_clause.indexed_only,
419+
quantization=with_clause.quantization,
417420
)
418421
if self._peek().kind == TokenKind.WITH:
419422
self._advance() # consume WITH
@@ -425,6 +428,8 @@ def _parse_search(self) -> SearchStmt:
425428
hnsw_ef=parsed_with.hnsw_ef or with_clause.hnsw_ef,
426429
exact=parsed_with.exact or with_clause.exact,
427430
acorn=parsed_with.acorn or with_clause.acorn,
431+
indexed_only=parsed_with.indexed_only or with_clause.indexed_only,
432+
quantization=parsed_with.quantization or with_clause.quantization,
428433
)
429434
group_by: str | None = None
430435
group_size: int = 3
@@ -760,8 +765,8 @@ def _parse_field_path(self) -> str:
760765
f"Expected a field name, got '{tok.value}'", tok.pos
761766
)
762767

763-
def _parse_literal(self) -> str | int | float:
764-
"""STRING | INTEGER | FLOAT"""
768+
def _parse_literal(self) -> str | int | float | bool:
769+
"""STRING | INTEGER | FLOAT | boolean"""
765770
tok = self._peek()
766771
if tok.kind == TokenKind.STRING:
767772
self._advance()
@@ -772,8 +777,16 @@ def _parse_literal(self) -> str | int | float:
772777
if tok.kind == TokenKind.FLOAT:
773778
self._advance()
774779
return float(tok.value)
780+
if tok.kind == TokenKind.IDENTIFIER:
781+
upper = tok.value.upper()
782+
if upper == "TRUE":
783+
self._advance()
784+
return True
785+
if upper == "FALSE":
786+
self._advance()
787+
return False
775788
raise QQLSyntaxError(
776-
f"Expected a literal value (string, integer, or float), got '{tok.value}'",
789+
f"Expected a literal value (string, integer, float, or boolean), got '{tok.value}'",
777790
tok.pos,
778791
)
779792

@@ -790,10 +803,10 @@ def _parse_number(self) -> int | float:
790803
f"Expected a number, got '{tok.value}'", tok.pos
791804
)
792805

793-
def _parse_literal_list(self) -> list[str | int | float]:
806+
def _parse_literal_list(self) -> list[str | int | float | bool]:
794807
"""'(' literal { ',' literal } [','] ')' — used by IN / NOT IN."""
795808
self._expect(TokenKind.LPAREN)
796-
items: list[str | int | float] = []
809+
items: list[str | int | float | bool] = []
797810
if self._peek().kind == TokenKind.RPAREN:
798811
self._advance()
799812
return items
@@ -942,13 +955,15 @@ def _parse_value(self) -> Any:
942955
return self._parse_list()
943956
raise QQLSyntaxError(f"Unexpected value token '{tok.value}'", tok.pos)
944957

945-
# ── WITH clause: { hnsw_ef: N, exact: true, acorn: true } ──
958+
# ── WITH clause: { hnsw_ef: N, exact: true, acorn: true, ... } ──
946959

947960
def _parse_with_clause(self) -> SearchWith:
948961
self._expect(TokenKind.LBRACE)
949962
hnsw_ef: int | None = None
950963
exact: bool = False
951964
acorn: bool = False
965+
indexed_only: bool = False
966+
quantization: QuantizationSearchWith | None = None
952967
while self._peek().kind != TokenKind.RBRACE:
953968
key_tok = self._peek()
954969
if key_tok.kind not in (
@@ -969,9 +984,14 @@ def _parse_with_clause(self) -> SearchWith:
969984
exact = self._parse_bool()
970985
elif key == "acorn":
971986
acorn = self._parse_bool()
987+
elif key == "indexed_only":
988+
indexed_only = self._parse_bool()
989+
elif key == "quantization":
990+
quantization = self._parse_quantization_search_with()
972991
else:
973992
raise QQLSyntaxError(
974-
f"Unknown WITH parameter '{key}'. Expected: hnsw_ef, exact, acorn",
993+
"Unknown WITH parameter "
994+
f"'{key}'. Expected: hnsw_ef, exact, acorn, indexed_only, quantization",
975995
key_tok.pos,
976996
)
977997
if self._peek().kind == TokenKind.COMMA:
@@ -985,6 +1005,44 @@ def _parse_with_clause(self) -> SearchWith:
9851005
hnsw_ef=hnsw_ef,
9861006
exact=exact,
9871007
acorn=acorn,
1008+
indexed_only=indexed_only,
1009+
quantization=quantization,
1010+
)
1011+
1012+
def _parse_quantization_search_with(self) -> QuantizationSearchWith:
1013+
self._expect(TokenKind.LBRACE)
1014+
ignore: bool | None = None
1015+
rescore: bool | None = None
1016+
oversampling: float | None = None
1017+
1018+
while self._peek().kind != TokenKind.RBRACE:
1019+
key_tok = self._expect(TokenKind.IDENTIFIER)
1020+
key = key_tok.value.lower()
1021+
self._expect(TokenKind.COLON)
1022+
if key == "ignore":
1023+
ignore = self._parse_bool()
1024+
elif key == "rescore":
1025+
rescore = self._parse_bool()
1026+
elif key == "oversampling":
1027+
oversampling = float(self._parse_number())
1028+
else:
1029+
raise QQLSyntaxError(
1030+
"Unknown quantization parameter "
1031+
f"'{key}'. Expected: ignore, rescore, oversampling",
1032+
key_tok.pos,
1033+
)
1034+
if self._peek().kind == TokenKind.COMMA:
1035+
self._advance()
1036+
if self._peek().kind == TokenKind.RBRACE:
1037+
break
1038+
else:
1039+
break
1040+
1041+
self._expect(TokenKind.RBRACE)
1042+
return QuantizationSearchWith(
1043+
ignore=ignore,
1044+
rescore=rescore,
1045+
oversampling=oversampling,
9881046
)
9891047

9901048
def _parse_bool(self) -> bool:

0 commit comments

Comments
 (0)