Coverage for open_webui/retrieval/vector/async_client.py: 83%
35 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 05:07 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 05:07 +0000
1"""
2Async facade over the synchronous VECTOR_DB_CLIENT.
4The vector DB backends bundled with Open WebUI (Chroma, pgvector, Qdrant,
5Milvus, OpenSearch, Pinecone, Weaviate, …) all expose a uniformly
6synchronous API. Each method performs blocking network or disk I/O — and
7some, like `insert`/`upsert`, can run for several seconds.
9When such a sync method is awaited from an async route handler, it blocks
10the event loop for its entire duration, freezing every other in-flight
11HTTP request, websocket message and background task.
13This module wraps the sync client in an `AsyncVectorDBClient` that
14transparently dispatches each call to a worker thread via
15`asyncio.to_thread`. Async callers can `await ASYNC_VECTOR_DB_CLIENT.x(...)`
16in place of `VECTOR_DB_CLIENT.x(...)` and the loop stays responsive.
18Client initialization and calls run in the worker thread. Synchronous callers
19already inside `run_in_threadpool` use `get_vector_db_client()` directly.
21Thread-safety expectations
22--------------------------
23Every async caller now invokes `VECTOR_DB_CLIENT` from a worker thread
24rather than the event-loop thread, and many can run concurrently. The
25sync client (and its underlying backend driver) is therefore expected
26to be safe for concurrent use across threads, which is the standard
27contract for the bundled drivers (chroma, pgvector via SQLAlchemy
28pool, qdrant-client, opensearch-py, …). This is *not* a new exposure
29introduced by this facade — `save_docs_to_vector_db` already called
30the sync client from `run_in_threadpool`, so concurrent threaded
31access has always been a requirement of the codebase. Adding a global
32serialization lock here would defeat the responsiveness this facade
33exists to provide; any backend that genuinely cannot tolerate
34concurrent access should grow its own internal serialization.
36API surface
37-----------
38Method signatures mirror `VectorDBBase` exactly. This is deliberate:
39permissive `*args/**kwargs` forwarding hides typos at the call site
40(an earlier revision of this file shipped that, and a `metadata=`
41typo silently broke an entire endpoint until explicit signatures
42surfaced it). Callers that need a backend-specific parameter not on
43`VectorDBBase` should reach for the `.sync` escape hatch and wrap
44their own `asyncio.to_thread`, e.g. ::
46 await asyncio.to_thread(
47 ASYNC_VECTOR_DB_CLIENT.sync.some_backend_specific_op,
48 collection_name, special_kwarg=value,
49 )
50"""
52from __future__ import annotations
54import asyncio
55from typing import Dict, List, Optional, Union
57from open_webui.retrieval.vector.factory import get_vector_db_client
58from open_webui.retrieval.vector.main import (
59 GetResult,
60 SearchResult,
61 VectorDBBase,
62 VectorItem,
63)
66class AsyncVectorDBClient:
67 """Awaitable mirror of `VectorDBBase` that off-loads each call to a thread.
69 Method signatures mirror `VectorDBBase` exactly so static analysis
70 catches bad kwargs at the call site instead of letting them surface
71 deep inside the worker thread (where the resulting ``TypeError`` is
72 typically swallowed by surrounding ``try/except``).
73 """
75 def __init__(self, sync_client: Optional[VectorDBBase] = None) -> None:
76 self._sync = sync_client
78 @property
79 def sync(self) -> VectorDBBase:
80 """Escape hatch for code that must call the sync client directly
81 (e.g. already inside a worker thread)."""
82 return self._sync if self._sync is not None else get_vector_db_client()
84 @property
85 def supports_hybrid_search(self) -> bool:
86 return type(self.sync).hybrid_search is not VectorDBBase.hybrid_search
88 async def has_collection(self, collection_name: str) -> bool:
89 return await asyncio.to_thread(lambda: self.sync.has_collection(collection_name))
91 async def delete_collection(self, collection_name: str) -> None:
92 return await asyncio.to_thread(lambda: self.sync.delete_collection(collection_name))
94 async def insert(self, collection_name: str, items: List[VectorItem]) -> None:
95 return await asyncio.to_thread(lambda: self.sync.insert(collection_name, items))
97 async def upsert(self, collection_name: str, items: List[VectorItem]) -> None:
98 return await asyncio.to_thread(lambda: self.sync.upsert(collection_name, items))
100 async def search(
101 self,
102 collection_name: str,
103 vectors: List[List[Union[float, int]]],
104 filter: Optional[Dict] = None,
105 limit: int = 10,
106 ) -> Optional[SearchResult]:
107 return await asyncio.to_thread(lambda: self.sync.search(collection_name, vectors, filter, limit))
109 async def hybrid_search(
110 self,
111 collection_name: str,
112 query: str,
113 vectors: List[List[Union[float, int]]],
114 filter: Optional[Dict] = None,
115 limit: int = 10,
116 hybrid_bm25_weight: float = 0.5,
117 ) -> Optional[SearchResult]:
118 return await asyncio.to_thread(
119 lambda: self.sync.hybrid_search(collection_name, query, vectors, filter, limit, hybrid_bm25_weight)
120 )
122 async def query(
123 self,
124 collection_name: str,
125 filter: Dict,
126 limit: Optional[int] = None,
127 ) -> Optional[GetResult]:
128 return await asyncio.to_thread(lambda: self.sync.query(collection_name, filter, limit))
130 async def get(self, collection_name: str) -> Optional[GetResult]:
131 return await asyncio.to_thread(lambda: self.sync.get(collection_name))
133 async def delete(
134 self,
135 collection_name: str,
136 ids: Optional[List[str]] = None,
137 filter: Optional[Dict] = None,
138 ) -> None:
139 return await asyncio.to_thread(lambda: self.sync.delete(collection_name, ids, filter))
141 async def reset(self) -> None:
142 return await asyncio.to_thread(lambda: self.sync.reset())
145ASYNC_VECTOR_DB_CLIENT = AsyncVectorDBClient()