add BM25 hybrid search support for Qdrant (multitenancy mode)

This commit is contained in:
MiXaiLL76 2026-03-31 22:07:18 +03:00
parent 9bd84258d0
commit a0e595c1c5
21 changed files with 219 additions and 40 deletions

View file

@ -2276,6 +2276,12 @@ QDRANT_TIMEOUT = int(os.environ.get('QDRANT_TIMEOUT', '5'))
QDRANT_HNSW_M = int(os.environ.get('QDRANT_HNSW_M', '16'))
ENABLE_QDRANT_MULTITENANCY_MODE = os.environ.get('ENABLE_QDRANT_MULTITENANCY_MODE', 'true').lower() == 'true'
QDRANT_COLLECTION_PREFIX = os.environ.get('QDRANT_COLLECTION_PREFIX', 'open-webui')
QDRANT_HYBRID_SEARCH_ENABLED = os.environ.get('QDRANT_HYBRID_SEARCH_ENABLED', 'true').lower() == 'true'
QDRANT_SPARSE_EMBEDDING_MODEL = os.environ.get('QDRANT_SPARSE_EMBEDDING_MODEL', 'Qdrant/bm25')
QDRANT_DENSE_VECTOR_NAME = os.environ.get('QDRANT_DENSE_VECTOR_NAME', 'dense')
QDRANT_SPARSE_VECTOR_NAME = os.environ.get('QDRANT_SPARSE_VECTOR_NAME', 'sparse')
QDRANT_HYBRID_SEARCH_RRF_K = int(os.environ.get('QDRANT_HYBRID_SEARCH_RRF_K', '60'))
QDRANT_SPARSE_ON_DISK = os.environ.get('QDRANT_SPARSE_ON_DISK', 'false').lower() == 'true'
WEAVIATE_HTTP_HOST = os.environ.get('WEAVIATE_HTTP_HOST', '')
WEAVIATE_GRPC_HOST = os.environ.get('WEAVIATE_GRPC_HOST', '')

View file

@ -125,6 +125,7 @@ class VectorSearchRetriever(BaseRetriever):
collection_name=self.collection_name,
vectors=[embedding],
limit=self.top_k,
query=query,
)
ids = result.ids[0]
@ -144,13 +145,14 @@ class VectorSearchRetriever(BaseRetriever):
return results
def query_doc(collection_name: str, query_embedding: list[float], k: int, user: UserModel = None):
def query_doc(collection_name: str, query_embedding: list[float], k: int, user: UserModel = None, query_text: Optional[str] = None):
try:
log.debug(f'query_doc:doc {collection_name}')
result = VECTOR_DB_CLIENT.search(
collection_name=collection_name,
vectors=[query_embedding],
limit=k,
query=query_text,
)
if result:
@ -436,13 +438,14 @@ async def query_collection(
results = []
error = False
def process_query_collection(collection_name, query_embedding):
def process_query_collection(collection_name, query_embedding, query_text : Optional[str] = None):
try:
if collection_name:
result = query_doc(
collection_name=collection_name,
k=k,
query_embedding=query_embedding,
query_text=query_text,
)
if result is not None:
return result.model_dump(), None
@ -457,9 +460,9 @@ async def query_collection(
with ThreadPoolExecutor() as executor:
future_results = []
for query_embedding in query_embeddings:
for query_text, query_embedding in zip(queries, query_embeddings):
for collection_name in collection_names:
result = executor.submit(process_query_collection, collection_name, query_embedding)
result = executor.submit(process_query_collection, collection_name, query_embedding, query_text)
future_results.append(result)
task_results = [future.result() for future in future_results]

View file

@ -72,6 +72,7 @@ class ChromaClient(VectorDBBase):
vectors: list[list[float | int]],
filter: Optional[dict] = None,
limit: int = 10,
query: Optional[str] = None,
) -> Optional[SearchResult]:
# Search for the nearest neighbor items based on the vectors and return 'limit' number of results.
try:

View file

@ -160,6 +160,7 @@ class ElasticsearchClient(VectorDBBase):
vectors: list[list[float]],
filter: Optional[dict] = None,
limit: int = 10,
query: Optional[str] = None,
) -> Optional[SearchResult]:
query = {
'size': limit,

View file

@ -384,6 +384,7 @@ class MariaDBVectorClient(VectorDBBase):
vectors: List[List[float]],
filter: Optional[Dict[str, Any]] = None,
limit: int = 10,
query: Optional[str] = None,
) -> Optional[SearchResult]:
"""
Perform a vector similarity search.

View file

@ -182,6 +182,7 @@ class MilvusClient(VectorDBBase):
vectors: list[list[float | int]],
filter: Optional[dict] = None,
limit: int = 10,
query: Optional[str] = None,
) -> Optional[SearchResult]:
# Search for the nearest neighbor items based on the vectors and return 'limit' number of results.
collection_name = collection_name.replace('-', '_')

View file

@ -160,6 +160,7 @@ class MilvusClient(VectorDBBase):
vectors: List[List[float]],
filter: Optional[Dict] = None,
limit: int = 10,
query: Optional[str] = None,
) -> Optional[SearchResult]:
if not vectors:
return None

View file

@ -222,6 +222,7 @@ class OpenGaussClient(VectorDBBase):
vectors: List[List[float]],
filter: Optional[Dict[str, Any]] = None,
limit: int = 10,
query: Optional[str] = None,
) -> Optional[SearchResult]:
try:
if not vectors:

View file

@ -120,6 +120,7 @@ class OpenSearchClient(VectorDBBase):
vectors: list[list[float | int]],
filter: Optional[dict] = None,
limit: int = 10,
query: Optional[str] = None,
) -> Optional[SearchResult]:
try:
if not self.has_collection(collection_name):

View file

@ -518,6 +518,7 @@ class Oracle23aiClient(VectorDBBase):
vectors: List[List[Union[float, int]]],
filter: Optional[dict] = None,
limit: int = 10,
query: Optional[str] = None,
) -> Optional[SearchResult]:
"""
Search for similar vectors in the database.

