Skip to content

Commit 00ae675

Browse files
authored
Implemented Full Reranking (#5)
1 parent e950f7b commit 00ae675

11 files changed

Lines changed: 468 additions & 6 deletions

File tree

README.md

Lines changed: 118 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ qql> SEARCH notes SIMILAR TO 'vector databases' LIMIT 5 USING HYBRID
1717
Score │ ID │ Payload
1818
────────┼──────────────────────────────────────┼──────────────────────────────────────
1919
0.9102 │ 3f2e1a4b-8c91-4d0e-b123-abc123def456 │ {'text': 'Qdrant is a ...', 'author': 'alice', 'year': 2024}
20+
21+
qql> SEARCH notes SIMILAR TO 'vector databases' LIMIT 5 USING HYBRID RERANK
22+
✓ Found 1 result(s) (hybrid, reranked)
23+
Score │ ID │ Payload
24+
────────┼──────────────────────────────────────┼──────────────────────────────────────
25+
5.3714 │ 3f2e1a4b-8c91-4d0e-b123-abc123def456 │ {'text': 'Qdrant is a ...', 'author': 'alice', 'year': 2024}
2026
```
2127

2228
---
@@ -32,6 +38,7 @@ qql> SEARCH notes SIMILAR TO 'vector databases' LIMIT 5 USING HYBRID
3238
- [SEARCH — find similar points](#search--find-similar-points)
3339
- [WHERE Clause Filters](#where-clause-filters)
3440
- [Hybrid Search (USING HYBRID)](#hybrid-search-using-hybrid)
41+
- [Cross-Encoder Reranking (RERANK)](#cross-encoder-reranking-rerank)
3542
- [SHOW COLLECTIONS — list collections](#show-collections--list-collections)
3643
- [CREATE COLLECTION — create a collection](#create-collection--create-a-collection)
3744
- [DROP COLLECTION — delete a collection](#drop-collection--delete-a-collection)
@@ -244,6 +251,7 @@ SEARCH <collection_name> SIMILAR TO '<query_text>' LIMIT <n> USING MODEL '<model
244251
SEARCH <collection_name> SIMILAR TO '<query_text>' LIMIT <n> [USING MODEL '<model>'] WHERE <filter>
245252
SEARCH <collection_name> SIMILAR TO '<query_text>' LIMIT <n> USING HYBRID
246253
SEARCH <collection_name> SIMILAR TO '<query_text>' LIMIT <n> USING HYBRID [DENSE MODEL '<model>'] [SPARSE MODEL '<model>'] [WHERE <filter>]
254+
SEARCH <collection_name> SIMILAR TO '<query_text>' LIMIT <n> [USING ...] [WHERE <filter>] RERANK [MODEL '<reranker_model>']
247255
```
248256

249257
**Examples:**
@@ -508,6 +516,95 @@ Both can be overridden independently with `DENSE MODEL` and `SPARSE MODEL`.
508516

509517
---
510518

519+
### Cross-Encoder Reranking (RERANK)
520+
521+
Appending `RERANK` to any SEARCH statement activates a **second-pass relevance scoring** step using a [cross-encoder](https://www.sbert.net/examples/applications/cross-encoder/README.html) model. Unlike bi-encoders (which encode query and document independently), a cross-encoder processes the **(query, document)** pair jointly, producing a more accurate relevance score at the cost of extra compute.
522+
523+
#### How it works internally
524+
525+
1. Qdrant executes the normal dense or hybrid search, but fetches `LIMIT × 4` candidates instead of just `LIMIT` — giving the reranker enough material to work with.
526+
2. Each candidate's `payload["text"]` is paired with the original query text.
527+
3. The cross-encoder scores all (query, document) pairs in one batch.
528+
4. Results are sorted **descending by cross-encoder score** and sliced to `LIMIT`.
529+
5. The `score` column in the output reflects the cross-encoder relevance score (raw logits — higher is more relevant).
530+
531+
#### Syntax
532+
533+
```
534+
SEARCH <name> SIMILAR TO '<query>' LIMIT <n> RERANK
535+
SEARCH <name> SIMILAR TO '<query>' LIMIT <n> RERANK MODEL '<cross_encoder_model>'
536+
```
537+
538+
`RERANK` must come **after** any `USING` and `WHERE` clauses:
539+
540+
```
541+
SEARCH ... LIMIT n [USING ...] [WHERE ...] RERANK [MODEL '...']
542+
```
543+
544+
#### Examples
545+
546+
Dense search + rerank (default cross-encoder):
547+
```sql
548+
SEARCH articles SIMILAR TO 'machine learning for healthcare' LIMIT 5 RERANK
549+
```
550+
551+
Hybrid search + rerank (best of all three worlds):
552+
```sql
553+
SEARCH articles SIMILAR TO 'attention mechanism in transformers' LIMIT 10 USING HYBRID RERANK
554+
```
555+
556+
Dense search + WHERE filter + rerank:
557+
```sql
558+
SEARCH articles SIMILAR TO 'deep learning' LIMIT 10 WHERE year > 2020 RERANK
559+
```
560+
561+
Custom cross-encoder model:
562+
```sql
563+
SEARCH articles SIMILAR TO 'semantic search' LIMIT 5
564+
RERANK MODEL 'cross-encoder/ms-marco-MiniLM-L-6-v2'
565+
```
566+
567+
All clauses combined:
568+
```sql
569+
SEARCH articles SIMILAR TO 'neural IR' LIMIT 10
570+
USING HYBRID DENSE MODEL 'BAAI/bge-base-en-v1.5'
571+
WHERE year >= 2020
572+
RERANK MODEL 'cross-encoder/ms-marco-MiniLM-L-6-v2'
573+
```
574+
575+
#### Default cross-encoder model
576+
577+
```
578+
cross-encoder/ms-marco-MiniLM-L-6-v2
579+
```
580+
581+
- A lightweight but effective passage reranker fine-tuned on MS MARCO.
582+
- Downloaded on first use and cached locally by Fastembed.
583+
- No additional packages needed — `TextCrossEncoder` is included in the `fastembed` package.
584+
585+
#### Commonly available cross-encoder models (Fastembed)
586+
587+
| Model | Notes |
588+
|---|---|
589+
| `cross-encoder/ms-marco-MiniLM-L-6-v2` | Default. Fast and accurate for passage reranking |
590+
| `cross-encoder/ms-marco-MiniLM-L-12-v2` | Larger, higher quality, slower |
591+
| `BAAI/bge-reranker-base` | BGE reranker, strong general-purpose performance |
592+
| `BAAI/bge-reranker-large` | Highest quality BGE reranker, slower |
593+
594+
#### When to use RERANK
595+
596+
| Situation | Recommendation |
597+
|---|---|
598+
| High-precision retrieval (legal, medical, research) | Add `RERANK` |
599+
| Small LIMIT (top-3 or top-5 results) | Very effective — reranker focuses precision |
600+
| Low latency required | Skip `RERANK` (adds ~100–500 ms per batch) |
601+
| Large collections with keyword-heavy queries | `USING HYBRID RERANK` for best coverage + precision |
602+
| General-purpose semantic search | Optional; `RERANK` improves quality at mild cost |
603+
604+
> **Note on scores:** After reranking, the `score` column shows the cross-encoder's raw logit (can be any real number, unbounded). Do not compare reranked scores to non-reranked cosine similarity scores — they are on different scales.
605+
606+
---
607+
511608
### SHOW COLLECTIONS — list collections
512609

513610
Lists all collections in the connected Qdrant instance.
@@ -670,6 +767,25 @@ SEARCH docs SIMILAR TO 'hello' LIMIT 5
670767
| `prithivida/Splade_PP_en_v1` | SPLADE++ — strong keyword + semantic overlap |
671768
| `Qdrant/Unicoil` | UniCOIL sparse encoder |
672769

770+
### Cross-encoder reranking (RERANK default)
771+
772+
```
773+
cross-encoder/ms-marco-MiniLM-L-6-v2
774+
```
775+
776+
- A passage reranker fine-tuned on MS MARCO.
777+
- No new dependencies — `TextCrossEncoder` is included in the `fastembed` package.
778+
- Override with `RERANK MODEL '<model_name>'`.
779+
780+
### Commonly available cross-encoder models (Fastembed)
781+
782+
| Model | Notes |
783+
|---|---|
784+
| `cross-encoder/ms-marco-MiniLM-L-6-v2` | Default. Fast passage reranker |
785+
| `cross-encoder/ms-marco-MiniLM-L-12-v2` | Larger, higher quality |
786+
| `BAAI/bge-reranker-base` | Strong general-purpose reranker |
787+
| `BAAI/bge-reranker-large` | Highest quality, slower |
788+
673789
> Models are downloaded automatically on first use and cached by Fastembed. Loading a new model for the first time takes a few seconds.
674790
675791
### Model consistency rule
@@ -847,7 +963,7 @@ qql/
847963
│ ├── lexer.py # Tokenizer: string → List[Token]
848964
│ ├── ast_nodes.py # Frozen dataclasses for each statement and filter type
849965
│ ├── parser.py # Recursive descent parser: tokens → AST node
850-
│ ├── embedder.py # Embedder (dense) + SparseEmbedder (BM25) with per-model cache
966+
│ ├── embedder.py # Embedder (dense) + SparseEmbedder (BM25) + CrossEncoderEmbedder (rerank)
851967
│ └── executor.py # AST node → Qdrant client call + filter + hybrid search
852968
└── tests/
853969
├── test_lexer.py # Tokenizer unit tests (keywords, operators, dot-paths, hybrid tokens)
@@ -865,7 +981,7 @@ Tests do not require a running Qdrant instance — the Qdrant client is mocked.
865981
pytest tests/ -v
866982
```
867983

868-
Expected output: **169 tests passing**.
984+
Expected output: **193 tests passing**.
869985

870986
---
871987

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "qql-cli"
3-
version = "1.0.0"
3+
version = "1.1.0"
44
description = "A SQL-like query language CLI wrapper for Qdrant vector database"
55
readme = "README.md"
66
license = { file = "LICENSE" }

src/qql/ast_nodes.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,8 @@ class SearchStmt:
146146
hybrid: bool = False # if True, use prefetch+RRF hybrid search
147147
sparse_model: str | None = None # sparse model for hybrid; None → SparseEmbedder.DEFAULT_MODEL
148148
query_filter: FilterExpr | None = None # optional WHERE clause; default keeps existing tests valid
149+
rerank: bool = False # if True, apply cross-encoder reranking post-Qdrant
150+
rerank_model: str | None = None # cross-encoder model; None → CrossEncoderEmbedder.DEFAULT_MODEL
149151

150152

151153
@dataclass(frozen=True)

src/qql/cli.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
Optional: [yellow]USING MODEL[/yellow] '<model>'
4343
Optional: [yellow]USING HYBRID[/yellow] [DENSE MODEL '<model>'] [SPARSE MODEL '<model>']
4444
Optional: [yellow]WHERE[/yellow] <filter> (e.g. WHERE year > 2020 AND status = 'ok')
45+
Optional: [yellow]RERANK[/yellow] [MODEL '<model>'] rerank results with a cross-encoder
4546
4647
[yellow]DELETE FROM[/yellow] <name> [yellow]WHERE id =[/yellow] '<id>'
4748
Delete a point by its ID.

src/qql/embedder.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,34 @@ def query_embed(self, text: str) -> dict[str, list]:
6565
"""Embed a query string (BM25 applies different IDF weighting at query time)."""
6666
result = next(iter(self._model.query_embed(text))) # type: ignore[attr-defined]
6767
return {"indices": result.indices.tolist(), "values": result.values.tolist()}
68+
69+
70+
class CrossEncoderEmbedder:
71+
"""Cross-encoder reranker using fastembed.TextCrossEncoder.
72+
73+
Jointly encodes (query, document) pairs to produce relevance scores.
74+
Higher score = more relevant. No new package dependencies —
75+
TextCrossEncoder is included in the fastembed package bundled with
76+
qdrant-client[fastembed].
77+
"""
78+
79+
DEFAULT_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
80+
81+
# Class-level cache mirrors Embedder's pattern
82+
_cache: dict[str, object] = {}
83+
84+
def __init__(self, model_name: str = DEFAULT_MODEL) -> None:
85+
self._model_name = model_name
86+
if model_name not in CrossEncoderEmbedder._cache:
87+
from fastembed import TextCrossEncoder
88+
89+
CrossEncoderEmbedder._cache[model_name] = TextCrossEncoder(model_name)
90+
self._model = CrossEncoderEmbedder._cache[model_name]
91+
92+
def rerank(self, query: str, documents: list[str]) -> list[float]:
93+
"""Return a relevance score for each (query, document) pair.
94+
95+
Scores are raw logits — higher means more relevant.
96+
The returned list is the same length as ``documents`` and in the same order.
97+
"""
98+
return list(self._model.rerank(query, documents)) # type: ignore[attr-defined]

src/qql/executor.py

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@
5555
ShowCollectionsStmt,
5656
)
5757
from .config import QQLConfig
58-
from .embedder import Embedder, SparseEmbedder
58+
from .embedder import CrossEncoderEmbedder, Embedder, SparseEmbedder
59+
60+
_RERANK_FETCH_MULTIPLIER = 4
5961
from .exceptions import QQLRuntimeError
6062

6163

@@ -234,6 +236,10 @@ def _execute_search(self, node: SearchStmt) -> ExecutionResult:
234236
self._build_qdrant_filter(node.query_filter)
235237
)
236238

239+
# When reranking is requested, fetch more candidates so the reranker has
240+
# enough material to reorder; only `node.limit` results are returned.
241+
fetch_limit = node.limit * _RERANK_FETCH_MULTIPLIER if node.rerank else node.limit
242+
237243
# ── Hybrid SEARCH: prefetch dense+sparse, fuse with RRF ───────────
238244
if node.hybrid:
239245
dense_model = node.model or self._config.default_model
@@ -264,7 +270,7 @@ def _execute_search(self, node: SearchStmt) -> ExecutionResult:
264270
),
265271
],
266272
query=FusionQuery(fusion=Fusion.RRF),
267-
limit=node.limit,
273+
limit=fetch_limit,
268274
query_filter=qdrant_filter,
269275
)
270276
except UnexpectedResponse as e:
@@ -274,6 +280,15 @@ def _execute_search(self, node: SearchStmt) -> ExecutionResult:
274280
{"id": str(h.id), "score": round(h.score, 4), "payload": h.payload}
275281
for h in response.points
276282
]
283+
284+
if node.rerank:
285+
results = self._apply_reranking(node.query_text, results, node.limit, node.rerank_model)
286+
return ExecutionResult(
287+
success=True,
288+
message=f"Found {len(results)} result(s) (hybrid, reranked)",
289+
data=results,
290+
)
291+
277292
return ExecutionResult(
278293
success=True,
279294
message=f"Found {len(results)} result(s) (hybrid)",
@@ -289,7 +304,7 @@ def _execute_search(self, node: SearchStmt) -> ExecutionResult:
289304
response = self._client.query_points(
290305
collection_name=node.collection,
291306
query=vector,
292-
limit=node.limit,
307+
limit=fetch_limit,
293308
query_filter=qdrant_filter,
294309
)
295310
except UnexpectedResponse as e:
@@ -299,12 +314,37 @@ def _execute_search(self, node: SearchStmt) -> ExecutionResult:
299314
{"id": str(h.id), "score": round(h.score, 4), "payload": h.payload}
300315
for h in response.points
301316
]
317+
318+
if node.rerank:
319+
results = self._apply_reranking(node.query_text, results, node.limit, node.rerank_model)
320+
return ExecutionResult(
321+
success=True,
322+
message=f"Found {len(results)} result(s) (reranked)",
323+
data=results,
324+
)
325+
302326
return ExecutionResult(
303327
success=True,
304328
message=f"Found {len(results)} result(s)",
305329
data=results,
306330
)
307331

332+
def _apply_reranking(
333+
self,
334+
query: str,
335+
results: list[dict],
336+
limit: int,
337+
rerank_model: str | None,
338+
) -> list[dict]:
339+
"""Re-score candidates with a cross-encoder and return top-``limit`` results."""
340+
model_name = rerank_model or CrossEncoderEmbedder.DEFAULT_MODEL
341+
reranker = CrossEncoderEmbedder(model_name)
342+
texts = [r["payload"].get("text", "") for r in results]
343+
scores = reranker.rerank(query, texts)
344+
for r, s in zip(results, scores):
345+
r["score"] = round(float(s), 4)
346+
return sorted(results, key=lambda r: r["score"], reverse=True)[:limit]
347+
308348
def _execute_delete(self, node: DeleteStmt) -> ExecutionResult:
309349
if not self._client.collection_exists(node.collection):
310350
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
@@ -15,6 +15,7 @@ class TokenKind(Enum):
1515
HYBRID = auto()
1616
DENSE = auto()
1717
SPARSE = auto()
18+
RERANK = auto()
1819
CREATE = auto()
1920
DROP = auto()
2021
SHOW = auto()
@@ -75,6 +76,7 @@ class TokenKind(Enum):
7576
"HYBRID": TokenKind.HYBRID,
7677
"DENSE": TokenKind.DENSE,
7778
"SPARSE": TokenKind.SPARSE,
79+
"RERANK": TokenKind.RERANK,
7880
"CREATE": TokenKind.CREATE,
7981
"DROP": TokenKind.DROP,
8082
"SHOW": TokenKind.SHOW,

src/qql/parser.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,14 @@ def _parse_search(self) -> SearchStmt:
154154
if self._peek().kind == TokenKind.WHERE:
155155
self._advance() # consume WHERE
156156
query_filter = self._parse_filter_expr()
157+
rerank: bool = False
158+
rerank_model: str | None = None
159+
if self._peek().kind == TokenKind.RERANK:
160+
self._advance() # consume RERANK
161+
rerank = True
162+
if self._peek().kind == TokenKind.MODEL:
163+
self._advance() # consume MODEL
164+
rerank_model = self._expect(TokenKind.STRING).value
157165
return SearchStmt(
158166
collection=collection,
159167
query_text=query_text,
@@ -162,6 +170,8 @@ def _parse_search(self) -> SearchStmt:
162170
hybrid=hybrid,
163171
sparse_model=sparse_model,
164172
query_filter=query_filter,
173+
rerank=rerank,
174+
rerank_model=rerank_model,
165175
)
166176

167177
def _parse_delete(self) -> DeleteStmt:

0 commit comments

Comments
 (0)