| """ |
| ByteAstra — ChromaDB RAG service. |
| |
| Handles: |
| - Initialising the ChromaDB client and per-domain collections |
| - Embedding queries using sentence-transformers |
| - Retrieving the top-K most relevant chunks for a query |
| - Indexing new documents (used by ingest.py) |
| """ |
| from __future__ import annotations |
| import logging |
| from dataclasses import dataclass |
| from functools import lru_cache |
|
|
| import chromadb |
| from chromadb import Settings as ChromaSettings |
| from chromadb.api.types import Documents, EmbeddingFunction, Embeddings |
| import torch |
| from transformers import AutoTokenizer, AutoModel |
| from app.config import get_settings |
| from app.schemas import Citation |
|
|
| logger = logging.getLogger(__name__) |
| settings = get_settings() |
|
|
|
|
| class TransformersEmbeddingFunction(EmbeddingFunction[Documents]): |
| def __init__(self, model_name: str = "sentence-transformers/all-MiniLM-L6-v2", device: str = "cpu"): |
| self.device = device |
| if "/" not in model_name: |
| model_name = f"sentence-transformers/{model_name}" |
| |
| |
| torch.set_num_threads(1) |
| |
| try: |
| import os |
| os.environ["HF_HUB_OFFLINE"] = "1" |
| os.environ["TRANSFORMERS_OFFLINE"] = "1" |
| self.tokenizer = AutoTokenizer.from_pretrained(model_name, local_files_only=True) |
| self.model = AutoModel.from_pretrained(model_name, local_files_only=True).to(self.device) |
| logger.info("Loaded embedding model %s from local files.", model_name) |
| except Exception: |
| logger.info("Local files not found for %s. Enabling online download...", model_name) |
| import os |
| os.environ["HF_HUB_OFFLINE"] = "0" |
| os.environ["TRANSFORMERS_OFFLINE"] = "0" |
| self.tokenizer = AutoTokenizer.from_pretrained(model_name, local_files_only=False) |
| self.model = AutoModel.from_pretrained(model_name, local_files_only=False).to(self.device) |
| logger.info("Successfully downloaded and loaded embedding model %s.", model_name) |
|
|
| def __call__(self, input: Documents) -> Embeddings: |
| encoded_input = self.tokenizer(input, padding=True, truncation=True, return_tensors='pt').to(self.device) |
| with torch.no_grad(): |
| model_output = self.model(**encoded_input) |
| |
| token_embeddings = model_output[0] |
| attention_mask = encoded_input['attention_mask'] |
| input_mask_expanded = attention_mask.unsqueeze(-1).expand(token_embeddings.size()).float() |
| sum_embeddings = torch.sum(token_embeddings * input_mask_expanded, 1) |
| sum_mask = torch.clamp(input_mask_expanded.sum(1), min=1e-9) |
| embeddings = sum_embeddings / sum_mask |
| |
| import torch.nn.functional as F |
| embeddings = F.normalize(embeddings, p=2, dim=1) |
| |
| return embeddings.cpu().numpy().tolist() |
|
|
|
|
| @dataclass |
| class RetrievedChunk: |
| chunk_id: str |
| content: str |
| source: str |
| chapter: str | None |
| section: str | None |
| relevance_score: float |
|
|
| def to_citation(self) -> Citation: |
| return Citation( |
| chunk_id=self.chunk_id, |
| source=self.source, |
| chapter=self.chapter, |
| section=self.section, |
| relevance_score=self.relevance_score, |
| content=self.content, |
| ) |
|
|
|
|
| @lru_cache(maxsize=1) |
| def _get_embedding_fn() -> TransformersEmbeddingFunction: |
| logger.info("Loading embedding model: %s", settings.embedding_model) |
| return TransformersEmbeddingFunction(model_name=settings.embedding_model, device="cpu") |
|
|
|
|
| @lru_cache(maxsize=1) |
| def _get_chroma_client() -> chromadb.ClientAPI: |
| logger.info("Initialising ChromaDB at: %s", settings.chroma_persist_path) |
| return chromadb.PersistentClient( |
| path=settings.chroma_persist_path, |
| settings=ChromaSettings(anonymized_telemetry=False), |
| ) |
|
|
|
|
| def get_or_create_collection(collection_name: str) -> chromadb.Collection: |
| """Get or create a ChromaDB collection for a specific domain.""" |
| client = _get_chroma_client() |
| return client.get_or_create_collection( |
| name=collection_name, |
| metadata={"hnsw:space": "cosine"}, |
| ) |
|
|
|
|
| def retrieve( |
| query: str, |
| collection_name: str, |
| top_k: int = 5, |
| relevance_threshold: float = 0.45, |
| ) -> list[RetrievedChunk]: |
| """ |
| Retrieve the most relevant chunks from the domain's collection. |
| Only returns chunks with similarity score >= relevance_threshold. |
| |
| Returns an empty list if nothing meets the threshold — the engine |
| will treat this as an out-of-syllabus query. |
| """ |
| import re |
| collection = get_or_create_collection(collection_name) |
| |
| |
| embedding_fn = _get_embedding_fn() |
| query_embeddings = embedding_fn([query]) |
|
|
| |
| candidate_k = max(top_k * 2, 6) |
| results = collection.query( |
| query_embeddings=query_embeddings, |
| n_results=candidate_k, |
| include=["documents", "metadatas", "distances"], |
| ) |
|
|
| candidates: list[tuple[RetrievedChunk, float]] = [] |
| if not results["ids"] or not results["ids"][0]: |
| return [] |
|
|
| |
| q_words = re.findall(r"\b[a-zA-Z]{3,}\b", query.lower()) |
| stop_words = {"how", "does", "that", "relate", "to", "what", "is", "are", "the", "and", "in", "of", "a", "an", "with", "about", "for", "its", "why", "who"} |
| sig_words = [w for w in q_words if w not in stop_words] |
|
|
| for i, chunk_id in enumerate(results["ids"][0]): |
| |
| distance = results["distances"][0][i] |
| similarity = 1.0 - distance |
| if similarity < relevance_threshold: |
| logger.debug("Chunk %s below threshold (%.3f < %.3f) — skipped", chunk_id, similarity, relevance_threshold) |
| continue |
|
|
| content = results["documents"][0][i] |
| meta = results["metadatas"][0][i] or {} |
| |
| |
| boost = 0.0 |
| if sig_words: |
| content_lower = content.lower() |
| for w in sig_words: |
| if re.search(r"\b" + re.escape(w) + r"\b", content_lower): |
| boost += 0.03 |
| |
| boosted_score = similarity + boost |
| |
| chunk = RetrievedChunk( |
| chunk_id=chunk_id, |
| content=content, |
| source=meta.get("source", "Unknown Source"), |
| chapter=meta.get("chapter"), |
| section=meta.get("section"), |
| relevance_score=round(similarity, 4), |
| ) |
| candidates.append((chunk, boosted_score)) |
|
|
| |
| candidates.sort(key=lambda x: x[1], reverse=True) |
| selected_chunks = [x[0] for x in candidates[:top_k]] |
|
|
| logger.info("Retrieved %d relevant chunks for query (candidates=%d, threshold=%.2f)", len(selected_chunks), len(candidates), relevance_threshold) |
| return selected_chunks |
|
|
|
|
| def index_chunks( |
| collection_name: str, |
| chunks: list[dict], |
| ) -> None: |
| """ |
| Index a batch of text chunks into the domain's ChromaDB collection. |
| |
| Each chunk dict must have: |
| - id: str (unique, e.g. "ayurveda_charaka_ch1_000") |
| - text: str |
| - source: str |
| - chapter: str | None |
| - section: str | None |
| """ |
| collection = get_or_create_collection(collection_name) |
|
|
| ids = [c["id"] for c in chunks] |
| documents = [c["text"] for c in chunks] |
| metadatas = [ |
| { |
| "source": c.get("source", ""), |
| "chapter": c.get("chapter") or "", |
| "section": c.get("section") or "", |
| } |
| for c in chunks |
| ] |
|
|
| |
| embedding_fn = _get_embedding_fn() |
| embeddings = embedding_fn(documents) |
|
|
| collection.upsert(ids=ids, embeddings=embeddings, documents=documents, metadatas=metadatas) |
| logger.info("Indexed %d chunks into collection '%s'", len(chunks), collection_name) |
|
|