Skip to content

Commit 40e2db1

Browse files
committed
Implemented Hybrid Vectors and Reranking
1 parent c4e4aa6 commit 40e2db1

11 files changed

Lines changed: 953 additions & 106 deletions

File tree

README.md

Lines changed: 193 additions & 78 deletions
Large diffs are not rendered by default.

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "qql-cli"
3-
version = "0.2.0"
3+
version = "1.0.0"
44
description = "A SQL-like query language CLI wrapper for Qdrant vector database"
55
readme = "README.md"
66
license = { file = "LICENSE" }
@@ -32,6 +32,7 @@ dependencies = [
3232
"qdrant-client[fastembed]>=1.13.0",
3333
"click>=8.1.0",
3434
"rich>=13.0.0",
35+
"prompt_toolkit>=3.0.0",
3536
]
3637

3738
[project.urls]

src/qql/ast_nodes.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,12 +116,15 @@ class NotExpr:
116116
class InsertStmt:
117117
collection: str
118118
values: dict[str, Any] # must contain "text" key
119-
model: str | None # None → use default
119+
model: str | None # dense model; None → use config default
120+
hybrid: bool = False # if True, also embed + store sparse BM25 vector
121+
sparse_model: str | None = None # sparse model; None → SparseEmbedder.DEFAULT_MODEL
120122

121123

122124
@dataclass(frozen=True)
123125
class CreateCollectionStmt:
124126
collection: str
127+
hybrid: bool = False # if True, create with dense + sparse named vectors
125128

126129

127130
@dataclass(frozen=True)
@@ -139,7 +142,9 @@ class SearchStmt:
139142
collection: str
140143
query_text: str
141144
limit: int
142-
model: str | None
145+
model: str | None # dense model; None → use config default
146+
hybrid: bool = False # if True, use prefetch+RRF hybrid search
147+
sparse_model: str | None = None # sparse model for hybrid; None → SparseEmbedder.DEFAULT_MODEL
143148
query_filter: FilterExpr | None = None # optional WHERE clause; default keeps existing tests valid
144149

145150

src/qql/cli.py

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
import sys
44

55
import click
6+
from prompt_toolkit import PromptSession
7+
from prompt_toolkit.formatted_text import HTML
8+
from prompt_toolkit.history import InMemoryHistory
69
from rich.console import Console
7-
from rich.prompt import Prompt
810
from rich.table import Table
911

