job-chat / retrieval.py
codegood's picture
ocr, email writing, rag
d7d27a3
Raw History Blame Contribute Delete
3.57 kB
"""Hybrid BM25 and FAISS retrieval over the uploaded profile."""
import bm25s
import faiss
import numpy as np
import Stemmer
from dataloader import SEPARATOR
WORDS_PER_CHUNK = 100
CHUNK_OVERLAP_WORDS = 15
RRF_K = 60
def chunk_documents(
profile_text,
words_per_chunk=WORDS_PER_CHUNK,
overlap=CHUNK_OVERLAP_WORDS,
):
"""Split each source document into word windows with exact overlap."""
if words_per_chunk <= 0 or overlap < 0 or overlap >= words_per_chunk:
raise ValueError("Require words_per_chunk > overlap >= 0")
chunks = []
step = words_per_chunk - overlap
for document in profile_text.split(SEPARATOR):
document = document.strip()
if not document:
continue
lines = document.splitlines()
has_source = lines[0].startswith("# Source:")
source = lines[0].removeprefix("# Source:").strip() if has_source else ""
body = "\n".join(lines[1:]).strip() if has_source else document
words = body.split()
for start in range(0, len(words), step):
chunk = " ".join(words[start : start + words_per_chunk])
if chunk:
chunks.append(_label(source, chunk))
if start + words_per_chunk >= len(words):
break
return chunks
def _label(source, text):
return f"[{source}]\n{text}" if source else text
def _reciprocal_rank_fusion(*rankings, k=RRF_K):
scores = {}
for ranking in rankings:
for rank, chunk_id in enumerate(ranking, start=1):
if chunk_id >= 0:
scores[chunk_id] = scores.get(chunk_id, 0.0) + 1.0 / (k + rank)
return sorted(scores, key=scores.get, reverse=True)
class ProfileIndex:
def __init__(self, chunks, embed_fn):
self.chunks = list(chunks)
self.embed_fn = embed_fn
self.stemmer = Stemmer.Stemmer("english")
if self.chunks:
tokens = bm25s.tokenize(
self.chunks,
stopwords="en",
stemmer=self.stemmer,
show_progress=False,
)
self.bm25 = bm25s.BM25()
self.bm25.index(tokens, show_progress=False)
vectors = np.ascontiguousarray(embed_fn(self.chunks), dtype=np.float32)
faiss.normalize_L2(vectors)
self.faiss_index = faiss.IndexFlatIP(vectors.shape[1])
self.faiss_index.add(vectors)
else:
self.bm25 = None
self.faiss_index = None
def search(self, query, k=5):
"""Return chunks ranked by reciprocal-rank fusion of BM25 and cosine."""
if not self.chunks or not query.strip() or k <= 0:
return []
candidate_count = min(k, len(self.chunks))
query_tokens = bm25s.tokenize(
[query],
stopwords="en",
stemmer=self.stemmer,
show_progress=False,
)
bm25_ids, _ = self.bm25.retrieve(
query_tokens,
k=candidate_count,
show_progress=False,
)
dense_query = np.ascontiguousarray(self.embed_fn([query]), dtype=np.float32)
faiss.normalize_L2(dense_query)
_, faiss_ids = self.faiss_index.search(dense_query, candidate_count)
fused_ids = _reciprocal_rank_fusion(bm25_ids[0], faiss_ids[0])
return [self.chunks[i] for i in fused_ids[:candidate_count]]
def search_text(self, query, k=5):
found = self.search(query, k)
return "\n\n---\n\n".join(found) if found else "(No relevant excerpts found.)"