Skip to content

Commit c016db1

Browse files
committed
Add indexed_only and quantization support to search parameters
1 parent 6fb9063 commit c016db1

7 files changed

Lines changed: 150 additions & 4 deletions

File tree

docs/search.md

Lines changed: 3 additions & 2 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, mmr_diversity: <0..1>, mmr_candidates: <n> }
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> }, mmr_diversity: <0..1>, mmr_candidates: <n> }
2121
SEARCH <collection_name> SIMILAR TO '<query_text>' LIMIT <n> [USING ...] [WHERE <filter>] RERANK [MODEL '<reranker_model>']
2222
```
2323

@@ -107,11 +107,12 @@ Use these when you want to debug retrieval quality or tune recall without changi
107107
| `WITH { hnsw_ef: 128 }` | Increase HNSW exploration at query time |
108108
| `WITH { exact: true }` | Force exact KNN explicitly |
109109
| `WITH { acorn: true }` | Enable ACORN for filtered queries |
110+
| `WITH { indexed_only: true, quantization: { rescore: true } }` | Prefer indexed vectors and apply quantization controls |
110111
| `WITH { mmr_diversity: 0.5, mmr_candidates: 50 }` | Apply native MMR diversification after nearest-neighbor retrieval |
111112

112113
- `EXACT` can appear after `LIMIT` or after `RERANK`
113114
- `WITH { ... }` can appear after `WHERE` and/or `RERANK`
114-
- Supported `WITH` keys are `hnsw_ef`, `exact`, `acorn`, `mmr_diversity`, and `mmr_candidates`
115+
- Supported `WITH` keys are `hnsw_ef`, `exact`, `acorn`, `indexed_only`, `quantization`, `mmr_diversity`, and `mmr_candidates`
115116
- MMR is currently supported for dense `SEARCH` and dense `SEARCH ... GROUP BY`
116117
- MMR is not yet supported with `USING HYBRID`, `USING SPARSE`, or `RECOMMEND`
117118

src/qql/ast_nodes.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,19 @@ 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
3032
mmr_diversity: float | None = None
3133
mmr_candidates: int | None = None
3234

3335

36+
@dataclass(frozen=True)
37+
class QuantizationSearchWith:
38+
ignore: bool | None = None
39+
rescore: bool | None = None
40+
oversampling: float | None = None
41+
42+
3443
# ── Filter expression leaf nodes ──────────────────────────────────────────────
3544

3645
@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>, mmr_diversity: <0..1>, mmr_candidates: <int> } search parameters
73+
Optional: [yellow]WITH[/yellow] { hnsw_ef: <int>, exact: <bool>, acorn: <bool>, indexed_only: <bool>, quantization: { ignore: <bool>, rescore: <bool>, oversampling: <n> }, mmr_diversity: <0..1>, mmr_candidates: <int> } 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: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
Prefetch,
3838
ProductQuantization,
3939
ProductQuantizationConfig,
40+
QuantizationSearchParams,
4041
Range,
4142
RecommendInput,
4243
RecommendQuery,
@@ -830,9 +831,18 @@ def _execute_recommend(self, node: RecommendStmt) -> ExecutionResult:
830831
def _build_search_params(self, with_clause: SearchWith | None) -> SearchParams | None:
831832
if with_clause is None:
832833
return None
834+
quantization = None
835+
if with_clause.quantization is not None:
836+
quantization = QuantizationSearchParams(
837+
ignore=with_clause.quantization.ignore,
838+
rescore=with_clause.quantization.rescore,
839+
oversampling=with_clause.quantization.oversampling,
840+
)
833841
return SearchParams(
834842
hnsw_ef=with_clause.hnsw_ef,
835843
exact=with_clause.exact,
844+
quantization=quantization,
845+
indexed_only=True if with_clause.indexed_only else None,
836846
acorn=AcornSearchParams(enable=True) if with_clause.acorn else None,
837847
)
838848

src/qql/parser.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
OrExpr,
2626
QuantizationConfig,
2727
QuantizationType,
28+
QuantizationSearchWith,
2829
RecommendStmt,
2930
SelectStmt,
3031
ScrollStmt,
@@ -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
mmr_diversity=with_clause.mmr_diversity,
418421
mmr_candidates=with_clause.mmr_candidates,
419422
)
@@ -427,6 +430,8 @@ def _parse_search(self) -> SearchStmt:
427430
hnsw_ef=parsed_with.hnsw_ef or with_clause.hnsw_ef,
428431
exact=parsed_with.exact or with_clause.exact,
429432
acorn=parsed_with.acorn or with_clause.acorn,
433+
indexed_only=parsed_with.indexed_only or with_clause.indexed_only,
434+
quantization=parsed_with.quantization or with_clause.quantization,
430435
mmr_diversity=(
431436
parsed_with.mmr_diversity
432437
if parsed_with.mmr_diversity is not None
@@ -957,6 +962,8 @@ def _parse_with_clause(self) -> SearchWith:
957962
hnsw_ef: int | None = None
958963
exact: bool = False
959964
acorn: bool = False
965+
indexed_only: bool = False
966+
quantization: QuantizationSearchWith | None = None
960967
mmr_diversity: float | None = None
961968
mmr_candidates: int | None = None
962969
while self._peek().kind != TokenKind.RBRACE:
@@ -979,6 +986,10 @@ def _parse_with_clause(self) -> SearchWith:
979986
exact = self._parse_bool()
980987
elif key == "acorn":
981988
acorn = self._parse_bool()
989+
elif key == "indexed_only":
990+
indexed_only = self._parse_bool()
991+
elif key == "quantization":
992+
quantization = self._parse_quantization_search_with()
982993
elif key == "mmr_diversity":
983994
mmr_diversity = float(self._parse_number())
984995
if not 0.0 <= mmr_diversity <= 1.0:
@@ -996,7 +1007,7 @@ def _parse_with_clause(self) -> SearchWith:
9961007
else:
9971008
raise QQLSyntaxError(
9981009
"Unknown WITH parameter "
999-
f"'{key}'. Expected: hnsw_ef, exact, acorn, mmr_diversity, mmr_candidates",
1010+
f"'{key}'. Expected: hnsw_ef, exact, acorn, indexed_only, quantization, mmr_diversity, mmr_candidates",
10001011
key_tok.pos,
10011012
)
10021013
if self._peek().kind == TokenKind.COMMA:
@@ -1010,10 +1021,48 @@ def _parse_with_clause(self) -> SearchWith:
10101021
hnsw_ef=hnsw_ef,
10111022
exact=exact,
10121023
acorn=acorn,
1024+
indexed_only=indexed_only,
1025+
quantization=quantization,
10131026
mmr_diversity=mmr_diversity,
10141027
mmr_candidates=mmr_candidates,
10151028
)
10161029

1030+
def _parse_quantization_search_with(self) -> QuantizationSearchWith:
1031+
self._expect(TokenKind.LBRACE)
1032+
ignore: bool | None = None
1033+
rescore: bool | None = None
1034+
oversampling: float | None = None
1035+
1036+
while self._peek().kind != TokenKind.RBRACE:
1037+
key_tok = self._expect(TokenKind.IDENTIFIER)
1038+
key = key_tok.value.lower()
1039+
self._expect(TokenKind.COLON)
1040+
if key == "ignore":
1041+
ignore = self._parse_bool()
1042+
elif key == "rescore":
1043+
rescore = self._parse_bool()
1044+
elif key == "oversampling":
1045+
oversampling = float(self._parse_number())
1046+
else:
1047+
raise QQLSyntaxError(
1048+
"Unknown QUANTIZATION parameter "
1049+
f"'{key}'. Expected: ignore, rescore, oversampling",
1050+
key_tok.pos,
1051+
)
1052+
if self._peek().kind == TokenKind.COMMA:
1053+
self._advance()
1054+
if self._peek().kind == TokenKind.RBRACE:
1055+
break
1056+
else:
1057+
break
1058+
1059+
self._expect(TokenKind.RBRACE)
1060+
return QuantizationSearchWith(
1061+
ignore=ignore,
1062+
rescore=rescore,
1063+
oversampling=oversampling,
1064+
)
1065+
10171066
def _parse_bool(self) -> bool:
10181067
tok = self._peek()
10191068
if tok.kind == TokenKind.IDENTIFIER:

tests/test_executor.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
InsertBulkStmt,
99
InsertStmt,
1010
QuantizationConfig,
11+
QuantizationSearchWith,
1112
QuantizationType,
1213
RecommendStmt,
1314
SelectStmt,
@@ -728,6 +729,37 @@ def test_search_with_acorn_forwards_search_params(
728729
assert search_params.hnsw_ef == 128
729730
assert search_params.acorn.enable is True
730731

732+
def test_search_with_indexed_only_and_quantization_forwards_search_params(
733+
self, executor, mock_client, mocker
734+
):
735+
mock_client.collection_exists.return_value = True
736+
mock_response = mocker.MagicMock()
737+
mock_response.points = []
738+
mock_client.query_points.return_value = mock_response
739+
740+
node = SearchStmt(
741+
collection="notes",
742+
query_text="hello",
743+
limit=5,
744+
model=None,
745+
with_clause=SearchWith(
746+
indexed_only=True,
747+
quantization=QuantizationSearchWith(
748+
ignore=True,
749+
rescore=False,
750+
oversampling=2.5,
751+
),
752+
),
753+
)
754+
executor.execute(node)
755+
756+
search_params = mock_client.query_points.call_args.kwargs["search_params"]
757+
assert search_params.indexed_only is True
758+
assert search_params.quantization is not None
759+
assert search_params.quantization.ignore is True
760+
assert search_params.quantization.rescore is False
761+
assert search_params.quantization.oversampling == pytest.approx(2.5)
762+
731763
def test_dense_search_against_hybrid_collection_uses_dense_vector_name(
732764
self, executor, mock_client, mocker
733765
):
@@ -989,6 +1021,30 @@ def test_recommend_forwards_search_params(self, executor, mock_client, mocker):
9891021
assert search_params.exact is True
9901022
assert search_params.hnsw_ef == 128
9911023

1024+
def test_recommend_forwards_indexed_only_and_quantization(
1025+
self, executor, mock_client, mocker
1026+
):
1027+
mock_client.collection_exists.return_value = True
1028+
mock_response = mocker.MagicMock()
1029+
mock_response.points = []
1030+
mock_client.query_points.return_value = mock_response
1031+
1032+
node = RecommendStmt(
1033+
collection="notes",
1034+
positive_ids=("a",),
1035+
limit=5,
1036+
with_clause=SearchWith(
1037+
indexed_only=True,
1038+
quantization=QuantizationSearchWith(rescore=True),
1039+
),
1040+
)
1041+
executor.execute(node)
1042+
1043+
search_params = mock_client.query_points.call_args.kwargs["search_params"]
1044+
assert search_params.indexed_only is True
1045+
assert search_params.quantization is not None
1046+
assert search_params.quantization.rescore is True
1047+
9921048
def test_recommend_with_mmr_raises(self, executor, mock_client):
9931049
mock_client.collection_exists.return_value = True
9941050
node = RecommendStmt(

tests/test_parser.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
NotInExpr,
2323
OrExpr,
2424
QuantizationType,
25+
QuantizationSearchWith,
2526
RecommendStmt,
2627
SelectStmt,
2728
ScrollStmt,
@@ -892,6 +893,22 @@ def test_with_acorn(self):
892893
assert node.with_clause is not None
893894
assert node.with_clause.acorn is True
894895

896+
def test_with_indexed_only(self):
897+
node = parse("SEARCH col SIMILAR TO 'q' LIMIT 5 WITH { indexed_only: true }")
898+
assert node.with_clause is not None
899+
assert node.with_clause.indexed_only is True
900+
901+
def test_with_quantization(self):
902+
node = parse(
903+
"SEARCH col SIMILAR TO 'q' LIMIT 5 "
904+
"WITH { quantization: { ignore: true, rescore: false, oversampling: 2 } }"
905+
)
906+
assert node.with_clause is not None
907+
assert node.with_clause.quantization is not None
908+
assert node.with_clause.quantization.ignore is True
909+
assert node.with_clause.quantization.rescore is False
910+
assert node.with_clause.quantization.oversampling == pytest.approx(2.0)
911+
895912
def test_with_multiple_params(self):
896913
node = parse(
897914
"SEARCH col SIMILAR TO 'q' LIMIT 5 WITH { hnsw_ef: 256, acorn: true }"
@@ -946,6 +963,10 @@ def test_with_mmr_candidates_non_positive_raises(self):
946963
with pytest.raises(QQLSyntaxError, match="mmr_candidates must be a positive integer"):
947964
parse("SEARCH col SIMILAR TO 'q' LIMIT 5 WITH { mmr_candidates: 0 }")
948965

966+
def test_with_quantization_unknown_key_raises(self):
967+
with pytest.raises(QQLSyntaxError):
968+
parse("SEARCH col SIMILAR TO 'q' LIMIT 5 WITH { quantization: { unknown: true } }")
969+
949970
def test_with_trailing_comma(self):
950971
node = parse("SEARCH col SIMILAR TO 'q' LIMIT 5 WITH { hnsw_ef: 256, }")
951972
assert node.with_clause.hnsw_ef == 256

0 commit comments

Comments
 (0)