Skip to content

Commit 00f1144

Browse files
committed
Add native MMR support for dense search
1 parent 5bfa595 commit 00f1144

8 files changed

Lines changed: 180 additions & 11 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ INSERT BULK INTO COLLECTION articles VALUES [{'text': '...'}, {'text': '...'}]
102102
SEARCH articles SIMILAR TO 'query' LIMIT 10
103103
SEARCH articles SIMILAR TO 'query' LIMIT 10 WHERE year >= 2020
104104
SEARCH articles SIMILAR TO 'query' LIMIT 10 WHERE active = true
105+
SEARCH articles SIMILAR TO 'query' LIMIT 10 WITH { mmr_diversity: 0.5, mmr_candidates: 50 }
105106
SEARCH articles SIMILAR TO 'query' LIMIT 10 USING HYBRID
106107
SEARCH articles SIMILAR TO 'query' LIMIT 10 USING HYBRID FUSION 'dbsf'
107108
SEARCH articles SIMILAR TO 'query' LIMIT 10 WITH { indexed_only: true }

docs/search.md

Lines changed: 13 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, indexed_only: true|false, quantization: { ignore: true|false, rescore: true|false, oversampling: <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

@@ -55,6 +55,11 @@ Search with query-time HNSW tuning:
5555
SEARCH articles SIMILAR TO 'attention mechanism' LIMIT 10 WITH { hnsw_ef: 128 }
5656
```
5757

58+
Search with native MMR diversification:
59+
```sql
60+
SEARCH articles SIMILAR TO 'attention mechanism' LIMIT 10 WITH { mmr_diversity: 0.5, mmr_candidates: 50 }
61+
```
62+
5863
**Output:**
5964

6065
Results are displayed as a table with three columns:
@@ -104,10 +109,13 @@ Use these when you want to debug retrieval quality or tune recall without changi
104109
| `WITH { acorn: true }` | Enable ACORN for filtered queries |
105110
| `WITH { indexed_only: true }` | Restrict the query to indexed segments only |
106111
| `WITH { quantization: { ... } }` | Tune quantized-search behavior at query time |
112+
| `WITH { mmr_diversity: 0.5, mmr_candidates: 50 }` | Apply native MMR diversification after nearest-neighbor retrieval |
107113

108114
- `EXACT` can appear after `LIMIT` or after `RERANK`
109115
- `WITH { ... }` can appear after `WHERE` and/or `RERANK`
110-
- Supported top-level `WITH` keys are `hnsw_ef`, `exact`, `acorn`, `indexed_only`, and `quantization`
116+
- Supported top-level `WITH` keys are `hnsw_ef`, `exact`, `acorn`, `indexed_only`, `quantization`, `mmr_diversity`, and `mmr_candidates`
117+
- MMR is currently supported for dense `SEARCH` and dense `SEARCH ... GROUP BY`
118+
- MMR is not yet supported with `USING HYBRID`, `USING SPARSE`, or `RECOMMEND`
111119

112120
```sql
113121
-- Exact KNN baseline
@@ -124,6 +132,9 @@ SEARCH articles SIMILAR TO 'retrieval' LIMIT 10 WITH { indexed_only: true }
124132

125133
-- Quantized-search tuning
126134
SEARCH articles SIMILAR TO 'vector db' LIMIT 10 WITH { quantization: { ignore: true, oversampling: 2 } }
135+
136+
-- Diversify top-k results with native MMR
137+
SEARCH articles SIMILAR TO 'retrieval systems' LIMIT 10 WITH { mmr_diversity: 0.5, mmr_candidates: 50 }
127138
```
128139

129140
---

src/qql/ast_nodes.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ class SearchWith:
2929
acorn: bool = False
3030
indexed_only: bool = False
3131
quantization: "QuantizationSearchWith | None" = None
32+
mmr_diversity: float | None = None
33+
mmr_candidates: int | None = None
3234

3335

3436
@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>, indexed_only: <bool>, quantization: { ignore: <bool>, rescore: <bool>, oversampling: <n> } } 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: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@
2727
MatchText,
2828
MatchTextAny,
2929
MatchValue,
30+
Mmr,
3031
Modifier,
32+
NearestQuery,
3133
PayloadField,
3234
PayloadSchemaType,
3335
PointStruct,
@@ -602,6 +604,7 @@ def _execute_search(self, node: SearchStmt) -> ExecutionResult:
602604
)
603605

604606
search_params = self._build_search_params(node.with_clause)
607+
self._validate_search_mmr_usage(node)
605608

606609
# When reranking is requested, fetch more candidates so the reranker has
607610
# enough material to reorder; only `node.limit` results are returned.
@@ -712,7 +715,7 @@ def _execute_search(self, node: SearchStmt) -> ExecutionResult:
712715
query_using = self._get_dense_vector_name(node.collection)
713716
response = self._client.query_points(
714717
collection_name=node.collection,
715-
query=vector,
718+
query=self._build_dense_query(vector, node.with_clause),
716719
using=query_using,
717720
limit=fetch_limit,
718721
query_filter=qdrant_filter,
@@ -790,6 +793,8 @@ def _execute_recommend(self, node: RecommendStmt) -> ExecutionResult:
790793
)
791794

792795
search_params = self._build_search_params(node.with_clause)
796+
if self._has_mmr(node.with_clause):
797+
raise QQLRuntimeError("MMR is supported only for SEARCH statements")
793798

794799
lookup_from: LookupLocation | None = None
795800
if node.lookup_from is not None:
@@ -842,6 +847,34 @@ def _build_search_params(self, with_clause: SearchWith | None) -> SearchParams |
842847
acorn=AcornSearchParams(enable=True) if with_clause.acorn else None,
843848
)
844849

850+
def _has_mmr(self, with_clause: SearchWith | None) -> bool:
851+
return with_clause is not None and (
852+
with_clause.mmr_diversity is not None or with_clause.mmr_candidates is not None
853+
)
854+
855+
def _validate_search_mmr_usage(self, node: SearchStmt) -> None:
856+
if not self._has_mmr(node.with_clause):
857+
return
858+
if node.hybrid:
859+
raise QQLRuntimeError("MMR is not supported with USING HYBRID yet")
860+
if node.sparse_only:
861+
raise QQLRuntimeError("MMR is not supported with USING SPARSE yet")
862+
863+
def _build_dense_query(
864+
self,
865+
vector: list[float],
866+
with_clause: SearchWith | None,
867+
) -> list[float] | NearestQuery:
868+
if not self._has_mmr(with_clause):
869+
return vector
870+
return NearestQuery(
871+
nearest=vector,
872+
mmr=Mmr(
873+
diversity=with_clause.mmr_diversity,
874+
candidates_limit=with_clause.mmr_candidates,
875+
),
876+
)
877+
845878
def _parse_recommend_strategy(
846879
self, strategy: str | None
847880
) -> RecommendStrategy | None:
@@ -1029,7 +1062,7 @@ def _execute_search_groups(
10291062
response = self._client.query_points_groups(
10301063
collection_name=node.collection,
10311064
group_by=node.group_by,
1032-
query=vector,
1065+
query=self._build_dense_query(vector, node.with_clause),
10331066
using=query_using,
10341067
limit=node.limit,
10351068
group_size=node.group_size,

src/qql/parser.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -417,6 +417,8 @@ def _parse_search(self) -> SearchStmt:
417417
acorn=with_clause.acorn,
418418
indexed_only=with_clause.indexed_only,
419419
quantization=with_clause.quantization,
420+
mmr_diversity=with_clause.mmr_diversity,
421+
mmr_candidates=with_clause.mmr_candidates,
420422
)
421423
if self._peek().kind == TokenKind.WITH:
422424
self._advance() # consume WITH
@@ -430,6 +432,12 @@ def _parse_search(self) -> SearchStmt:
430432
acorn=parsed_with.acorn or with_clause.acorn,
431433
indexed_only=parsed_with.indexed_only or with_clause.indexed_only,
432434
quantization=parsed_with.quantization or with_clause.quantization,
435+
mmr_diversity=(
436+
parsed_with.mmr_diversity
437+
if parsed_with.mmr_diversity is not None
438+
else with_clause.mmr_diversity
439+
),
440+
mmr_candidates=parsed_with.mmr_candidates or with_clause.mmr_candidates,
433441
)
434442
group_by: str | None = None
435443
group_size: int = 3
@@ -964,6 +972,8 @@ def _parse_with_clause(self) -> SearchWith:
964972
acorn: bool = False
965973
indexed_only: bool = False
966974
quantization: QuantizationSearchWith | None = None
975+
mmr_diversity: float | None = None
976+
mmr_candidates: int | None = None
967977
while self._peek().kind != TokenKind.RBRACE:
968978
key_tok = self._peek()
969979
if key_tok.kind not in (
@@ -988,10 +998,24 @@ def _parse_with_clause(self) -> SearchWith:
988998
indexed_only = self._parse_bool()
989999
elif key == "quantization":
9901000
quantization = self._parse_quantization_search_with()
1001+
elif key == "mmr_diversity":
1002+
mmr_diversity = float(self._parse_number())
1003+
if not 0.0 <= mmr_diversity <= 1.0:
1004+
raise QQLSyntaxError(
1005+
f"mmr_diversity must be between 0 and 1, got {mmr_diversity}",
1006+
key_tok.pos,
1007+
)
1008+
elif key == "mmr_candidates":
1009+
mmr_candidates = int(self._expect(TokenKind.INTEGER).value)
1010+
if mmr_candidates <= 0:
1011+
raise QQLSyntaxError(
1012+
f"mmr_candidates must be a positive integer, got {mmr_candidates}",
1013+
key_tok.pos,
1014+
)
9911015
else:
9921016
raise QQLSyntaxError(
9931017
"Unknown WITH parameter "
994-
f"'{key}'. Expected: hnsw_ef, exact, acorn, indexed_only, quantization",
1018+
f"'{key}'. Expected: hnsw_ef, exact, acorn, indexed_only, quantization, mmr_diversity, mmr_candidates",
9951019
key_tok.pos,
9961020
)
9971021
if self._peek().kind == TokenKind.COMMA:
@@ -1007,6 +1031,8 @@ def _parse_with_clause(self) -> SearchWith:
10071031
acorn=acorn,
10081032
indexed_only=indexed_only,
10091033
quantization=quantization,
1034+
mmr_diversity=mmr_diversity,
1035+
mmr_candidates=mmr_candidates,
10101036
)
10111037

10121038
def _parse_quantization_search_with(self) -> QuantizationSearchWith:

tests/test_executor.py

Lines changed: 84 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -811,6 +811,55 @@ def test_dense_search_against_hybrid_collection_uses_dense_vector_name(
811811

812812
assert mock_client.query_points.call_args.kwargs["using"] == "dense"
813813

814+
def test_dense_search_with_mmr_uses_nearest_query(self, executor, mock_client, mocker):
815+
from qdrant_client.models import NearestQuery
816+
817+
mock_client.collection_exists.return_value = True
818+
mock_response = mocker.MagicMock()
819+
mock_response.points = []
820+
mock_client.query_points.return_value = mock_response
821+
822+
node = SearchStmt(
823+
collection="notes",
824+
query_text="hello",
825+
limit=5,
826+
model=None,
827+
with_clause=SearchWith(mmr_diversity=0.4, mmr_candidates=25),
828+
)
829+
executor.execute(node)
830+
831+
query = mock_client.query_points.call_args.kwargs["query"]
832+
assert isinstance(query, NearestQuery)
833+
assert query.mmr is not None
834+
assert query.mmr.diversity == pytest.approx(0.4)
835+
assert query.mmr.candidates_limit == 25
836+
837+
def test_hybrid_search_with_mmr_raises(self, executor, mock_client):
838+
mock_client.collection_exists.return_value = True
839+
node = SearchStmt(
840+
collection="notes",
841+
query_text="hello",
842+
limit=5,
843+
model=None,
844+
hybrid=True,
845+
with_clause=SearchWith(mmr_diversity=0.5),
846+
)
847+
with pytest.raises(QQLRuntimeError, match="MMR is not supported with USING HYBRID yet"):
848+
executor.execute(node)
849+
850+
def test_sparse_search_with_mmr_raises(self, executor, mock_client):
851+
mock_client.collection_exists.return_value = True
852+
node = SearchStmt(
853+
collection="notes",
854+
query_text="hello",
855+
limit=5,
856+
model=None,
857+
sparse_only=True,
858+
with_clause=SearchWith(mmr_diversity=0.5),
859+
)
860+
with pytest.raises(QQLRuntimeError, match="MMR is not supported with USING SPARSE yet"):
861+
executor.execute(node)
862+
814863

815864
class TestRecommend:
816865
def test_recommend_calls_qdrant_query_points(self, executor, mock_client, mocker):
@@ -1026,6 +1075,17 @@ def test_recommend_forwards_indexed_only_and_quantization(self, executor, mock_c
10261075
assert search_params.quantization is not None
10271076
assert search_params.quantization.rescore is True
10281077

1078+
def test_recommend_with_mmr_raises(self, executor, mock_client):
1079+
mock_client.collection_exists.return_value = True
1080+
node = RecommendStmt(
1081+
collection="notes",
1082+
positive_ids=("a",),
1083+
limit=5,
1084+
with_clause=SearchWith(mmr_diversity=0.5),
1085+
)
1086+
with pytest.raises(QQLRuntimeError, match="MMR is supported only for SEARCH statements"):
1087+
executor.execute(node)
1088+
10291089
def test_recommend_offset_zero_passes_none(self, executor, mock_client, mocker):
10301090
mock_client.collection_exists.return_value = True
10311091
mock_response = mocker.MagicMock()
@@ -2268,12 +2328,35 @@ def test_group_by_hybrid_uses_query_points_groups(self, executor, mock_client, m
22682328
collection="articles", query_text="q", limit=3, model=None,
22692329
hybrid=True, group_by="category", group_size=2,
22702330
)
2271-
result = executor.execute(node)
2331+
executor.execute(node)
22722332
mock_client.query_points_groups.assert_called_once()
22732333
kwargs = mock_client.query_points_groups.call_args.kwargs
22742334
assert kwargs["group_by"] == "category"
22752335
assert "prefetch" in kwargs
22762336

2337+
def test_group_by_dense_with_mmr_uses_nearest_query(self, executor, mock_client, mocker):
2338+
from qdrant_client.models import NearestQuery
2339+
2340+
mock_client.collection_exists.return_value = True
2341+
mock_response = mocker.MagicMock()
2342+
mock_response.groups = []
2343+
mock_client.query_points_groups.return_value = mock_response
2344+
2345+
node = SearchStmt(
2346+
collection="articles",
2347+
query_text="ai",
2348+
limit=5,
2349+
model=None,
2350+
group_by="category",
2351+
with_clause=SearchWith(mmr_diversity=0.35, mmr_candidates=40),
2352+
)
2353+
executor.execute(node)
2354+
query = mock_client.query_points_groups.call_args.kwargs["query"]
2355+
assert isinstance(query, NearestQuery)
2356+
assert query.mmr is not None
2357+
assert query.mmr.diversity == pytest.approx(0.35)
2358+
assert query.mmr.candidates_limit == 40
2359+
22772360

22782361
class TestUpdateVector:
22792362
def test_update_vector_calls_update_vectors(self, executor, mock_client):
@@ -2288,7 +2371,6 @@ def test_update_vector_calls_update_vectors(self, executor, mock_client):
22882371

22892372
def test_update_vector_passes_correct_point_id(self, executor, mock_client):
22902373
from qql.ast_nodes import UpdateVectorStmt
2291-
from qdrant_client.models import PointVectors
22922374
mock_client.collection_exists.return_value = True
22932375
mock_client.get_collection.return_value.config.params.vectors = {} # non-dict → unnamed
22942376
node = UpdateVectorStmt(
@@ -2480,7 +2562,6 @@ def test_update_vector_unnamed_collection_sends_plain_list(self, executor, mock_
24802562
from qql.ast_nodes import UpdateVectorStmt
24812563
mock_client.collection_exists.return_value = True
24822564
# Unnamed collection: get_collection returns non-dict vectors
2483-
mock_vectors = mocker.MagicMock() if False else type("V", (), {})()
24842565
info = mock_client.get_collection.return_value
24852566
info.config.params.vectors = [None] # list → not a dict → unnamed
24862567

tests/test_parser.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -939,6 +939,15 @@ def test_with_quantization(self):
939939
assert node.with_clause.quantization.rescore is False
940940
assert node.with_clause.quantization.oversampling == pytest.approx(2.0)
941941

942+
def test_with_mmr_params(self):
943+
node = parse(
944+
"SEARCH col SIMILAR TO 'q' LIMIT 5 "
945+
"WITH { mmr_diversity: 0.5, mmr_candidates: 50 }"
946+
)
947+
assert node.with_clause is not None
948+
assert node.with_clause.mmr_diversity == pytest.approx(0.5)
949+
assert node.with_clause.mmr_candidates == 50
950+
942951
def test_with_after_where(self):
943952
node = parse(
944953
"SEARCH col SIMILAR TO 'q' LIMIT 5 WHERE year > 2020 WITH { hnsw_ef: 128 }"
@@ -969,6 +978,14 @@ def test_with_unknown_keyword_raises(self):
969978
with pytest.raises(QQLSyntaxError):
970979
parse("SEARCH col SIMILAR TO 'q' LIMIT 5 WITH { diversity: 0.5 }")
971980

981+
def test_with_mmr_diversity_out_of_range_raises(self):
982+
with pytest.raises(QQLSyntaxError, match="mmr_diversity must be between 0 and 1"):
983+
parse("SEARCH col SIMILAR TO 'q' LIMIT 5 WITH { mmr_diversity: 1.5 }")
984+
985+
def test_with_mmr_candidates_non_positive_raises(self):
986+
with pytest.raises(QQLSyntaxError, match="mmr_candidates must be a positive integer"):
987+
parse("SEARCH col SIMILAR TO 'q' LIMIT 5 WITH { mmr_candidates: 0 }")
988+
972989
def test_with_trailing_comma(self):
973990
node = parse("SEARCH col SIMILAR TO 'q' LIMIT 5 WITH { hnsw_ef: 256, }")
974991
assert node.with_clause.hnsw_ef == 256
@@ -1326,7 +1343,6 @@ def test_update_vector_parses_float_list(self):
13261343
assert all(isinstance(v, float) for v in node.vector)
13271344

13281345
def test_update_vector_collection_stored(self):
1329-
from qql.ast_nodes import UpdateVectorStmt
13301346
node = parse("UPDATE my_col SET VECTOR WHERE id = 99 [0.5]")
13311347
assert node.collection == "my_col"
13321348

@@ -1399,7 +1415,6 @@ def test_update_payload_dict_values_preserved(self):
13991415
assert node.payload["score"] == pytest.approx(0.99)
14001416

14011417
def test_update_payload_collection_stored(self):
1402-
from qql.ast_nodes import UpdatePayloadStmt
14031418
node = parse("UPDATE my_notes SET PAYLOAD WHERE id = 7 {'tag': 'ai'}")
14041419
assert node.collection == "my_notes"
14051420

0 commit comments

Comments
 (0)