mirror of
https://github.com/hanzoai/chat-rag-api.git
synced 2026-10-30 18:45:41 +00:00
Extracted from the deployed chat-rag-api:0.7.8-hanzo image; adds a Qdrant vector store targeting hanzoai/vector as a drop-in for pgvector. - app/services/vector_store/qdrant_vector.py: QdrantVector(QdrantVectorStore) with the id helpers (get_all_ids/get_filtered_ids/get_documents_by_ids), delete-by-file_id, and the route's Mongo-style file_id filter translated to native Qdrant filters. file_id stored in payload metadata (unique point ids). - factory.get_vector_store: mode='qdrant' (lazy import; pg/atlas deploys unaffected). - config: VectorDBType.QDRANT + QDRANT_URL/QDRANT_API_KEY/QDRANT_DISTANCE/ EMBEDDINGS_DIM(1536) + construction branch. - health: qdrant_health_check (client.get_collections()). - requirements: langchain-qdrant==0.2.0, qdrant-client==1.12.1. Verified: pytest tests/test_qdrant_vector.py (4 tests) against in-memory Qdrant; factory builds QdrantVector with all route methods present.
256 lines
9.4 KiB
Python
256 lines
9.4 KiB
Python
"""Qdrant vector store backend for the Hanzo RAG API.
|
|
|
|
Targets `hanzoai/vector` (Qdrant) — the native Hanzo vector database — as a
|
|
drop-in replacement for the pgvector backend. It satisfies exactly the
|
|
vector_store contract that `app/routes/document_routes.py` drives:
|
|
|
|
add_documents(docs, ids=[file_id]*n) / aadd_documents(...)
|
|
similarity_search_with_score_by_vector(emb, k, filter={"file_id": ...})
|
|
and the async variant (run via run_in_executor)
|
|
get_all_ids() -> list[str] (distinct file_ids)
|
|
get_filtered_ids(ids) -> list[str] (subset that exist)
|
|
get_documents_by_ids(ids) -> list[Document]
|
|
delete(ids, collection_only=False)
|
|
|
|
LibreChat's RAG API addresses everything by `file_id`: every chunk of an
|
|
uploaded file is inserted with `ids=[file_id]*n` and later queried/deleted by
|
|
that id. pgvector stored `file_id` as the non-unique `custom_id` column. Qdrant
|
|
point ids must be unique (UUID/uint), so we let langchain_qdrant assign unique
|
|
point ids and instead persist `file_id` inside each point's payload metadata —
|
|
then filter/scroll on `metadata.file_id` for get_*/delete. The route-level
|
|
Mongo-style filter (`{"file_id": x}` / `{"file_id": {"$in": [...]}}`) is
|
|
translated to a native Qdrant filter here.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any, Optional
|
|
|
|
from langchain_core.documents import Document
|
|
from langchain_core.embeddings import Embeddings
|
|
from langchain_core.runnables.config import run_in_executor
|
|
from langchain_qdrant import QdrantVectorStore
|
|
from qdrant_client import QdrantClient, models
|
|
from qdrant_client.http.exceptions import UnexpectedResponse
|
|
|
|
# langchain_qdrant stores the original LangChain Document metadata under this
|
|
# payload key and the page content under the content key. We read/scroll the
|
|
# nested `metadata.file_id` field accordingly.
|
|
METADATA_KEY = "metadata"
|
|
CONTENT_KEY = "page_content"
|
|
FILE_ID_PATH = f"{METADATA_KEY}.file_id"
|
|
|
|
|
|
def _ensure_collection(
|
|
client: QdrantClient, collection_name: str, dim: int, distance: models.Distance
|
|
) -> None:
|
|
"""Create the collection if it doesn't exist and index the file_id payload
|
|
field so filter/scroll on metadata.file_id is fast and reliable."""
|
|
try:
|
|
client.get_collection(collection_name)
|
|
except (UnexpectedResponse, ValueError):
|
|
client.create_collection(
|
|
collection_name=collection_name,
|
|
vectors_config=models.VectorParams(size=dim, distance=distance),
|
|
)
|
|
# Payload index is idempotent; ignore "already exists".
|
|
try:
|
|
client.create_payload_index(
|
|
collection_name=collection_name,
|
|
field_name=FILE_ID_PATH,
|
|
field_schema=models.PayloadSchemaType.KEYWORD,
|
|
)
|
|
except Exception: # noqa: BLE001 - index may already exist
|
|
pass
|
|
|
|
|
|
def _file_id_filter(value: Any) -> models.Filter:
|
|
"""Translate the route-level Mongo-style file_id filter into a native
|
|
Qdrant filter. Accepts {"file_id": "x"}, {"file_id": {"$in": [...]}},
|
|
{"file_id": {"$eq": "x"}}, or a bare string/list."""
|
|
# Unwrap {"file_id": ...}
|
|
if isinstance(value, dict) and "file_id" in value:
|
|
value = value["file_id"]
|
|
if isinstance(value, dict):
|
|
if "$in" in value:
|
|
return models.Filter(
|
|
must=[
|
|
models.FieldCondition(
|
|
key=FILE_ID_PATH,
|
|
match=models.MatchAny(any=list(value["$in"])),
|
|
)
|
|
]
|
|
)
|
|
if "$eq" in value:
|
|
value = value["$eq"]
|
|
if isinstance(value, (list, tuple)):
|
|
return models.Filter(
|
|
must=[
|
|
models.FieldCondition(
|
|
key=FILE_ID_PATH, match=models.MatchAny(any=list(value))
|
|
)
|
|
]
|
|
)
|
|
return models.Filter(
|
|
must=[
|
|
models.FieldCondition(
|
|
key=FILE_ID_PATH, match=models.MatchValue(value=value)
|
|
)
|
|
]
|
|
)
|
|
|
|
|
|
class QdrantVector(QdrantVectorStore):
|
|
"""QdrantVectorStore extended with the id helpers + Mongo-filter shim that
|
|
the RAG API routes expect. Construct via :meth:`from_config`."""
|
|
|
|
@classmethod
|
|
def from_config(
|
|
cls,
|
|
*,
|
|
url: str,
|
|
api_key: Optional[str],
|
|
collection_name: str,
|
|
embeddings: Embeddings,
|
|
dim: int,
|
|
distance: str = "cosine",
|
|
prefer_grpc: bool = False,
|
|
) -> "QdrantVector":
|
|
client = QdrantClient(url=url, api_key=api_key, prefer_grpc=prefer_grpc)
|
|
dist = {
|
|
"cosine": models.Distance.COSINE,
|
|
"euclid": models.Distance.EUCLID,
|
|
"dot": models.Distance.DOT,
|
|
}.get(distance.lower(), models.Distance.COSINE)
|
|
_ensure_collection(client, collection_name, dim, dist)
|
|
return cls(
|
|
client=client,
|
|
collection_name=collection_name,
|
|
embedding=embeddings,
|
|
content_payload_key=CONTENT_KEY,
|
|
metadata_payload_key=METADATA_KEY,
|
|
)
|
|
|
|
# ---- id-based helpers (file_id lives in metadata.file_id) --------------
|
|
|
|
def add_documents(self, documents: list[Document], ids=None, **kwargs) -> list[str]:
|
|
"""LibreChat passes ids=[file_id]*n. Stamp file_id into each document's
|
|
metadata so it can be filtered/deleted by file_id, and let Qdrant assign
|
|
unique point ids. Returns the file_ids (one per chunk) to preserve the
|
|
route's expectation that len(returned) == len(documents)."""
|
|
file_ids = self._stamp_file_ids(documents, ids)
|
|
super().add_documents(documents, **kwargs)
|
|
return file_ids
|
|
|
|
async def aadd_documents(
|
|
self, documents: list[Document], ids=None, **kwargs
|
|
) -> list[str]:
|
|
file_ids = self._stamp_file_ids(documents, ids)
|
|
await run_in_executor(None, lambda: super(QdrantVector, self).add_documents(documents, **kwargs))
|
|
return file_ids
|
|
|
|
@staticmethod
|
|
def _stamp_file_ids(documents: list[Document], ids) -> list[str]:
|
|
file_ids: list[str] = []
|
|
for i, doc in enumerate(documents):
|
|
fid = None
|
|
if ids and i < len(ids):
|
|
fid = ids[i]
|
|
if fid is None:
|
|
fid = doc.metadata.get("file_id")
|
|
if fid is not None:
|
|
doc.metadata["file_id"] = fid
|
|
file_ids.append(fid)
|
|
return file_ids
|
|
|
|
def get_all_ids(self) -> list[str]:
|
|
return sorted(self._scroll_file_ids(None))
|
|
|
|
def get_filtered_ids(self, ids: list[str]) -> list[str]:
|
|
if not ids:
|
|
return []
|
|
present = self._scroll_file_ids(_file_id_filter(list(ids)))
|
|
wanted = set(ids)
|
|
return [i for i in sorted(present) if i in wanted]
|
|
|
|
def get_documents_by_ids(self, ids: list[str]) -> list[Document]:
|
|
if not ids:
|
|
return []
|
|
docs: list[Document] = []
|
|
points = self._scroll_points(_file_id_filter(list(ids)), with_vectors=False)
|
|
for p in points:
|
|
payload = p.payload or {}
|
|
meta = payload.get(METADATA_KEY, {}) or {}
|
|
docs.append(
|
|
Document(page_content=payload.get(CONTENT_KEY, ""), metadata=meta)
|
|
)
|
|
return docs
|
|
|
|
def delete(
|
|
self, ids: Optional[list[str]] = None, collection_only: bool = False, **kwargs
|
|
) -> None:
|
|
if not ids:
|
|
return
|
|
self.client.delete(
|
|
collection_name=self.collection_name,
|
|
points_selector=models.FilterSelector(filter=_file_id_filter(list(ids))),
|
|
wait=True,
|
|
)
|
|
|
|
# ---- similarity search with the route's Mongo-style filter ------------
|
|
|
|
def similarity_search_with_score_by_vector(
|
|
self, embedding: list[float], k: int = 4, filter: Any = None, **kwargs
|
|
):
|
|
qfilter = _file_id_filter(filter) if filter else None
|
|
results = self.client.search(
|
|
collection_name=self.collection_name,
|
|
query_vector=embedding,
|
|
query_filter=qfilter,
|
|
limit=k,
|
|
with_payload=True,
|
|
)
|
|
out = []
|
|
for r in results:
|
|
payload = r.payload or {}
|
|
meta = payload.get(METADATA_KEY, {}) or {}
|
|
out.append(
|
|
(Document(page_content=payload.get(CONTENT_KEY, ""), metadata=meta), r.score)
|
|
)
|
|
return out
|
|
|
|
async def asimilarity_search_with_score_by_vector(
|
|
self, embedding: list[float], k: int = 4, filter: Any = None, **kwargs
|
|
):
|
|
return await run_in_executor(
|
|
None, self.similarity_search_with_score_by_vector, embedding, k, filter
|
|
)
|
|
|
|
# ---- internal scroll helpers -----------------------------------------
|
|
|
|
def _scroll_points(self, qfilter, with_vectors: bool = False) -> list[Any]:
|
|
points: list[Any] = []
|
|
offset = None
|
|
while True:
|
|
batch, offset = self.client.scroll(
|
|
collection_name=self.collection_name,
|
|
scroll_filter=qfilter,
|
|
with_payload=True,
|
|
with_vectors=with_vectors,
|
|
limit=256,
|
|
offset=offset,
|
|
)
|
|
points.extend(batch)
|
|
if offset is None:
|
|
break
|
|
return points
|
|
|
|
def _scroll_file_ids(self, qfilter) -> set[str]:
|
|
ids: set[str] = set()
|
|
for p in self._scroll_points(qfilter, with_vectors=False):
|
|
meta = (p.payload or {}).get(METADATA_KEY, {}) or {}
|
|
fid = meta.get("file_id")
|
|
if fid is not None:
|
|
ids.add(fid)
|
|
return ids
|