View file

@ -396,6 +396,7 @@ class PgvectorClient(VectorDBBase):
vectors: List[List[float]],
filter: Optional[Dict[str, Any]] = None,
limit: int = 10,
query: Optional[str] = None,
) -> Optional[SearchResult]:
try:
if not vectors:

View file

@ -354,6 +354,7 @@ class PineconeClient(VectorDBBase):
vectors: List[List[Union[float, int]]],
filter: Optional[dict] = None,
limit: int = 10,
query: Optional[str] = None,
) -> Optional[SearchResult]:
"""Search for similar vectors in a collection."""
if not vectors or not vectors[0]:

View file

@ -148,6 +148,7 @@ class QdrantClient(VectorDBBase):
vectors: list[list[float | int]],
filter: Optional[dict] = None,
limit: int = 10,
query: Optional[str] = None,
) -> Optional[SearchResult]:
# Search for the nearest neighbor items based on the vectors and return 'limit' number of results.
if limit is None:

View file

@ -16,7 +16,14 @@ from open_webui.config import (
QDRANT_COLLECTION_PREFIX,
QDRANT_TIMEOUT,
QDRANT_HNSW_M,
QDRANT_HYBRID_SEARCH_ENABLED,
QDRANT_SPARSE_EMBEDDING_MODEL,
QDRANT_DENSE_VECTOR_NAME,
QDRANT_SPARSE_VECTOR_NAME,
QDRANT_HYBRID_SEARCH_RRF_K,
QDRANT_SPARSE_ON_DISK,
)
from open_webui.retrieval.vector.main import (
GetResult,
SearchResult,
@ -25,9 +32,16 @@ from open_webui.retrieval.vector.main import (
)
from qdrant_client import QdrantClient as Qclient
from qdrant_client.http.exceptions import UnexpectedResponse
from qdrant_client.http.models import PointStruct
from qdrant_client.http.models import PointStruct, SparseVector
from qdrant_client.models import models
try:
from fastembed.sparse import SparseTextEmbedding
FASTEMBED_AVAILABLE = True
except ImportError:
FASTEMBED_AVAILABLE = False
NO_LIMIT = 999999999
TENANT_ID_FIELD = 'tenant_id'
DEFAULT_DIMENSION = 384
@ -53,6 +67,12 @@ class QdrantClient(VectorDBBase):
self.GRPC_PORT = QDRANT_GRPC_PORT
self.QDRANT_TIMEOUT = QDRANT_TIMEOUT
self.QDRANT_HNSW_M = QDRANT_HNSW_M
self.QDRANT_HYBRID_SEARCH_ENABLED = QDRANT_HYBRID_SEARCH_ENABLED
self.QDRANT_SPARSE_EMBEDDING_MODEL = QDRANT_SPARSE_EMBEDDING_MODEL
self.QDRANT_DENSE_VECTOR_NAME = QDRANT_DENSE_VECTOR_NAME
self.QDRANT_SPARSE_VECTOR_NAME = QDRANT_SPARSE_VECTOR_NAME
self.QDRANT_HYBRID_SEARCH_RRF_K = QDRANT_HYBRID_SEARCH_RRF_K
self.QDRANT_SPARSE_ON_DISK = QDRANT_SPARSE_ON_DISK
if not self.QDRANT_URI:
raise ValueError('QDRANT_URI is not set. Please configure it in the environment variables.')
@ -86,6 +106,42 @@ class QdrantClient(VectorDBBase):
self.WEB_SEARCH_COLLECTION = f'{self.collection_prefix}_web-search'
self.HASH_BASED_COLLECTION = f'{self.collection_prefix}_hash-based'
# Initialize sparse encoder if hybrid search is enabled
self.sparse_encoder = None
if self.QDRANT_HYBRID_SEARCH_ENABLED:
if FASTEMBED_AVAILABLE:
try:
self.sparse_encoder = SparseTextEmbedding(model_name=self.QDRANT_SPARSE_EMBEDDING_MODEL)
log.info(f'Hybrid search enabled with sparse model: {self.QDRANT_SPARSE_EMBEDDING_MODEL}')
except Exception as e:
log.warning(f'Failed to load sparse encoder, hybrid search disabled: {e}')
else:
log.warning(
"fastembed not installed, hybrid search disabled. Install with: pip install 'fastembed>=0.6.1'"
)
@property
def _hybrid_enabled(self) -> bool:
return self.QDRANT_HYBRID_SEARCH_ENABLED and self.sparse_encoder is not None
def _is_hybrid_collection(self, mt_collection_name: str) -> bool:
"""Check if an existing collection has sparse vectors (i.e. was created as hybrid)."""
try:
info = self.client.get_collection(mt_collection_name)
return bool(info.config.params.sparse_vectors)
except Exception:
return False
def _encode_sparse(self, texts: list[str]) -> list[SparseVector]:
"""Encode texts into sparse vectors using fastembed."""
embeddings = list(self.sparse_encoder.embed(texts))
return [SparseVector(indices=e.indices.tolist(), values=e.values.tolist()) for e in embeddings]
def _encode_sparse_query(self, text: str) -> SparseVector:
"""Encode a query text into a sparse vector."""
embeddings = list(self.sparse_encoder.query_embed(text))
e = embeddings[0]
return SparseVector(indices=e.indices.tolist(), values=e.values.tolist())
def _result_to_get_result(self, points) -> GetResult:
ids, documents, metadatas = [], [], []
for point in points:
@ -134,21 +190,46 @@ class QdrantClient(VectorDBBase):
"""
Creates a collection with multi-tenancy configuration and payload indexes for tenant_id and metadata fields.
"""
self.client.create_collection(
collection_name=mt_collection_name,
vectors_config=models.VectorParams(
size=dimension,
distance=models.Distance.COSINE,
on_disk=self.QDRANT_ON_DISK,
),
# Disable global index building due to multitenancy
# For more details https://qdrant.tech/documentation/guides/multiple-partitions/#calibrate-performance
hnsw_config=models.HnswConfigDiff(
payload_m=self.QDRANT_HNSW_M,
m=0,
),
)
log.info(f'Multi-tenant collection {mt_collection_name} created with dimension {dimension}!')
if self._hybrid_enabled:
self.client.create_collection(
collection_name=mt_collection_name,
vectors_config={
self.QDRANT_DENSE_VECTOR_NAME: models.VectorParams(
size=dimension,
distance=models.Distance.COSINE,
on_disk=self.QDRANT_ON_DISK,
)
},
sparse_vectors_config={
self.QDRANT_SPARSE_VECTOR_NAME: models.SparseVectorParams(
index=models.SparseIndexParams(on_disk=self.QDRANT_SPARSE_ON_DISK)
)
},
hnsw_config=models.HnswConfigDiff(
payload_m=self.QDRANT_HNSW_M,
m=0,
),
)
log.info(
f'Multi-tenant hybrid collection {mt_collection_name} created with '
f'dense ({dimension} dims) + sparse vectors'
)
else:
self.client.create_collection(
collection_name=mt_collection_name,
vectors_config=models.VectorParams(
size=dimension,
distance=models.Distance.COSINE,
on_disk=self.QDRANT_ON_DISK,
),
# Disable global index building due to multitenancy
# For more details https://qdrant.tech/documentation/guides/multiple-partitions/#calibrate-performance
hnsw_config=models.HnswConfigDiff(
payload_m=self.QDRANT_HNSW_M,
m=0,
),
)
log.info(f'Multi-tenant collection {mt_collection_name} created with dimension {dimension}')
self.client.create_payload_index(
collection_name=mt_collection_name,
@ -170,22 +251,60 @@ class QdrantClient(VectorDBBase):
),
)
def _create_points(self, items: List[VectorItem], tenant_id: str) -> List[PointStruct]:
def _create_points(
self, items: list[VectorItem], tenant_id: str, collection_is_hybrid: bool = False
) -> list[PointStruct]:
"""
Create point structs from vector items with tenant ID.
Uses named vectors when collection_is_hybrid is True.
If hybrid is also enabled in config, adds sparse vectors; otherwise dense-only named vector.
"""
return [
PointStruct(
id=item['id'],
vector=item['vector'],
payload={
'text': item['text'],
'metadata': item['metadata'],
TENANT_ID_FIELD: tenant_id,
},
)
for item in items
]
if collection_is_hybrid:
if self._hybrid_enabled:
texts = [item['text'] for item in items]
sparse_vectors = self._encode_sparse(texts)
return [
PointStruct(
id=item['id'],
vector={
self.QDRANT_DENSE_VECTOR_NAME: item['vector'],
self.QDRANT_SPARSE_VECTOR_NAME: sparse_vec,
},
payload={
'text': item['text'],
'metadata': item['metadata'],
TENANT_ID_FIELD: tenant_id,
},
)
for item, sparse_vec in zip(items, sparse_vectors)
]
else:
# Collection is hybrid but encoder unavailable — store only dense named vector
return [
PointStruct(
id=item['id'],
vector={self.QDRANT_DENSE_VECTOR_NAME: item['vector']},
payload={
'text': item['text'],
'metadata': item['metadata'],
TENANT_ID_FIELD: tenant_id,
},
)
for item in items
]
else:
return [
PointStruct(
id=item['id'],
vector=item['vector'],
payload={
'text': item['text'],
'metadata': item['metadata'],
TENANT_ID_FIELD: tenant_id,
},
)
for item in items
]
def _ensure_collection(self, mt_collection_name: str, dimension: int = DEFAULT_DIMENSION):
"""
@ -245,9 +364,11 @@ class QdrantClient(VectorDBBase):
vectors: List[List[float | int]],
filter: Optional[Dict] = None,
limit: int = 10,
query: Optional[str] = None,
) -> Optional[SearchResult]:
"""
Search for the nearest neighbor items based on the vectors with tenant isolation.
Uses hybrid search (dense + sparse RRF) when enabled and query is provided.
"""
if not self.client or not vectors:
return None
@ -257,12 +378,42 @@ class QdrantClient(VectorDBBase):
return None
tenant_filter = _tenant_filter(tenant_id)
query_response = self.client.query_points(
collection_name=mt_collection,
query=vectors[0],
limit=limit,
query_filter=models.Filter(must=[tenant_filter]),
)
combined_filter = models.Filter(must=[tenant_filter])
collection_is_hybrid = self._is_hybrid_collection(mt_collection)
# Hybrid search with RRF fusion
if collection_is_hybrid and self._hybrid_enabled and query:
sparse_query = self._encode_sparse_query(query)
query_response = self.client.query_points(
collection_name=mt_collection,
query=models.FusionQuery(fusion=models.Fusion.RRF),
prefetch=[
models.Prefetch(
query=vectors[0],
using=self.QDRANT_DENSE_VECTOR_NAME,
filter=combined_filter,
limit=limit * 2,
),
models.Prefetch(
query=sparse_query,
using=self.QDRANT_SPARSE_VECTOR_NAME,
filter=combined_filter,
limit=limit * 2,
),
],
limit=limit,
)
else:
# Dense-only search — use named vector if collection schema requires it
query_vector = (self.QDRANT_DENSE_VECTOR_NAME, vectors[0]) if collection_is_hybrid else vectors[0]
query_response = self.client.query_points(
collection_name=mt_collection,
query=query_vector,
limit=limit,
query_filter=combined_filter,
)
get_result = self._result_to_get_result(query_response.points)
return SearchResult(
ids=get_result.ids,
@ -320,7 +471,8 @@ class QdrantClient(VectorDBBase):
mt_collection, tenant_id = self._get_collection_and_tenant_id(collection_name)
dimension = len(items[0]['vector'])
self._ensure_collection(mt_collection, dimension)
points = self._create_points(items, tenant_id)
collection_is_hybrid = self._is_hybrid_collection(mt_collection)
points = self._create_points(items, tenant_id, collection_is_hybrid)
self.client.upload_points(mt_collection, points)
return None

View file

@ -285,6 +285,7 @@ class S3VectorClient(VectorDBBase):
vectors: List[List[Union[float, int]]],
filter: Optional[dict] = None,
limit: int = 10,
query: Optional[str] = None,
) -> Optional[SearchResult]:
"""
Search for similar vectors in a collection using multiple query vectors.

