abhishek-ai-bot / vectorless_store.py
Darknightcoder's picture
Speed up chatbot with vectorless retrieval
8557e31
Raw
History Blame Contribute Delete
4.98 kB
"""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