1012
from .config import delete_config, load_config, save_config, QQLConfig
@@ -24,9 +26,10 @@
2426
[yellow]INSERT INTO COLLECTION[/yellow] <name> [yellow]VALUES[/yellow] {[yellow]'text'[/yellow]: '...', ...}
2527
Insert a point. 'text' is required and auto-vectorized.
2628
Optional: [yellow]USING MODEL[/yellow] '<model>'
29+
Optional: [yellow]USING HYBRID[/yellow] [DENSE MODEL '<model>'] [SPARSE MODEL '<model>']
2730
28-
[yellow]CREATE COLLECTION[/yellow] <name>
29-
Create a new collection (uses default model dimensions).
31+
[yellow]CREATE COLLECTION[/yellow] <name> [[yellow]HYBRID[/yellow]]
32+
Create a new collection. Add HYBRID for dense+sparse BM25 vectors.
3033
3134
[yellow]DROP COLLECTION[/yellow] <name>
3235
Delete a collection and all its points.
@@ -37,10 +40,19 @@
3740
[yellow]SEARCH[/yellow] <name> [yellow]SIMILAR TO[/yellow] '<text>' [yellow]LIMIT[/yellow] <n>
3841
Semantic search by vector similarity.
3942
Optional: [yellow]USING MODEL[/yellow] '<model>'
43+
Optional: [yellow]USING HYBRID[/yellow] [DENSE MODEL '<model>'] [SPARSE MODEL '<model>']
44+
Optional: [yellow]WHERE[/yellow] <filter> (e.g. WHERE year > 2020 AND status = 'ok')
4045
4146
[yellow]DELETE FROM[/yellow] <name> [yellow]WHERE id =[/yellow] '<id>'
4247
Delete a point by its ID.
4348
49+
Keyboard shortcuts:
50+
← → arrows move cursor within the current line
51+
↑ ↓ arrows scroll through command history
52+
Ctrl-A / Ctrl-E jump to beginning / end of line
53+
Ctrl-C cancel current input
54+
Ctrl-D exit shell
55+
4456
Type [bold]exit[/bold] or [bold]quit[/bold] to leave the shell.
4557
"""
4658

@@ -115,10 +127,16 @@ def _launch_repl(cfg: QQLConfig) -> None:
115127
console.print(f"[bold cyan]QQL Interactive Shell[/bold cyan] • {cfg.url}")
116128
console.print("Type [bold]help[/bold] for available commands or [bold]exit[/bold] to quit.\n")
117129

130+
session: PromptSession[str] = PromptSession(history=InMemoryHistory())
131+
118132
while True:
119133
try:
120-
query = Prompt.ask("[bold green]qql>[/bold green]").strip()
121-
except (EOFError, KeyboardInterrupt):
134+
query = session.prompt(HTML("<ansigreen><b>qql&gt;</b></ansigreen> ")).strip()
135+
except KeyboardInterrupt:
136+
# Ctrl-C clears the current line; continue the loop
137+
continue
138+
except EOFError:
139+
# Ctrl-D exits
122140
console.print("\nBye.")
123141
break
124142

src/qql/embedder.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,37 @@ def embed_batch(self, texts: list[str]) -> list[list[float]]:
3131
def dimensions(self) -> int:
3232
"""Return the vector dimensionality by embedding a dummy string."""
3333
return len(self.embed("probe"))
34+
35+
36+
class SparseEmbedder:
37+
"""Sparse BM25-style embedder using fastembed.SparseTextEmbedding.
38+
39+
Returns dicts with "indices" and "values" lists (not numpy arrays),
40+
ready for direct construction of qdrant_client SparseVector objects.
41+
42+
Uses asymmetric embedding: embed() for document indexing, query_embed()
43+
for query-time encoding (BM25 IDF weighting differs at query vs. index time).
44+
"""
45+
46+
DEFAULT_MODEL = "Qdrant/bm25"
47+
48+
# Class-level cache mirrors Embedder's pattern
49+
_cache: dict[str, object] = {}
50+
51+
def __init__(self, model_name: str = DEFAULT_MODEL) -> None:
52+
self._model_name = model_name
53+
if model_name not in SparseEmbedder._cache:
54+
from fastembed import SparseTextEmbedding
55+
56+
SparseEmbedder._cache[model_name] = SparseTextEmbedding(model_name)
57+
self._model = SparseEmbedder._cache[model_name]
58+
59+
def embed(self, text: str) -> dict[str, list]:
60+
"""Embed a document string. Returns {"indices": [...], "values": [...]}."""
61+
result = next(iter(self._model.embed([text]))) # type: ignore[attr-defined]
62+
return {"indices": result.indices.tolist(), "values": result.values.tolist()}
63+
64+
def query_embed(self, text: str) -> dict[str, list]:
65+
"""Embed a query string (BM25 applies different IDF weighting at query time)."""
66+
result = next(iter(self._model.query_embed(text))) # type: ignore[attr-defined]
67+
return {"indices": result.indices.tolist(), "values": result.values.tolist()}

src/qql/executor.py

Lines changed: 150 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
Distance,
1111
FieldCondition,
1212
Filter,
13+
Fusion,
14+
FusionQuery,
1315
IsEmptyCondition,
1416
IsNullCondition,
1517
MatchAny,
@@ -18,9 +20,13 @@
1820
MatchText,
1921
MatchTextAny,
2022
MatchValue,
23+
Modifier,
2124
PayloadField,
2225
PointStruct,
26+
Prefetch,
2327
Range,
28+
SparseVector,
29+
SparseVectorParams,
2430
VectorParams,
2531
)
2632

@@ -49,7 +55,7 @@
4955
ShowCollectionsStmt,
5056
)
5157
from .config import QQLConfig
52-
from .embedder import Embedder
58+
from .embedder import Embedder, SparseEmbedder
5359
from .exceptions import QQLRuntimeError
5460

5561

@@ -86,6 +92,56 @@ def _execute_insert(self, node: InsertStmt) -> ExecutionResult:
8692
if "text" not in node.values:
8793
raise QQLRuntimeError("INSERT requires a 'text' field in VALUES")
8894

95+
# ── Hybrid INSERT: dense + sparse vectors ──────────────────────────
96+
if node.hybrid:
97+
dense_model = node.model or self._config.default_model
98+
sparse_model_name = node.sparse_model or SparseEmbedder.DEFAULT_MODEL
99+
dense_embedder = Embedder(dense_model)
100+
sparse_embedder = SparseEmbedder(sparse_model_name)
101+
102+
dense_vector = dense_embedder.embed(node.values["text"])
103+
sparse_obj = sparse_embedder.embed(node.values["text"])
104+
sparse_vector = SparseVector(
105+
indices=sparse_obj["indices"],
106+
values=sparse_obj["values"],
107+
)
108+
109+
# Auto-create hybrid collection if it doesn't exist yet
110+
if not self._client.collection_exists(node.collection):
111+
self._client.create_collection(
112+
collection_name=node.collection,
113+
vectors_config={
114+
"dense": VectorParams(
115+
size=len(dense_vector), distance=Distance.COSINE
116+
)
117+
},
118+
sparse_vectors_config={
119+
"sparse": SparseVectorParams(modifier=Modifier.IDF)
120+
},
121+
)
122+
123+
point_id = str(uuid.uuid4())
124+
try:
125+
self._client.upsert(
126+
collection_name=node.collection,
127+
points=[
128+
PointStruct(
129+
id=point_id,
130+
vector={"dense": dense_vector, "sparse": sparse_vector},
131+
payload=dict(node.values),
132+
)
133+
],
134+
)
135+
except UnexpectedResponse as e:
136+
raise QQLRuntimeError(f"Qdrant error during INSERT: {e}") from e
137+
138+
return ExecutionResult(
139+
success=True,
140+
message=f"Inserted 1 point [{point_id}] (hybrid)",
141+
data={"id": point_id, "collection": node.collection},
142+
)
143+
144+
# ── Standard dense-only INSERT ─────────────────────────────────────
89145
model_name = node.model or self._config.default_model
90146
embedder = Embedder(model_name)
91147
vector = embedder.embed(node.values["text"])
@@ -115,6 +171,29 @@ def _execute_create(self, node: CreateCollectionStmt) -> ExecutionResult:
115171
success=True,
116172
message=f"Collection '{node.collection}' already exists",
117173
)
174+
175+
# ── Hybrid collection: named dense + sparse vectors ────────────────
176+
if node.hybrid:
177+
embedder = Embedder(self._config.default_model)
178+
dims = embedder.dimensions
179+
self._client.create_collection(
180+
collection_name=node.collection,
181+
vectors_config={
182+
"dense": VectorParams(size=dims, distance=Distance.COSINE)
183+
},
184+
sparse_vectors_config={
185+
"sparse": SparseVectorParams(modifier=Modifier.IDF)
186+
},
187+
)
188+
return ExecutionResult(
189+
success=True,
190+
message=(
191+
f"Collection '{node.collection}' created "
192+
f"(hybrid: {dims}-dim dense + BM25 sparse, cosine distance)"
193+
),
194+
)
195+
196+
# ── Standard dense-only collection ─────────────────────────────────
118197
embedder = Embedder(self._config.default_model)
119198
dims = embedder.dimensions
120199
self._client.create_collection(
@@ -148,16 +227,64 @@ def _execute_search(self, node: SearchStmt) -> ExecutionResult:
148227
if not self._client.collection_exists(node.collection):
149228
raise QQLRuntimeError(f"Collection '{node.collection}' does not exist")
150229

151-
model_name = node.model or self._config.default_model
152-
embedder = Embedder(model_name)
153-
vector = embedder.embed(node.query_text)
154-
230+
# Build WHERE filter (shared by both hybrid and dense-only paths)
155231
qdrant_filter: Filter | None = None
156232
if node.query_filter is not None:
157233
qdrant_filter = self._wrap_as_filter(
158234
self._build_qdrant_filter(node.query_filter)
159235
)
160236

237+
# ── Hybrid SEARCH: prefetch dense+sparse, fuse with RRF ───────────
238+
if node.hybrid:
239+
dense_model = node.model or self._config.default_model
240+
sparse_model_name = node.sparse_model or SparseEmbedder.DEFAULT_MODEL
241+
dense_embedder = Embedder(dense_model)
242+
sparse_embedder = SparseEmbedder(sparse_model_name)
243+
244+
dense_vector = dense_embedder.embed(node.query_text)
245+
sparse_obj = sparse_embedder.query_embed(node.query_text)
246+
sparse_vector = SparseVector(
247+
indices=sparse_obj["indices"],
248+
values=sparse_obj["values"],
249+
)
250+
251+
try:
252+
response = self._client.query_points(
253+
collection_name=node.collection,
254+
prefetch=[
255+
Prefetch(
256+
query=dense_vector,
257+
using="dense",
258+
limit=node.limit * 4,
259+
),
260+
Prefetch(
261+
query=sparse_vector,
262+
using="sparse",
263+
limit=node.limit * 4,
264+
),
265+
],
266+
query=FusionQuery(fusion=Fusion.RRF),
267+
limit=node.limit,
268+
query_filter=qdrant_filter,
269+
)
270+
except UnexpectedResponse as e:
271+
raise QQLRuntimeError(f"Qdrant error during SEARCH: {e}") from e
272+
273+
results = [
274+
{"id": str(h.id), "score": round(h.score, 4), "payload": h.payload}
275+
for h in response.points
276+
]
277+
return ExecutionResult(
278+
success=True,
279+
message=f"Found {len(results)} result(s) (hybrid)",
280+
data=results,
281+
)
282+
283+
# ── Standard dense-only SEARCH ─────────────────────────────────────
284+
model_name = node.model or self._config.default_model
285+
embedder = Embedder(model_name)
286+
vector = embedder.embed(node.query_text)
287+
161288
try:
162289
response = self._client.query_points(
163290
collection_name=node.collection,
@@ -293,16 +420,26 @@ def _wrap_as_filter(self, qdrant_expr: Any) -> Filter:
293420
# ── Collection helpers ────────────────────────────────────────────────
294421

295422
def _ensure_collection(self, name: str, vector_size: int) -> None:
296-
"""Create the collection if it doesn't exist. Raises on dimension mismatch."""
423+
"""Create the collection if it doesn't exist. Raises on dimension mismatch.
424+
425+
For named-vector (hybrid) collections the validation is skipped — those
426+
collections are managed directly by the hybrid insert/create paths.
427+
"""
297428
if self._client.collection_exists(name):
298429
info = self._client.get_collection(name)
299-
existing_size = info.config.params.vectors.size # type: ignore[union-attr]
300-
if existing_size != vector_size:
301-
raise QQLRuntimeError(
302-
f"Vector dimension mismatch: collection '{name}' expects "
303-
f"{existing_size} dims, but model produces {vector_size} dims. "
304-
f"Specify a compatible model with USING MODEL '<model>'."
305-
)
430+
vectors = info.config.params.vectors # type: ignore[union-attr]
431+
if isinstance(vectors, dict):
432+
# Named-vector (hybrid) collection — skip validation here;
433+
# the hybrid insert path manages its own collection creation.
434+
pass
435+
else:
436+
# Unnamed single-vector collection: validate dimensions
437+
if vectors.size != vector_size:
438+
raise QQLRuntimeError(
439+
f"Vector dimension mismatch: collection '{name}' expects "
440+
f"{vectors.size} dims, but model produces {vector_size} dims. "
441+
f"Specify a compatible model with USING MODEL '<model>'."
442+
)
306443
else:
307444
self._client.create_collection(
308445
collection_name=name,

src/qql/lexer.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ class TokenKind(Enum):
1212
VALUES = auto()
1313
USING = auto()
1414
MODEL = auto()
15+
HYBRID = auto()
16+
DENSE = auto()
17+
SPARSE = auto()
1518
CREATE = auto()
1619
DROP = auto()
1720
SHOW = auto()
@@ -69,6 +72,9 @@ class TokenKind(Enum):
6972
"VALUES": TokenKind.VALUES,
7073
"USING": TokenKind.USING,
7174
"MODEL": TokenKind.MODEL,
75+
"HYBRID": TokenKind.HYBRID,
76+
"DENSE": TokenKind.DENSE,
77+
"SPARSE": TokenKind.SPARSE,
7278
"CREATE": TokenKind.CREATE,
7379
"DROP": TokenKind.DROP,
7480
"SHOW": TokenKind.SHOW,

0 commit comments

Comments
 (0)