feat(qdrant): implement client-side BM25 sparse vectors via fastembed

- Add sparse_vectors_config with 'bm25' named vector to create_col()
- Compute BM25 sparse vectors from text_lemmatized during insert/update
- Use client-side fastembed Qdrant/bm25 encoder instead of server-side inference
- Graceful fallback if fastembed not installed
- Fixes keyword_search() returning None on self-hosted Qdrant
This commit is contained in:
Soumil Rathi
2026-04-02 10:23:17 -07:00
parent 80749b92bf
commit 4534acdfbe
+76 -17
View File
@@ -2,7 +2,7 @@ import logging
import os import os
import shutil import shutil
from qdrant_client import QdrantClient from qdrant_client import QdrantClient, models
from qdrant_client.models import ( from qdrant_client.models import (
Distance, Distance,
FieldCondition, FieldCondition,
@@ -11,6 +11,8 @@ from qdrant_client.models import (
PointIdsList, PointIdsList,
PointStruct, PointStruct,
Range, Range,
SparseVector,
SparseVectorParams,
VectorParams, VectorParams,
) )
@@ -73,11 +75,44 @@ class Qdrant(VectorStoreBase):
self.collection_name = collection_name self.collection_name = collection_name
self.embedding_model_dims = embedding_model_dims self.embedding_model_dims = embedding_model_dims
self.on_disk = on_disk self.on_disk = on_disk
self._bm25_encoder = None
self.create_col(embedding_model_dims, on_disk) self.create_col(embedding_model_dims, on_disk)
def _get_bm25_encoder(self):
"""Lazy-load the BM25 sparse text encoder (fastembed)."""
if self._bm25_encoder is None:
try:
from fastembed import SparseTextEmbedding
self._bm25_encoder = SparseTextEmbedding(model_name="Qdrant/bm25")
logger.info("BM25 encoder loaded (fastembed Qdrant/bm25)")
except ImportError:
logger.warning("fastembed not installed — BM25 keyword search disabled. Install with: pip install fastembed")
self._bm25_encoder = False # sentinel: tried and failed
except Exception as e:
logger.warning(f"Failed to load BM25 encoder: {e}")
self._bm25_encoder = False
return self._bm25_encoder if self._bm25_encoder is not False else None
def _encode_bm25(self, text: str) -> SparseVector | None:
"""Encode text into a BM25 sparse vector."""
encoder = self._get_bm25_encoder()
if encoder is None:
return None
try:
results = list(encoder.embed([text]))
if results:
sparse = results[0]
return SparseVector(
indices=sparse.indices.tolist(),
values=sparse.values.tolist(),
)
except Exception as e:
logger.debug(f"BM25 encoding failed: {e}")
return None
def create_col(self, vector_size: int, on_disk: bool, distance: Distance = Distance.COSINE): def create_col(self, vector_size: int, on_disk: bool, distance: Distance = Distance.COSINE):
""" """
Create a new collection. Create a new collection with dense vectors and BM25 sparse vectors.
Args: Args:
vector_size (int): Size of the vectors to be stored. vector_size (int): Size of the vectors to be stored.
@@ -95,6 +130,11 @@ class Qdrant(VectorStoreBase):
self.client.create_collection( self.client.create_collection(
collection_name=self.collection_name, collection_name=self.collection_name,
vectors_config=VectorParams(size=vector_size, distance=distance, on_disk=on_disk), vectors_config=VectorParams(size=vector_size, distance=distance, on_disk=on_disk),
sparse_vectors_config={
"bm25": SparseVectorParams(
modifier=models.Modifier.IDF,
),
},
) )
self._create_filter_indexes() self._create_filter_indexes()
@@ -120,7 +160,8 @@ class Qdrant(VectorStoreBase):
def insert(self, vectors: list, payloads: list = None, ids: list = None): def insert(self, vectors: list, payloads: list = None, ids: list = None):
""" """
Insert vectors into a collection. Insert vectors into a collection, including BM25 sparse vectors
computed from the text_lemmatized payload field.
Args: Args:
vectors (list): List of vectors to insert. vectors (list): List of vectors to insert.
@@ -128,14 +169,21 @@ class Qdrant(VectorStoreBase):
ids (list, optional): List of IDs corresponding to vectors. Defaults to None. ids (list, optional): List of IDs corresponding to vectors. Defaults to None.
""" """
logger.info(f"Inserting {len(vectors)} vectors into collection {self.collection_name}") logger.info(f"Inserting {len(vectors)} vectors into collection {self.collection_name}")
points = [ points = []
PointStruct( for idx, vector in enumerate(vectors):
id=idx if ids is None else ids[idx], payload = payloads[idx] if payloads else {}
vector=vector, point_id = idx if ids is None else ids[idx]
payload=payloads[idx] if payloads else {},
) # Build named vectors: dense + optional BM25 sparse
for idx, vector in enumerate(vectors) named_vectors = {"": vector}
] text_for_bm25 = payload.get("text_lemmatized") or payload.get("data", "")
if text_for_bm25:
sparse = self._encode_bm25(text_for_bm25)
if sparse is not None:
named_vectors["bm25"] = sparse
points.append(PointStruct(id=point_id, vector=named_vectors, payload=payload))
self.client.upsert(collection_name=self.collection_name, points=points) self.client.upsert(collection_name=self.collection_name, points=points)
def _create_filter(self, filters: dict) -> Filter: def _create_filter(self, filters: dict) -> Filter:
@@ -191,22 +239,24 @@ class Qdrant(VectorStoreBase):
filters (dict, optional): Filters to apply to the search. Defaults to None. filters (dict, optional): Filters to apply to the search. Defaults to None.
Returns: Returns:
list: Search results, or None if the collection does not support sparse vectors. list: Search results, or None if BM25 is not available.
""" """
try: sparse_query = self._encode_bm25(query)
from qdrant_client import models if sparse_query is None:
return None
try:
query_filter = self._create_filter(filters) if filters else None query_filter = self._create_filter(filters) if filters else None
hits = self.client.query_points( hits = self.client.query_points(
collection_name=self.collection_name, collection_name=self.collection_name,
query=models.Document(text=query, model="Qdrant/bm25"), query=sparse_query,
using="bm25", using="bm25",
query_filter=query_filter, query_filter=query_filter,
limit=limit, limit=limit,
) )
return hits.points return hits.points
except Exception as e: except Exception as e:
logger.debug(f"BM25 keyword search failed (collection may lack sparse vector config): {e}") logger.debug(f"BM25 keyword search failed: {e}")
return None return None
def delete(self, vector_id: int): def delete(self, vector_id: int):
@@ -232,7 +282,16 @@ class Qdrant(VectorStoreBase):
vector (list, optional): Updated vector. Defaults to None. vector (list, optional): Updated vector. Defaults to None.
payload (dict, optional): Updated payload. Defaults to None. payload (dict, optional): Updated payload. Defaults to None.
""" """
point = PointStruct(id=vector_id, vector=vector, payload=payload) # Build named vectors with BM25 if payload has text
named_vectors = {"": vector} if vector else vector
if payload and vector:
text_for_bm25 = payload.get("text_lemmatized") or payload.get("data", "")
if text_for_bm25:
sparse = self._encode_bm25(text_for_bm25)
if sparse is not None:
named_vectors["bm25"] = sparse
point = PointStruct(id=vector_id, vector=named_vectors, payload=payload)
self.client.upsert(collection_name=self.collection_name, points=[point]) self.client.upsert(collection_name=self.collection_name, points=[point])
def get(self, vector_id: int) -> dict: def get(self, vector_id: int) -> dict: