docs-qa-backend / app /store.py
capcut8128's picture
Upload 12 files
0a19322 verified
Raw History Blame Contribute Delete
5.07 kB
"""Shared singletons: embedding model, re-ranker and the vector DB (all thread-safe)."""
import logging
import threading
from functools import lru_cache
from pathlib import Path
from qdrant_client import QdrantClient
from qdrant_client.models import (
Distance,
FieldCondition,
Filter,
FilterSelector,
MatchAny,
PointStruct,
VectorParams,
)
from .config import settings
log = logging.getLogger("rag.store")
__all__ = ["PointStruct"]
_qlock = threading.RLock() # qdrant local mode is not thread-safe, so every call goes through this
_model_lock = threading.Lock()
_embedder = None
_reranker = None
_reranker_failed = False
# ---------------------------------------------------------------- embeddings
def set_embedder(obj) -> None:
"""Swap the embedder (used by tests with a fake one)."""
global _embedder
_embedder = obj
def get_embedder():
global _embedder
if _embedder is None:
with _model_lock:
if _embedder is None:
from sentence_transformers import SentenceTransformer # lazy: heavy import
log.info("Loading embedding model %s", settings.embed_model)
_embedder = SentenceTransformer(settings.embed_model)
return _embedder
def embed(texts: list[str]) -> list[list[float]]:
vecs = get_embedder().encode(texts, normalize_embeddings=True, batch_size=32, show_progress_bar=False)
return vecs.tolist()
def embedding_dim() -> int:
return int(get_embedder().get_sentence_embedding_dimension())
# ------------------------------------------------------------------ reranker
def get_reranker():
"""Cross-encoder used to re-rank candidates. Returns None if disabled or unavailable."""
global _reranker, _reranker_failed
if not settings.use_reranker or _reranker_failed:
return None
if _reranker is None:
with _model_lock:
if _reranker is None and not _reranker_failed:
try:
from sentence_transformers import CrossEncoder # lazy
log.info("Loading re-ranker %s", settings.rerank_model)
_reranker = CrossEncoder(settings.rerank_model, max_length=512)
except Exception as e: # no internet, blocked DLL, etc. -> degrade gracefully
log.warning("Re-ranker unavailable, continuing without it: %s", e)
_reranker_failed = True
return _reranker
# -------------------------------------------------------------------- qdrant
def _ensure_collection(client: QdrantClient) -> None:
existing = {c.name for c in client.get_collections().collections}
if settings.collection not in existing:
client.create_collection(
collection_name=settings.collection,
vectors_config=VectorParams(size=embedding_dim(), distance=Distance.COSINE),
)
@lru_cache
def get_qdrant() -> QdrantClient:
path = Path(settings.data_dir) / "qdrant"
path.mkdir(parents=True, exist_ok=True)
with _qlock:
client = QdrantClient(path=str(path))
_ensure_collection(client)
return client
def make_filter(doc_ids=None, pages=None) -> Filter | None:
must = []
if doc_ids:
must.append(FieldCondition(key="doc_id", match=MatchAny(any=list(doc_ids))))
if pages:
must.append(FieldCondition(key="page", match=MatchAny(any=[int(p) for p in pages])))
return Filter(must=must) if must else None
def _hit(p) -> dict:
return {"id": str(p.id), "score": float(getattr(p, "score", 0.0) or 0.0), **(p.payload or {})}
def upsert(points: list[PointStruct]) -> None:
with _qlock:
get_qdrant().upsert(collection_name=settings.collection, points=points)
def search(vector: list[float], flt: Filter | None, limit: int) -> list[dict]:
with _qlock:
res = get_qdrant().query_points(
collection_name=settings.collection,
query=vector,
query_filter=flt,
limit=limit,
with_payload=True,
)
return [_hit(p) for p in res.points]
def scroll_all(flt: Filter | None = None) -> list[dict]:
"""Every stored chunk (payload only) that matches the filter."""
out: list[dict] = []
offset = None
while True:
with _qlock:
points, offset = get_qdrant().scroll(
collection_name=settings.collection,
scroll_filter=flt,
limit=512,
offset=offset,
with_payload=True,
with_vectors=False,
)
out.extend(_hit(p) for p in points)
if offset is None:
break
return out
def delete_doc(doc_id: str) -> None:
with _qlock:
get_qdrant().delete(
collection_name=settings.collection,
points_selector=FilterSelector(filter=make_filter([doc_id])),
)
def clear_all() -> None:
with _qlock:
client = get_qdrant()
client.delete_collection(settings.collection)
_ensure_collection(client)