document_agent / backend /app /services /vectorstore.py
Jai-rathore29's picture
Deploy: DocAgent backend (deterministic date-anomaly fix)
f65e025
Raw
History Blame Contribute Delete
3.36 kB
"""Vector store for RAG — pure-Python, numpy cosine similarity.
Deliberately dependency-light: no native build (chroma-hnswlib needs a C++
compiler) and no external service. Each document's chunks + embeddings are
persisted to a single .npz/.json pair on disk and cached in memory.
Chunks carry page/bbox provenance so retrieval returns citations the UI can
highlight. To scale up, swap this module's body for pgvector or Chroma — the
public interface (index_chunks / query / delete / Chunk) stays the same.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import Optional
from app.core.config import settings
from app.core.logging import get_logger
log = get_logger(__name__)
try:
import numpy as np
except Exception: # pragma: no cover
np = None
@dataclass
class Chunk:
id: str
text: str
page: int
doc_id: str
bbox: Optional[dict] = None
# in-memory cache: doc_id -> (matrix[N,d], list[Chunk])
_cache: dict[str, tuple] = {}
def _store_dir() -> Path:
p = Path(settings.chroma_dir)
p.mkdir(parents=True, exist_ok=True)
return p
def _paths(doc_id: str) -> tuple[Path, Path]:
d = _store_dir()
return d / f"{doc_id}.npy", d / f"{doc_id}.json"
def _load(doc_id: str):
if doc_id in _cache:
return _cache[doc_id]
vec_path, meta_path = _paths(doc_id)
if not vec_path.exists() or not meta_path.exists():
return None
try:
mat = np.load(vec_path)
chunks = [Chunk(**c) for c in json.loads(meta_path.read_text("utf-8"))]
_cache[doc_id] = (mat, chunks)
return _cache[doc_id]
except Exception as e: # pragma: no cover
log.warning("vectorstore load failed for %s: %s", doc_id, e)
return None
def _normalize(mat):
norms = np.linalg.norm(mat, axis=1, keepdims=True)
norms[norms == 0] = 1.0
return mat / norms
async def index_chunks(doc_id: str, chunks: list[Chunk]) -> None:
if not chunks:
return
if np is None:
raise RuntimeError("numpy is not installed")
from app.llm.registry import get_embedder
embedder = get_embedder()
vectors = await embedder.embed([c.text for c in chunks])
mat = np.asarray(vectors, dtype="float32")
vec_path, meta_path = _paths(doc_id)
np.save(vec_path, mat)
meta_path.write_text(
json.dumps([asdict(c) for c in chunks], ensure_ascii=False), "utf-8"
)
_cache[doc_id] = (mat, chunks)
log.info("Indexed %d chunks for doc %s (dim=%d)", len(chunks), doc_id,
mat.shape[1] if mat.ndim == 2 else 0)
async def query(doc_id: str, question: str, k: int = 5) -> list[Chunk]:
loaded = _load(doc_id)
if not loaded:
return []
mat, chunks = loaded
if mat.size == 0 or not chunks:
return []
from app.llm.registry import get_embedder
embedder = get_embedder()
qvec = np.asarray((await embedder.embed([question]))[0], dtype="float32")
sims = _normalize(mat) @ (qvec / (np.linalg.norm(qvec) or 1.0))
top = np.argsort(-sims)[: min(k, len(chunks))]
return [chunks[int(i)] for i in top]
def delete(doc_id: str) -> None:
_cache.pop(doc_id, None)
for p in _paths(doc_id):
try:
p.unlink(missing_ok=True)
except Exception:
pass