Skip to content

Commit 9e70cfc

Browse files
authored
feat: add hybrid fusion strategy(dbsf) support for search queries (#24)
1 parent 572924f commit 9e70cfc

13 files changed

Lines changed: 97 additions & 15 deletions

README.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
[![PyPI version](https://img.shields.io/pypi/v/qql-cli?color=blue&label=PyPI)](https://pypi.org/project/qql-cli/)
66
[![Python 3.12+](https://img.shields.io/pypi/pyversions/qql-cli)](https://pypi.org/project/qql-cli/)
77
[![MIT License](https://img.shields.io/badge/license-MIT-green)](LICENSE)
8-
[![Tests](https://img.shields.io/badge/tests-375%20passing-brightgreen)](tests/)
8+
[![Tests](https://img.shields.io/badge/tests-405%20passing-brightgreen)](tests/)
99

1010
Write `INSERT`, `SEARCH`, `SCROLL`, `RECOMMEND`, `DELETE`, and `CREATE COLLECTION` statements instead of Python SDK calls. Supports hybrid dense+sparse vector search, cross-encoder reranking, quantization (scalar, turbo, binary, product), SQL-style `WHERE` filters, script execution, and collection dump/restore.
1111

@@ -48,7 +48,7 @@ Your query string
4848
Qdrant instance
4949
```
5050

51-
When you run `INSERT`, the `text` field is automatically converted into a dense vector using [Fastembed](https://github.com/qdrant/fastembed). In **hybrid mode** (`USING HYBRID`), a sparse BM25 vector is also generated alongside the dense vector, and searches use Qdrant's Reciprocal Rank Fusion (RRF) to merge the results of both retrieval methods.
51+
When you run `INSERT`, the `text` field is automatically converted into a dense vector using [Fastembed](https://github.com/qdrant/fastembed). In **hybrid mode** (`USING HYBRID`), a sparse BM25 vector is also generated alongside the dense vector, and searches use Qdrant's Reciprocal Rank Fusion (RRF) by default to merge the results of both retrieval methods. You can switch hybrid search to DBSF with `FUSION 'dbsf'`.
5252

5353
---
5454

@@ -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 USING HYBRID
105+
SEARCH articles SIMILAR TO 'query' LIMIT 10 USING HYBRID FUSION 'dbsf'
105106
SEARCH articles SIMILAR TO 'query' LIMIT 10 USING HYBRID RERANK
106107

107108
-- Scroll
@@ -142,7 +143,7 @@ Tests do not require a running Qdrant instance — the Qdrant client is mocked.
142143
pytest tests/ -v
143144
```
144145

145-
Expected: **375 tests passing**.
146+
Expected: **405 tests passing**.
146147

147148
---
148149

docs/getting-started.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ Your query string
2424
Qdrant instance
2525
```
2626

27-
When you run `INSERT`, the `text` field is automatically converted into a dense vector using [Fastembed](https://github.com/qdrant/fastembed). In **hybrid mode** (`USING HYBRID`), a sparse BM25 vector is also generated alongside the dense vector, and searches use Qdrant's Reciprocal Rank Fusion (RRF) to merge the results of both retrieval methods.
27+
When you run `INSERT`, the `text` field is automatically converted into a dense vector using [Fastembed](https://github.com/qdrant/fastembed). In **hybrid mode** (`USING HYBRID`), a sparse BM25 vector is also generated alongside the dense vector, and searches use Qdrant's Reciprocal Rank Fusion (RRF) by default to merge the results of both retrieval methods. You can override that with `FUSION 'dbsf'` on hybrid searches.
2828

2929
---
3030

docs/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ <h1>QQL</h1>
114114
<a href="https://pypi.org/project/qql-cli/"><img src="https://img.shields.io/pypi/v/qql-cli?color=blue&label=PyPI" alt="PyPI version" /></a>
115115
<a href="https://pypi.org/project/qql-cli/"><img src="https://img.shields.io/pypi/pyversions/qql-cli" alt="Python versions" /></a>
116116
<a href="https://github.com/pavanjava/qql/blob/main/LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT License" /></a>
117-
<a href="https://github.com/pavanjava/qql/actions"><img src="https://img.shields.io/badge/tests-375%20passing-brightgreen" alt="375 tests" /></a>
117+
<a href="https://github.com/pavanjava/qql/actions"><img src="https://img.shields.io/badge/tests-405%20passing-brightgreen" alt="405 tests" /></a>
118118
</div>
119119

120120
<pre><span class="cmt"># Install</span>

docs/reference.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ SEARCH docs SIMILAR TO 'hello' LIMIT 5 USING MODEL 'BAAI/bge-small-en-v1.5'
3636
-- Hybrid with custom dense model
3737
SEARCH docs SIMILAR TO 'hello' LIMIT 5 USING HYBRID DENSE MODEL 'BAAI/bge-base-en-v1.5'
3838

39+
-- Hybrid with explicit fusion strategy
40+
SEARCH docs SIMILAR TO 'hello' LIMIT 5 USING HYBRID FUSION 'dbsf'
41+
3942
-- Hybrid with both custom
4043
SEARCH docs SIMILAR TO 'hello' LIMIT 5
4144
USING HYBRID DENSE MODEL 'BAAI/bge-base-en-v1.5' SPARSE MODEL 'prithivida/Splade_PP_en_v1'
@@ -159,7 +162,7 @@ Tests do not require a running Qdrant instance — the Qdrant client is mocked.
159162
pytest tests/ -v
160163
```
161164

162-
Expected output: **375 tests passing**.
165+
Expected output: **405 tests passing**.
163166

164167
---
165168

docs/search.md

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ SEARCH <collection_name> SIMILAR TO '<query_text>' LIMIT <n>
1414
SEARCH <collection_name> SIMILAR TO '<query_text>' LIMIT <n> USING MODEL '<model_name>'
1515
SEARCH <collection_name> SIMILAR TO '<query_text>' LIMIT <n> [USING MODEL '<model>'] WHERE <filter>
1616
SEARCH <collection_name> SIMILAR TO '<query_text>' LIMIT <n> USING HYBRID
17-
SEARCH <collection_name> SIMILAR TO '<query_text>' LIMIT <n> USING HYBRID [DENSE MODEL '<model>'] [SPARSE MODEL '<model>'] [WHERE <filter>]
17+
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
2020
SEARCH <collection_name> SIMILAR TO '<query_text>' LIMIT <n> [USING ...] [WHERE <filter>] [RERANK] WITH { hnsw_ef: <n>, exact: true|false, acorn: true|false }
@@ -33,7 +33,7 @@ Search only papers published after 2020:
3333
SEARCH articles SIMILAR TO 'deep learning' LIMIT 10 WHERE year > 2020
3434
```
3535

36-
Hybrid search (combines dense semantic + sparse BM25 keyword retrieval via RRF):
36+
Hybrid search (combines dense semantic + sparse BM25 keyword retrieval via RRF by default):
3737
```sql
3838
SEARCH articles SIMILAR TO 'attention mechanism' LIMIT 10 USING HYBRID
3939
```
@@ -126,13 +126,13 @@ SCROLL FROM articles AFTER 'cursor-id' LIMIT 50
126126

127127
## Hybrid Search (USING HYBRID)
128128

129-
Hybrid search combines **dense semantic vectors** and **sparse BM25 keyword vectors** in a single query and merges the results with Qdrant's **Reciprocal Rank Fusion (RRF)** algorithm. This typically outperforms either method alone.
129+
Hybrid search combines **dense semantic vectors** and **sparse BM25 keyword vectors** in a single query. By default QQL merges the two result sets with Qdrant's **Reciprocal Rank Fusion (RRF)** algorithm, and you can optionally switch to **DBSF** with a `FUSION` clause.
130130

131131
### How it works internally
132132

133133
1. Both a dense vector (`TextEmbedding`) and a sparse BM25 vector (`SparseTextEmbedding`) are generated from your query text.
134134
2. Qdrant fetches the top candidates from each index independently (`prefetch limit = LIMIT × 4`).
135-
3. The two result lists are merged using RRF — a rank-based fusion that does not require score normalization.
135+
3. The two result lists are merged using the selected fusion strategy (`RRF` by default, or `DBSF` when requested).
136136
4. The final top-N results are returned.
137137

138138
### Step 1: Create a hybrid collection
@@ -165,6 +165,9 @@ SEARCH articles SIMILAR TO 'transformer architecture' LIMIT 10 USING HYBRID
165165
-- Hybrid search with a WHERE filter
166166
SEARCH articles SIMILAR TO 'attention' LIMIT 10 USING HYBRID WHERE year >= 2017
167167

168+
-- Hybrid with DBSF fusion
169+
SEARCH articles SIMILAR TO 'hybrid retrieval' LIMIT 10 USING HYBRID FUSION 'dbsf'
170+
168171
-- Hybrid with custom dense model
169172
SEARCH articles SIMILAR TO 'embeddings' LIMIT 5
170173
USING HYBRID DENSE MODEL 'BAAI/bge-base-en-v1.5'
@@ -180,6 +183,7 @@ SEARCH articles SIMILAR TO 'sparse retrieval' LIMIT 5
180183
|---|---|
181184
| Dense model | configured default (`sentence-transformers/all-MiniLM-L6-v2`) |
182185
| Sparse model | `Qdrant/bm25` |
186+
| Fusion | `rrf` |
183187

184188
### Dense vs. hybrid — when to use which
185189

src/qql/ast_nodes.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ class SearchStmt:
195195
limit: int
196196
model: str | None # dense model; None → use config default
197197
hybrid: bool = False # if True, use prefetch+RRF hybrid search
198+
fusion: str | None = None # hybrid fusion strategy; None → default rrf
198199
sparse_only: bool = False # if True, query only the sparse vector (no dense)
199200
sparse_model: str | None = None # sparse model for hybrid/sparse-only; None → SparseEmbedder.DEFAULT_MODEL
200201
query_filter: FilterExpr | None = None # optional WHERE clause; default keeps existing tests valid

src/qql/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@
5757
[yellow]SEARCH[/yellow] <name> [yellow]SIMILAR TO[/yellow] '<text>' [yellow]LIMIT[/yellow] <n>
5858
Semantic search by vector similarity.
5959
Optional: [yellow]USING MODEL[/yellow] '<model>'
60-
Optional: [yellow]USING HYBRID[/yellow] [DENSE MODEL '<model>'] [SPARSE MODEL '<model>']
60+
Optional: [yellow]USING HYBRID[/yellow] [FUSION 'rrf|dbsf'] [DENSE MODEL '<model>'] [SPARSE MODEL '<model>']
6161
Optional: [yellow]USING SPARSE[/yellow] [MODEL '<model>'] sparse-vector-only search
6262
Optional: [yellow]WHERE[/yellow] <filter> (e.g. WHERE year > 2020 AND status = 'ok')
6363
Optional: [yellow]RERANK[/yellow] [MODEL '<model>'] rerank results with a cross-encoder

src/qql/executor.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -464,7 +464,7 @@ def _execute_search(self, node: SearchStmt) -> ExecutionResult:
464464
# enough material to reorder; only `node.limit` results are returned.
465465
fetch_limit = node.limit * _RERANK_FETCH_MULTIPLIER if node.rerank else node.limit
466466

467-
# ── Hybrid SEARCH: prefetch dense+sparse, fuse with RRF ───────────
467+
# ── Hybrid SEARCH: prefetch dense+sparse, fuse with the requested strategy ──
468468
if node.hybrid:
469469
dense_model = node.model or self._config.default_model
470470
sparse_model_name = node.sparse_model or SparseEmbedder.DEFAULT_MODEL
@@ -495,7 +495,7 @@ def _execute_search(self, node: SearchStmt) -> ExecutionResult:
495495
params=search_params,
496496
),
497497
],
498-
query=FusionQuery(fusion=Fusion.RRF),
498+
query=FusionQuery(fusion=self._resolve_hybrid_fusion(node.fusion)),
499499
limit=fetch_limit,
500500
query_filter=qdrant_filter,
501501
)
@@ -598,6 +598,15 @@ def _execute_search(self, node: SearchStmt) -> ExecutionResult:
598598
data=results,
599599
)
600600

601+
def _resolve_hybrid_fusion(self, fusion: str | None) -> Fusion:
602+
if fusion is None or fusion == "rrf":
603+
return Fusion.RRF
604+
if fusion == "dbsf":
605+
return Fusion.DBSF
606+
raise QQLRuntimeError(
607+
f"Unsupported hybrid fusion '{fusion}'; expected 'rrf' or 'dbsf'"
608+
)
609+
601610
def _execute_recommend(self, node: RecommendStmt) -> ExecutionResult:
602611
if not self._client.collection_exists(node.collection):
603612
raise QQLRuntimeError(f"Collection '{node.collection}' does not exist")

src/qql/lexer.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ class TokenKind(Enum):
1414
USING = auto()
1515
MODEL = auto()
1616
HYBRID = auto()
17+
FUSION = auto()
1718
DENSE = auto()
1819
SPARSE = auto()
1920
RERANK = auto()
@@ -104,6 +105,7 @@ class TokenKind(Enum):
104105
"USING": TokenKind.USING,
105106
"MODEL": TokenKind.MODEL,
106107
"HYBRID": TokenKind.HYBRID,
108+
"FUSION": TokenKind.FUSION,
107109
"DENSE": TokenKind.DENSE,
108110
"SPARSE": TokenKind.SPARSE,
109111
"RERANK": TokenKind.RERANK,

src/qql/parser.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@
4444
TokenKind.LTE: "<=",
4545
}
4646

47+
_HYBRID_FUSION_VALUES = {"rrf", "dbsf"}
48+
4749

4850
class Parser:
4951
def __init__(self, tokens: list[Token]) -> None:
@@ -333,16 +335,26 @@ def _parse_search(self) -> SearchStmt:
333335

334336
model: str | None = None
335337
hybrid: bool = False
338+
fusion: str | None = None
336339
sparse_only: bool = False
337340
sparse_model: str | None = None
338341
if self._peek().kind == TokenKind.USING:
339342
self._advance() # consume USING
340343
if self._peek().kind == TokenKind.HYBRID:
341344
self._advance() # consume HYBRID
342345
hybrid = True
343-
# Optional DENSE MODEL and/or SPARSE MODEL sub-clauses, any order
344-
while self._peek().kind in (TokenKind.DENSE, TokenKind.SPARSE):
346+
# Optional FUSION / DENSE MODEL / SPARSE MODEL sub-clauses, any order.
347+
while self._peek().kind in (TokenKind.FUSION, TokenKind.DENSE, TokenKind.SPARSE):
345348
sub = self._advance()
349+
if sub.kind == TokenKind.FUSION:
350+
value_tok = self._expect(TokenKind.STRING)
351+
fusion = value_tok.value.lower()
352+
if fusion not in _HYBRID_FUSION_VALUES:
353+
raise QQLSyntaxError(
354+
f"Unsupported hybrid fusion '{value_tok.value}'; expected 'rrf' or 'dbsf'",
355+
value_tok.pos,
356+
)
357+
continue
346358
self._expect(TokenKind.MODEL)
347359
m = self._expect(TokenKind.STRING).value
348360
if sub.kind == TokenKind.DENSE:
@@ -397,6 +409,7 @@ def _parse_search(self) -> SearchStmt:
397409
limit=limit,
398410
model=model,
399411
hybrid=hybrid,
412+
fusion=fusion,
400413
sparse_only=sparse_only,
401414
sparse_model=sparse_model,
402415
query_filter=query_filter,

0 commit comments

Comments
 (0)