mirror of
https://github.com/open-webui/open-webui.git
synced 2026-10-22 03:14:54 -05:00
With Chroma as the vector DB, hybrid search on a knowledge base with more than 32766 chunks fails with HTTP 400 "Error querying knowledge base". The legacy hybrid path fetches the whole collection to build the BM25 index, and Chroma's unbounded collection.get() binds one SQLite variable per row, so any collection above SQLite's 32766 variable limit raises "too many SQL variables" (reproduced on chromadb 1.5.9 with both PersistentClient and HttpClient). Vector-only search on the same collection works, which makes it look like a hybrid-search bug. The Chroma adapter now reads the collection in pages of 10000 rows via limit/offset and concatenates them into the same GetResult shape as before. Verified on a 90000-row collection: every row returned exactly once with documents and metadata aligned to ids, page order stable across page sizes, empty and exactly-one-page collections unchanged, and query_doc_with_hybrid_search returns results where it previously raised. The tests repo unit suite is identical before and after. Fixes #30351
207 lines
7.7 KiB
Python
Executable File
207 lines
7.7 KiB
Python
Executable File
import logging
|
|
from typing import Optional
|
|
|
|
import chromadb
|
|
from chromadb import Settings
|
|
from chromadb.errors import NotFoundError
|
|
from chromadb.utils.batch_utils import create_batches
|
|
from open_webui.config import (
|
|
CHROMA_CLIENT_AUTH_CREDENTIALS,
|
|
CHROMA_CLIENT_AUTH_PROVIDER,
|
|
CHROMA_DATA_PATH,
|
|
CHROMA_DATABASE,
|
|
CHROMA_HTTP_HEADERS,
|
|
CHROMA_HTTP_HOST,
|
|
CHROMA_HTTP_PORT,
|
|
CHROMA_HTTP_SSL,
|
|
CHROMA_TENANT,
|
|
)
|
|
from open_webui.env import USE_SLIM
|
|
from fastapi import HTTPException
|
|
from open_webui.retrieval.vector.main import (
|
|
GetResult,
|
|
SearchResult,
|
|
VectorDBBase,
|
|
VectorItem,
|
|
)
|
|
from open_webui.retrieval.vector.utils import process_metadata
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
GET_PAGE_SIZE = 10000
|
|
|
|
|
|
class ChromaClient(VectorDBBase):
|
|
def __init__(self):
|
|
if USE_SLIM and not CHROMA_HTTP_HOST:
|
|
raise HTTPException(503, 'Configure CHROMA_HTTP_HOST: embedded Chroma is unavailable in slim.')
|
|
settings_dict = {
|
|
'allow_reset': True,
|
|
'anonymized_telemetry': False,
|
|
}
|
|
if CHROMA_CLIENT_AUTH_PROVIDER is not None:
|
|
settings_dict['chroma_client_auth_provider'] = CHROMA_CLIENT_AUTH_PROVIDER
|
|
if CHROMA_CLIENT_AUTH_CREDENTIALS is not None:
|
|
settings_dict['chroma_client_auth_credentials'] = CHROMA_CLIENT_AUTH_CREDENTIALS
|
|
|
|
if CHROMA_HTTP_HOST != '':
|
|
self.client = chromadb.HttpClient(
|
|
host=CHROMA_HTTP_HOST,
|
|
port=CHROMA_HTTP_PORT,
|
|
headers=CHROMA_HTTP_HEADERS,
|
|
ssl=CHROMA_HTTP_SSL,
|
|
tenant=CHROMA_TENANT,
|
|
database=CHROMA_DATABASE,
|
|
settings=Settings(**settings_dict),
|
|
)
|
|
else:
|
|
self.client = chromadb.PersistentClient(
|
|
path=CHROMA_DATA_PATH,
|
|
settings=Settings(**settings_dict),
|
|
tenant=CHROMA_TENANT,
|
|
database=CHROMA_DATABASE,
|
|
)
|
|
|
|
def has_collection(self, collection_name: str) -> bool:
|
|
try:
|
|
self.client.get_collection(name=collection_name, embedding_function=None)
|
|
return True
|
|
except NotFoundError:
|
|
return False
|
|
|
|
def delete_collection(self, collection_name: str):
|
|
# Delete the collection based on the collection name.
|
|
return self.client.delete_collection(name=collection_name)
|
|
|
|
def search(
|
|
self,
|
|
collection_name: str,
|
|
vectors: list[list[float | int]],
|
|
filter: Optional[dict] = None,
|
|
limit: int = 10,
|
|
) -> Optional[SearchResult]:
|
|
# Search for the nearest neighbor items based on the vectors and return 'limit' number of results.
|
|
try:
|
|
collection = self.client.get_collection(name=collection_name, embedding_function=None)
|
|
if collection:
|
|
result = collection.query(
|
|
query_embeddings=vectors,
|
|
n_results=limit,
|
|
where=filter,
|
|
)
|
|
|
|
# chromadb has cosine distance, 2 (worst) -> 0 (best). Re-odering to 0 -> 1
|
|
# https://docs.trychroma.com/docs/collections/configure cosine equation
|
|
distances: list = result['distances'][0]
|
|
distances = [2 - dist for dist in distances]
|
|
distances = [[dist / 2 for dist in distances]]
|
|
|
|
return SearchResult(
|
|
**{
|
|
'ids': result['ids'],
|
|
'distances': distances,
|
|
'documents': result['documents'],
|
|
'metadatas': result['metadatas'],
|
|
}
|
|
)
|
|
return None
|
|
except Exception as e:
|
|
return None
|
|
|
|
def query(self, collection_name: str, filter: dict, limit: Optional[int] = None) -> Optional[GetResult]:
|
|
# Query the items from the collection based on the filter.
|
|
try:
|
|
collection = self.client.get_collection(name=collection_name, embedding_function=None)
|
|
if collection:
|
|
result = collection.get(
|
|
where=filter,
|
|
limit=limit,
|
|
)
|
|
|
|
return GetResult(
|
|
**{
|
|
'ids': [result['ids']],
|
|
'documents': [result['documents']],
|
|
'metadatas': [result['metadatas']],
|
|
}
|
|
)
|
|
return None
|
|
except Exception:
|
|
return None
|
|
|
|
def get(self, collection_name: str) -> Optional[GetResult]:
|
|
# Get all the items in the collection.
|
|
collection = self.client.get_collection(name=collection_name, embedding_function=None)
|
|
if collection:
|
|
ids, documents, metadatas = [], [], []
|
|
# Unpaged get() exceeds SQLite's bind-variable limit on large collections
|
|
for offset in range(0, collection.count(), GET_PAGE_SIZE):
|
|
page = collection.get(limit=GET_PAGE_SIZE, offset=offset)
|
|
ids.extend(page['ids'])
|
|
documents.extend(page['documents'])
|
|
metadatas.extend(page['metadatas'])
|
|
return GetResult(
|
|
**{
|
|
'ids': [ids],
|
|
'documents': [documents],
|
|
'metadatas': [metadatas],
|
|
}
|
|
)
|
|
return None
|
|
|
|
def insert(self, collection_name: str, items: list[VectorItem]):
|
|
# Insert the items into the collection, if the collection does not exist, it will be created.
|
|
collection = self.client.get_or_create_collection(
|
|
name=collection_name, metadata={'hnsw:space': 'cosine'}, embedding_function=None
|
|
)
|
|
|
|
ids = [item['id'] for item in items]
|
|
documents = [item['text'] for item in items]
|
|
embeddings = [item['vector'] for item in items]
|
|
metadatas = [process_metadata(item['metadata']) for item in items]
|
|
|
|
for batch in create_batches(
|
|
api=self.client,
|
|
documents=documents,
|
|
embeddings=embeddings,
|
|
ids=ids,
|
|
metadatas=metadatas,
|
|
):
|
|
collection.add(*batch)
|
|
|
|
def upsert(self, collection_name: str, items: list[VectorItem]):
|
|
# Update the items in the collection, if the items are not present, insert them. If the collection does not exist, it will be created.
|
|
collection = self.client.get_or_create_collection(
|
|
name=collection_name, metadata={'hnsw:space': 'cosine'}, embedding_function=None
|
|
)
|
|
|
|
ids = [item['id'] for item in items]
|
|
documents = [item['text'] for item in items]
|
|
embeddings = [item['vector'] for item in items]
|
|
metadatas = [process_metadata(item['metadata']) for item in items]
|
|
|
|
collection.upsert(ids=ids, documents=documents, embeddings=embeddings, metadatas=metadatas)
|
|
|
|
def delete(
|
|
self,
|
|
collection_name: str,
|
|
ids: Optional[list[str]] = None,
|
|
filter: Optional[dict] = None,
|
|
):
|
|
# Delete the items from the collection based on the ids.
|
|
try:
|
|
collection = self.client.get_collection(name=collection_name, embedding_function=None)
|
|
if collection:
|
|
if ids:
|
|
collection.delete(ids=ids)
|
|
elif filter:
|
|
collection.delete(where=filter)
|
|
except Exception as e:
|
|
# If collection doesn't exist, that's fine - nothing to delete
|
|
log.debug('Attempted to delete from non-existent collection %s. Ignoring.', collection_name)
|
|
pass
|
|
|
|
def reset(self):
|
|
# Resets the database. This will delete all collections and item entries.
|
|
return self.client.reset()
|