mirror of
https://github.com/open-webui/open-webui.git
synced 2026-07-26 22:49:41 -05:00
* fix(retrieval): offload sync VECTOR_DB_CLIENT calls in async paths via AsyncVectorDBClient The vector DB backends (Chroma, pgvector, Qdrant, Milvus, Pinecone, Weaviate, …) are uniformly synchronous and their methods perform blocking network or disk I/O. Multiple async route handlers and helpers were calling them directly on the event loop — file processing, memories, knowledge bases, hybrid search bookkeeping — so a single upsert/delete/search would freeze every other in-flight request for the duration of the call. Introduce `AsyncVectorDBClient`, a thin async facade that wraps the existing sync client and dispatches each method through `asyncio.to_thread`. It mirrors `VectorDBBase` exactly and forwards *args/**kwargs so backend-specific extra parameters keep working. Update every async-context call site (routers/retrieval, routers/files, routers/memories, routers/knowledge, retrieval/utils, tools/builtin) to await `ASYNC_VECTOR_DB_CLIENT` instead of calling the sync client directly. Two helpers that were sync-only also acquire async siblings or are awaited via `asyncio.to_thread` at their async call site (`remove_knowledge_base_metadata_embedding`, `get_all_items_from_collections`, `query_doc`). The original sync `VECTOR_DB_CLIENT` is unchanged, so callers that already run inside `run_in_threadpool` (e.g. `save_docs_to_vector_db` and the sync `query_doc`/`get_doc` helpers) are unaffected. https://claude.ai/code/session_01JSr4NZSskEUQvoJnavVXh8 * fix(retrieval): restore explicit AsyncVectorDBClient signatures matching VectorDBBase Per PR review: the original *args/**kwargs forwarding lost type safety and IDE/static-analysis support. Restore explicit signatures that mirror VectorDBBase exactly, so: * Bad kwargs fail at the facade boundary instead of inside the worker thread (where the resulting TypeError tends to be swallowed by surrounding `try/except`). * IDE autocomplete and static analysis work as expected. * The stated intent ("mirror VectorDBBase exactly") now holds at the API contract level, not just behaviourally. While doing this, surface a pre-existing bug in `delete_entries_from_collection` that the stricter typing flagged: the call passed `metadata={'hash': hash}` which is not a parameter on `VectorDBBase.delete` nor any backend. The TypeError raised inside the sync delete was silently swallowed by `except Exception` so the endpoint always reported `{'status': False}` for every request instead of actually deleting matching vectors. Replace with `filter=...` to do what the endpoint name promises. The thorough review's other note (no concurrency/backpressure on the shared default threadpool) is intentionally not addressed here: asyncio.to_thread on the shared executor is the right primitive for this use case; per-domain bounded executors would add lifecycle complexity disproportionate to the problem and the loop is no longer blocked, which was the actual bug. https://claude.ai/code/session_01JSr4NZSskEUQvoJnavVXh8 * fix(retrieval): parallelize hybrid-search collection prefetch; document async facade contracts Address PR review findings: 1. Hybrid-search prefetch was sequential `query_collection_with_hybrid_search` previously awaited `ASYNC_VECTOR_DB_CLIENT.get(name)` once per collection in a for loop. Each call already off-loaded to a worker thread, but awaiting them serially meant total prefetch latency scaled linearly with the number of collections. Run them concurrently with `asyncio.gather` so multi-collection queries actually benefit from the threadpool. Per-collection exception handling is preserved by wrapping each fetch in a small helper that logs and returns `(name, None)` on failure, so a single bad collection cannot poison the whole gather. 2. Document the thread-safety expectation explicitly The facade now formally states what was always implicit: the sync `VECTOR_DB_CLIENT` is shared across worker threads, so the underlying backend driver must be thread-safe. This is not a new exposure — `save_docs_to_vector_db` already called the sync client from `run_in_threadpool`. Adding a global lock here would defeat the responsiveness the facade exists to provide; backends that cannot tolerate concurrent access should grow their own internal serialization. 3. Document the API-surface choice and `.sync` escape hatch The strict `VectorDBBase` mirror was a deliberate choice (the previous `*args/**kwargs` revision let a `metadata=` typo silently break an endpoint). Document it, and call out the `.sync` escape hatch with an example for callers that genuinely need a backend-specific parameter not on `VectorDBBase`. https://claude.ai/code/session_01JSr4NZSskEUQvoJnavVXh8 * fix(retrieval): guard /delete against null file.hash and let HTTPException reach the client Address PR review finding on the `metadata=` → `filter=` change in `delete_entries_from_collection`. The new `filter={'hash': hash}` query was correct for files that have a hash, but did not handle `file.hash is None` (unprocessed, failed, or legacy records). The match semantics of a null filter value are backend-dependent — some ignore the key entirely, some treat it as "metadata field absent" and match every such row — so issuing the query risked deleting unrelated entries. * Reject `hash is None` up front with a 400 explaining the file has no hash to target. * Narrow the surrounding `except Exception` so it no longer swallows `HTTPException`. Without this fix the new 400 (and the pre-existing 404 for missing files) would be silently re-shaped into `{'status': False}` and the caller could not distinguish a bad-request input from a backend error. https://claude.ai/code/session_01JSr4NZSskEUQvoJnavVXh8 --------- Co-authored-by: Claude <noreply@anthropic.com>
136 lines
5.1 KiB
Python
136 lines
5.1 KiB
Python
"""
|
|
Async facade over the synchronous VECTOR_DB_CLIENT.
|
|
|
|
The vector DB backends bundled with Open WebUI (Chroma, pgvector, Qdrant,
|
|
Milvus, OpenSearch, Pinecone, Weaviate, …) all expose a uniformly
|
|
synchronous API. Each method performs blocking network or disk I/O — and
|
|
some, like `insert`/`upsert`, can run for several seconds.
|
|
|
|
When such a sync method is awaited from an async route handler, it blocks
|
|
the event loop for its entire duration, freezing every other in-flight
|
|
HTTP request, websocket message and background task.
|
|
|
|
This module wraps the sync client in an `AsyncVectorDBClient` that
|
|
transparently dispatches each call to a worker thread via
|
|
`asyncio.to_thread`. Async callers can `await ASYNC_VECTOR_DB_CLIENT.x(...)`
|
|
in place of `VECTOR_DB_CLIENT.x(...)` and the loop stays responsive.
|
|
|
|
The original `VECTOR_DB_CLIENT` is unchanged, so callers already running
|
|
inside `run_in_threadpool` (e.g. `save_docs_to_vector_db`) are not
|
|
affected.
|
|
|
|
Thread-safety expectations
|
|
--------------------------
|
|
Every async caller now invokes `VECTOR_DB_CLIENT` from a worker thread
|
|
rather than the event-loop thread, and many can run concurrently. The
|
|
sync client (and its underlying backend driver) is therefore expected
|
|
to be safe for concurrent use across threads, which is the standard
|
|
contract for the bundled drivers (chroma, pgvector via SQLAlchemy
|
|
pool, qdrant-client, opensearch-py, …). This is *not* a new exposure
|
|
introduced by this facade — `save_docs_to_vector_db` already called
|
|
the sync client from `run_in_threadpool`, so concurrent threaded
|
|
access has always been a requirement of the codebase. Adding a global
|
|
serialization lock here would defeat the responsiveness this facade
|
|
exists to provide; any backend that genuinely cannot tolerate
|
|
concurrent access should grow its own internal serialization.
|
|
|
|
API surface
|
|
-----------
|
|
Method signatures mirror `VectorDBBase` exactly. This is deliberate:
|
|
permissive `*args/**kwargs` forwarding hides typos at the call site
|
|
(an earlier revision of this file shipped that, and a `metadata=`
|
|
typo silently broke an entire endpoint until explicit signatures
|
|
surfaced it). Callers that need a backend-specific parameter not on
|
|
`VectorDBBase` should reach for the `.sync` escape hatch and wrap
|
|
their own `asyncio.to_thread`, e.g. ::
|
|
|
|
await asyncio.to_thread(
|
|
ASYNC_VECTOR_DB_CLIENT.sync.some_backend_specific_op,
|
|
collection_name, special_kwarg=value,
|
|
)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from typing import Dict, List, Optional, Union
|
|
|
|
from open_webui.retrieval.vector.factory import VECTOR_DB_CLIENT
|
|
from open_webui.retrieval.vector.main import (
|
|
GetResult,
|
|
SearchResult,
|
|
VectorDBBase,
|
|
VectorItem,
|
|
)
|
|
|
|
|
|
class AsyncVectorDBClient:
|
|
"""Awaitable mirror of `VectorDBBase` that off-loads each call to a thread.
|
|
|
|
Method signatures mirror `VectorDBBase` exactly so static analysis
|
|
catches bad kwargs at the call site instead of letting them surface
|
|
deep inside the worker thread (where the resulting ``TypeError`` is
|
|
typically swallowed by surrounding ``try/except``).
|
|
"""
|
|
|
|
def __init__(self, sync_client: VectorDBBase) -> None:
|
|
self._sync = sync_client
|
|
|
|
@property
|
|
def sync(self) -> VectorDBBase:
|
|
"""Escape hatch for code that must call the sync client directly
|
|
(e.g. already inside a worker thread)."""
|
|
return self._sync
|
|
|
|
async def has_collection(self, collection_name: str) -> bool:
|
|
return await asyncio.to_thread(self._sync.has_collection, collection_name)
|
|
|
|
async def delete_collection(self, collection_name: str) -> None:
|
|
return await asyncio.to_thread(self._sync.delete_collection, collection_name)
|
|
|
|
async def insert(self, collection_name: str, items: List[VectorItem]) -> None:
|
|
return await asyncio.to_thread(self._sync.insert, collection_name, items)
|
|
|
|
async def upsert(self, collection_name: str, items: List[VectorItem]) -> None:
|
|
return await asyncio.to_thread(self._sync.upsert, collection_name, items)
|
|
|
|
async def search(
|
|
self,
|
|
collection_name: str,
|
|
vectors: List[List[Union[float, int]]],
|
|
filter: Optional[Dict] = None,
|
|
limit: int = 10,
|
|
) -> Optional[SearchResult]:
|
|
return await asyncio.to_thread(
|
|
self._sync.search, collection_name, vectors, filter, limit
|
|
)
|
|
|
|
async def query(
|
|
self,
|
|
collection_name: str,
|
|
filter: Dict,
|
|
limit: Optional[int] = None,
|
|
) -> Optional[GetResult]:
|
|
return await asyncio.to_thread(
|
|
self._sync.query, collection_name, filter, limit
|
|
)
|
|
|
|
async def get(self, collection_name: str) -> Optional[GetResult]:
|
|
return await asyncio.to_thread(self._sync.get, collection_name)
|
|
|
|
async def delete(
|
|
self,
|
|
collection_name: str,
|
|
ids: Optional[List[str]] = None,
|
|
filter: Optional[Dict] = None,
|
|
) -> None:
|
|
return await asyncio.to_thread(
|
|
self._sync.delete, collection_name, ids, filter
|
|
)
|
|
|
|
async def reset(self) -> None:
|
|
return await asyncio.to_thread(self._sync.reset)
|
|
|
|
|
|
ASYNC_VECTOR_DB_CLIENT = AsyncVectorDBClient(VECTOR_DB_CLIENT)
|