Spaces:
Sleeping
Sleeping
feat: redesign multi-hop reasoning, add sticky layout, query logs history tracking, and hybrid fallback search
0527a95 | import os | |
| import uuid | |
| import logging | |
| from typing import List | |
| from django.conf import settings | |
| import chromadb | |
| from chromadb.utils.embedding_functions import ONNXMiniLM_L6_V2 | |
| logger = logging.getLogger(__name__) | |
| class VectorRetriever: | |
| def __init__(self): | |
| logger.info("Initializing VectorRetriever service.") | |
| # 1. Initialize Persistent ChromaDB Client | |
| self.persist_directory = getattr(settings, 'CHROMADB_DIR', os.path.join(settings.BASE_DIR, "chroma_db")) | |
| os.makedirs(self.persist_directory, exist_ok=True) | |
| self.chroma_client = chromadb.PersistentClient(path=self.persist_directory) | |
| # 2. Load the Embedding Model via ChromaDB's built-in ONNX path | |
| # Uses ONNX runtime only — no torch/CUDA needed, no sentence-transformers package | |
| self.embedding_fn = ONNXMiniLM_L6_V2(preferred_providers=["CPUExecutionProvider"]) | |
| # 3. Setup text splitter for document chunking | |
| def chunk_text(text: str, chunk_size: int = 800, chunk_overlap: int = 100) -> List[str]: | |
| words = text.split() | |
| chunks = [] | |
| start = 0 | |
| while start < len(words): | |
| end = min(start + chunk_size, len(words)) | |
| chunks.append(' '.join(words[start:end])) | |
| if end == len(words): | |
| break | |
| start = end - chunk_overlap | |
| return chunks | |
| self.text_splitter = chunk_text | |
| def _get_user_collection(self, user_id): | |
| """ | |
| Enforce multi-tenancy by returning a collection isolated for each user. | |
| """ | |
| collection_name = f"user_collection_{str(user_id).replace('-', '_')}" | |
| return self.chroma_client.get_or_create_collection( | |
| name=collection_name, | |
| metadata={"hnsw:space": "cosine"} # Use cosine similarity | |
| ) | |
| def index_document(self, text_content: str, doc_name: str, user_id: str): | |
| """ | |
| Splits document text into chunks, generates embeddings, and saves them to ChromaDB. | |
| """ | |
| if not text_content or not text_content.strip(): | |
| logger.warning("Empty text content provided for vector indexing.") | |
| return | |
| logger.info("Starting vector indexing for document '%s' (User: %s)", doc_name, user_id) | |
| try: | |
| # Split text into chunks | |
| chunks = self.text_splitter(text_content) | |
| logger.info("Split document into %d vector chunks.", len(chunks)) | |
| collection = self._get_user_collection(user_id) | |
| # Prepare inputs for ChromaDB | |
| doc_id = str(uuid.uuid4())[:8] | |
| ids = [f"{doc_id}_{doc_name}_chunk_{i}" for i in range(len(chunks))] | |
| # Generate vector representations using ONNX-based embedding | |
| embeddings = self.embedding_fn(chunks) | |
| metadatas = [{"source_doc": doc_name, "page": i + 1, "chunk_index": i} for i in range(len(chunks))] | |
| # Insert or update in ChromaDB | |
| collection.upsert( | |
| ids=ids, | |
| embeddings=embeddings, | |
| documents=chunks, | |
| metadatas=metadatas | |
| ) | |
| logger.info("Successfully indexed %d chunks in ChromaDB for document: %s", len(chunks), doc_name) | |
| except Exception as e: | |
| logger.error("Failed to index document in ChromaDB. Error: %s", str(e), exc_info=True) | |
| raise e | |
| def retrieve_relevant_chunks(self, query: str, user_id: str, limit: int = 5, doc_names: List[str] = None) -> List[dict]: | |
| """ | |
| Queries ChromaDB to retrieve the most semantically relevant text passages. | |
| """ | |
| logger.info("Searching ChromaDB for query: '%s' (Limit: %d, User: %s, Docs: %s)", query, limit, user_id, doc_names) | |
| try: | |
| collection = self._get_user_collection(user_id) | |
| query_vector = self.embedding_fn([query])[0] | |
| query_params = { | |
| "query_embeddings": [query_vector], | |
| "n_results": limit | |
| } | |
| if doc_names: | |
| query_params["where"] = {"source_doc": {"$in": [str(d) for d in doc_names]}} | |
| results = collection.query(**query_params) | |
| retrieved = [] | |
| if results and results["documents"]: | |
| documents = results["documents"][0] | |
| metadatas = results["metadatas"][0] | |
| distances = results["distances"][0] if "distances" in results else [0.0] * len(documents) | |
| for doc, meta, dist in zip(documents, metadatas, distances): | |
| # Cosine distance (0.0 is exact match, 1.0 is opposite) | |
| # Convert distance to a similarity score (1.0 - distance) | |
| similarity = round(1.0 - dist, 4) | |
| retrieved.append({ | |
| "text": doc, | |
| "source_doc": meta.get("source_doc", "unknown"), | |
| "page": meta.get("page", 1), | |
| "similarity_score": similarity | |
| }) | |
| logger.info("Retrieved %d relevant text chunks from ChromaDB.", len(retrieved)) | |
| return retrieved | |
| except Exception as e: | |
| logger.error("Error retrieving from ChromaDB: %s", str(e), exc_info=True) | |
| return [] | |
| def get_chunk_by_page(self, doc_name: str, page: int, user_id: str) -> str: | |
| """ | |
| Retrieves the verbatim text content of a specific page/chunk from ChromaDB. | |
| """ | |
| try: | |
| collection = self._get_user_collection(user_id) | |
| results = collection.get( | |
| where={ | |
| "$and": [ | |
| {"source_doc": str(doc_name)}, | |
| {"page": int(page)} | |
| ] | |
| }, | |
| limit=1 | |
| ) | |
| if results and results["documents"]: | |
| return results["documents"][0] | |
| except Exception as e: | |
| logger.error("Error fetching chunk from ChromaDB for %s page %d: %s", doc_name, page, str(e)) | |
| return "" | |
| def delete_document_vectors(self, doc_name: str, user_id: str): | |
| """ | |
| Removes all vectors belonging to a deleted document. | |
| """ | |
| logger.info("Deleting vectors for document '%s' from ChromaDB (User: %s)", doc_name, user_id) | |
| try: | |
| collection = self._get_user_collection(user_id) | |
| collection.delete(where={"source_doc": doc_name}) | |
| logger.info("Successfully deleted all vectors for document '%s' from ChromaDB.", doc_name) | |
| except Exception as e: | |
| logger.error("Failed to delete document vectors from ChromaDB: %s", str(e), exc_info=True) | |