View file

@ -162,6 +162,7 @@ class WeaviateClient(VectorDBBase):
vectors: List[List[Union[float, int]]],
filter: Optional[dict] = None,
limit: int = 10,
query: Optional[str] = None,
) -> Optional[SearchResult]:
sane_collection_name = self._sanitize_collection_name(collection_name)
if not self.client.collections.exists(sane_collection_name):

View file

@ -58,6 +58,7 @@ class VectorDBBase(ABC):
vectors: List[List[Union[float, int]]],
filter: Optional[Dict] = None,
limit: int = 10,
query: Optional[str] = None,
) -> Optional[SearchResult]:
"""Search for similar vectors in a collection."""
pass

View file

@ -142,6 +142,7 @@ async def query_memory(
collection_name=f'user-memory-{user.id}',
vectors=[vector],
limit=form_data.k,
query=form_data.content,
)
return results

View file

@ -2409,6 +2409,7 @@ async def query_doc_handler(
query_embedding=query_embedding,
k=form_data.k if form_data.k else request.app.state.config.TOP_K,
user=user,
query_text=form_data.query,
)
except Exception as e:
log.exception(e)

View file

@ -2221,6 +2221,7 @@ async def query_knowledge_bases(
vectors=[query_embedding],
filter={'knowledge_base_id': {'$in': accessible_ids}},
limit=count,
query=query,
)
if search_results and search_results.ids and search_results.ids[0]:

View file

@ -124,6 +124,7 @@ boto3==1.42.62
pymilvus==2.6.9
qdrant-client==1.17.0
fastembed==0.8.0 # fastembed for qdrant SparseTextEmbedding
playwright==1.58.0 # Caution: version must match docker-compose.playwright.yaml - Update the docker-compose.yaml if necessary
elasticsearch==9.3.0
pinecone==6.0.2