agroadvisor-bd / src /retrieval.py
Borhan72's picture
Initial commit
84a9a2a
Raw
History Blame Contribute Delete
4.13 kB
import chromadb
from sentence_transformers import SentenceTransformer, CrossEncoder
from dataclasses import dataclass
from collections import defaultdict
# ── Config ─────────────────────────────────────────────────
EMBEDDING_MODEL_NAME = "paraphrase-multilingual-mpnet-base-v2"
RELEVANCE_THRESHOLD = 0.45
MAX_CHUNKS_PER_SOURCE = 2
CHROMA_DIR = "data/faiss_db"
_model = None
_cross_encoder = None
def get_model():
global _model
if _model is None:
print("Loading embedding model...")
_model = SentenceTransformer(EMBEDDING_MODEL_NAME)
print("Model loaded.")
return _model
def get_cross_encoder():
global _cross_encoder
if _cross_encoder is None:
print("Loading cross-encoder...")
_cross_encoder = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
print("Cross-encoder loaded.")
return _cross_encoder
def load_vectorstore(chroma_dir: str = CHROMA_DIR):
"""
Load ChromaDB collection using the new PersistentClient API.
Raises an error if the collection does not exist.
"""
try:
client = chromadb.PersistentClient(path=chroma_dir)
collection = client.get_collection("agricultural_knowledge")
print(f"ChromaDB loaded β€” Total chunks: {collection.count()}")
return collection
except Exception as e:
raise RuntimeError(
f"Failed to load ChromaDB from '{chroma_dir}'. "
f"Make sure the directory exists and contains a valid collection. "
f"Original error: {e}"
)
# alias for backward compatibility
load_collection = load_vectorstore
@dataclass
class RetrievedChunk:
text: str
source: str
chunk_id: int
similarity_score: float
page: int = None
def is_garbage_chunk(text: str) -> bool:
stripped = text.strip()
if len(stripped) < 120:
return True
alpha_ratio = sum(c.isalpha() for c in stripped) / max(len(stripped), 1)
if alpha_ratio < 0.4:
return True
if stripped.startswith("[Page") and len(stripped) < 30:
return True
return False
def diversify_sources(chunks: list, max_per_source: int = MAX_CHUNKS_PER_SOURCE) -> list:
source_counts = defaultdict(int)
diversified = []
for chunk in chunks:
src = chunk.source
if source_counts[src] < max_per_source:
diversified.append(chunk)
source_counts[src] += 1
return diversified
def rerank_chunks(query: str, chunks: list, top_k: int = 5) -> list:
if not chunks:
return []
model = get_cross_encoder()
pairs = [[query, chunk.text] for chunk in chunks]
scores = model.predict(pairs)
scored = sorted(zip(chunks, scores), key=lambda x: x[1], reverse=True)
return [item[0] for item in scored[:top_k]]
def retrieve(query: str, collection, top_k: int = 10) -> tuple:
model = get_model()
query_embedding = model.encode(query, normalize_embeddings=True).tolist()
results = collection.query(
query_embeddings=[query_embedding],
n_results=top_k,
include=["documents", "metadatas", "distances"]
)
raw_chunks = []
for i in range(len(results["documents"][0])):
distance = results["distances"][0][i]
similarity = round(1 - distance, 4)
text = results["documents"][0][i]
meta = results["metadatas"][0][i]
chunk = RetrievedChunk(
text=text,
source=meta.get("source", "Unknown"),
chunk_id=meta.get("chunk_id", 0),
similarity_score=similarity,
page=meta.get("page")
)
raw_chunks.append(chunk)
clean_chunks = [c for c in raw_chunks if not is_garbage_chunk(c.text)]
relevant_chunks = [c for c in clean_chunks if c.similarity_score >= RELEVANCE_THRESHOLD]
diverse_chunks = diversify_sources(relevant_chunks, max_per_source=MAX_CHUNKS_PER_SOURCE)
reranked = rerank_chunks(query, diverse_chunks, top_k=5)
has_reliable = len(reranked) > 0
return reranked, has_reliable