Spaces:
Sleeping
Sleeping
| """Fast vectorless document retrieval for the portfolio assistant. | |
| This module keeps the hosted chatbot responsive by avoiding embedding model | |
| downloads and Chroma startup work on the default path. It builds a small | |
| in-memory BM25-style index from the local data folder. | |
| """ | |
| from __future__ import annotations | |
| import math | |
| import re | |
| from collections import Counter | |
| from typing import Dict, List, Tuple | |
| from config import CHUNK_OVERLAP, CHUNK_SIZE, DATA_DIR | |
| from document_loader import load_documents | |
| from utils.text_cleaner import clean_text | |
| _records_cache: List[Dict] | None = None | |
| _index_cache: Dict | None = None | |
| TOKEN_RE = re.compile(r"[a-zA-Z0-9+#.]+") | |
| STOP_WORDS = { | |
| "a", | |
| "about", | |
| "an", | |
| "and", | |
| "are", | |
| "as", | |
| "can", | |
| "does", | |
| "for", | |
| "from", | |
| "has", | |
| "have", | |
| "his", | |
| "is", | |
| "me", | |
| "of", | |
| "on", | |
| "or", | |
| "raj", | |
| "tell", | |
| "the", | |
| "to", | |
| "what", | |
| "who", | |
| "with", | |
| } | |
| def _log(message: str) -> None: | |
| print(f"[vectorless_store] {message}") | |
| def _tokenize(text: str) -> List[str]: | |
| tokens = [token.lower() for token in TOKEN_RE.findall(text)] | |
| return [token for token in tokens if len(token) > 1 and token not in STOP_WORDS] | |
| def _chunk_text(text: str) -> List[str]: | |
| text = clean_text(text) | |
| if not text: | |
| return [] | |
| chunks = [] | |
| start = 0 | |
| while start < len(text): | |
| end = min(start + CHUNK_SIZE, len(text)) | |
| if end < len(text): | |
| boundary = max(text.rfind("\n\n", start, end), text.rfind(". ", start, end)) | |
| if boundary > start + int(CHUNK_SIZE * 0.55): | |
| end = boundary + 1 | |
| chunk = text[start:end].strip() | |
| if chunk: | |
| chunks.append(chunk) | |
| if end >= len(text): | |
| break | |
| start = max(end - CHUNK_OVERLAP, start + 1) | |
| return chunks | |
| def collection_records() -> List[Dict]: | |
| global _records_cache | |
| if _records_cache is not None: | |
| return _records_cache | |
| records: List[Dict] = [] | |
| for document in load_documents(DATA_DIR): | |
| for chunk_index, chunk in enumerate(_chunk_text(document.text)): | |
| metadata = dict(document.metadata) | |
| metadata["chunk_index"] = chunk_index | |
| records.append({"text": chunk, "metadata": metadata}) | |
| _records_cache = records | |
| _log(f"Indexed {len(records)} vectorless chunks.") | |
| return records | |
| def _build_index() -> Dict: | |
| global _index_cache | |
| if _index_cache is not None: | |
| return _index_cache | |
| records = collection_records() | |
| doc_terms = [] | |
| doc_freq = Counter() | |
| total_length = 0 | |
| for record in records: | |
| terms = Counter(_tokenize(record["text"])) | |
| doc_terms.append(terms) | |
| total_length += sum(terms.values()) | |
| doc_freq.update(terms.keys()) | |
| doc_count = max(len(records), 1) | |
| avg_length = total_length / doc_count if total_length else 1 | |
| idf = { | |
| term: math.log(1 + (doc_count - freq + 0.5) / (freq + 0.5)) | |
| for term, freq in doc_freq.items() | |
| } | |
| _index_cache = { | |
| "records": records, | |
| "doc_terms": doc_terms, | |
| "idf": idf, | |
| "avg_length": avg_length, | |
| } | |
| return _index_cache | |
| def retrieve(question: str, top_k: int) -> Tuple[str, List[Dict]]: | |
| index = _build_index() | |
| records = index["records"] | |
| if not records: | |
| return "", [] | |
| query_terms = _tokenize(question) | |
| if not query_terms: | |
| return "", [] | |
| scores = [] | |
| k1 = 1.4 | |
| b = 0.72 | |
| avg_length = index["avg_length"] | |
| idf = index["idf"] | |
| for record_index, terms in enumerate(index["doc_terms"]): | |
| doc_length = sum(terms.values()) or 1 | |
| score = 0.0 | |
| for term in query_terms: | |
| freq = terms.get(term, 0) | |
| if not freq: | |
| continue | |
| numerator = freq * (k1 + 1) | |
| denominator = freq + k1 * (1 - b + b * doc_length / avg_length) | |
| score += idf.get(term, 0.0) * numerator / denominator | |
| if score: | |
| scores.append((score, record_index)) | |
| scores.sort(reverse=True) | |
| retrieved = [] | |
| context_parts = [] | |
| for score, record_index in scores[:top_k]: | |
| record = records[record_index] | |
| metadata = record["metadata"] | |
| source = metadata.get("file_name", "unknown source") | |
| page = metadata.get("page_number") | |
| source_label = f"{source}, page {page}" if page else source | |
| item = { | |
| "text": record["text"], | |
| "metadata": metadata, | |
| "distance": 1 / (score + 1), | |
| } | |
| retrieved.append(item) | |
| context_parts.append(f"Source: {source_label}\n{record['text']}") | |
| return "\n\n---\n\n".join(context_parts), retrieved | |
| def list_sources() -> List[str]: | |
| sources = [] | |
| for record in collection_records(): | |
| source = record["metadata"].get("file_name") | |
| if source and source not in sources: | |
| sources.append(source) | |
| return sources | |