|
10 | 10 | Distance, |
11 | 11 | FieldCondition, |
12 | 12 | Filter, |
| 13 | + Fusion, |
| 14 | + FusionQuery, |
13 | 15 | IsEmptyCondition, |
14 | 16 | IsNullCondition, |
15 | 17 | MatchAny, |
|
18 | 20 | MatchText, |
19 | 21 | MatchTextAny, |
20 | 22 | MatchValue, |
| 23 | + Modifier, |
21 | 24 | PayloadField, |
22 | 25 | PointStruct, |
| 26 | + Prefetch, |
23 | 27 | Range, |
| 28 | + SparseVector, |
| 29 | + SparseVectorParams, |
24 | 30 | VectorParams, |
25 | 31 | ) |
26 | 32 |
|
|
49 | 55 | ShowCollectionsStmt, |
50 | 56 | ) |
51 | 57 | from .config import QQLConfig |
52 | | -from .embedder import Embedder |
| 58 | +from .embedder import Embedder, SparseEmbedder |
53 | 59 | from .exceptions import QQLRuntimeError |
54 | 60 |
|
55 | 61 |
|
@@ -86,6 +92,56 @@ def _execute_insert(self, node: InsertStmt) -> ExecutionResult: |
86 | 92 | if "text" not in node.values: |
87 | 93 | raise QQLRuntimeError("INSERT requires a 'text' field in VALUES") |
88 | 94 |
|
| 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 ───────────────────────────────────── |
89 | 145 | model_name = node.model or self._config.default_model |
90 | 146 | embedder = Embedder(model_name) |
91 | 147 | vector = embedder.embed(node.values["text"]) |
@@ -115,6 +171,29 @@ def _execute_create(self, node: CreateCollectionStmt) -> ExecutionResult: |
115 | 171 | success=True, |
116 | 172 | message=f"Collection '{node.collection}' already exists", |
117 | 173 | ) |
| 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 ───────────────────────────────── |
118 | 197 | embedder = Embedder(self._config.default_model) |
119 | 198 | dims = embedder.dimensions |
120 | 199 | self._client.create_collection( |
@@ -148,16 +227,64 @@ def _execute_search(self, node: SearchStmt) -> ExecutionResult: |
148 | 227 | if not self._client.collection_exists(node.collection): |
149 | 228 | raise QQLRuntimeError(f"Collection '{node.collection}' does not exist") |
150 | 229 |
|
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) |
155 | 231 | qdrant_filter: Filter | None = None |
156 | 232 | if node.query_filter is not None: |
157 | 233 | qdrant_filter = self._wrap_as_filter( |
158 | 234 | self._build_qdrant_filter(node.query_filter) |
159 | 235 | ) |
160 | 236 |
|
| 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 | + |
161 | 288 | try: |
162 | 289 | response = self._client.query_points( |
163 | 290 | collection_name=node.collection, |
@@ -293,16 +420,26 @@ def _wrap_as_filter(self, qdrant_expr: Any) -> Filter: |
293 | 420 | # ── Collection helpers ──────────────────────────────────────────────── |
294 | 421 |
|
295 | 422 | 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 | + """ |
297 | 428 | if self._client.collection_exists(name): |
298 | 429 | 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 | + ) |
306 | 443 | else: |
307 | 444 | self._client.create_collection( |
308 | 445 | collection_name=name, |
|
0 commit comments