"""Embedding and FAISS retrieval — REVISED v3.""" import os import pickle from typing import List, Dict import numpy as np import faiss from sentence_transformers import SentenceTransformer from config import ( EMBEDDING_MODEL, EMBEDDING_DIM, INDEX_DIR, TOP_K, TOP_K_FETCH, get_boosted_sections ) class PortfolioRetriever: """Dual-tier retriever: FAISS semantic + keyword fallback.""" def __init__(self, build: bool = False): self.encoder = SentenceTransformer(EMBEDDING_MODEL, device="cpu") self.index = None self.chunks = [] if build or not self._index_exists(): self._build_index() else: self._load_index() def _index_exists(self) -> bool: return os.path.exists(os.path.join(INDEX_DIR, "faiss.index")) def _build_index(self): """Build FAISS index from KB chunks.""" from ingestion import parse_kb print("Building FAISS index...") os.makedirs(INDEX_DIR, exist_ok=True) self.chunks = parse_kb() texts = [c["text"] for c in self.chunks] print(f"Encoding {len(texts)} chunks with {EMBEDDING_MODEL}...") embeddings = self.encoder.encode( texts, show_progress_bar=True, normalize_embeddings=True ) self.index = faiss.IndexFlatIP(EMBEDDING_DIM) self.index.add(np.array(embeddings).astype('float32')) faiss.write_index(self.index, os.path.join(INDEX_DIR, "faiss.index")) with open(os.path.join(INDEX_DIR, "chunks.pkl"), 'wb') as f: pickle.dump(self.chunks, f) print(f"Index saved: {len(self.chunks)} chunks, {EMBEDDING_DIM}d") def _load_index(self): """Load pre-built index.""" print("Loading pre-built FAISS index...") self.index = faiss.read_index(os.path.join(INDEX_DIR, "faiss.index")) with open(os.path.join(INDEX_DIR, "chunks.pkl"), 'rb') as f: self.chunks = pickle.load(f) print(f"Loaded {len(self.chunks)} chunks") def retrieve(self, query: str, persona: str = "general", k: int = TOP_K) -> List[Dict]: """Retrieve relevant chunks with keyword fallback boosting.""" query_vec = self.encoder.encode([query], normalize_embeddings=True) query_vec = np.array(query_vec).astype('float32') scores, indices = self.index.search(query_vec, min(TOP_K_FETCH, len(self.chunks))) results = [] seen_ids = set() boosted_sections = get_boosted_sections(query) for score, idx in zip(scores[0], indices[0]): if idx < 0 or idx >= len(self.chunks): continue chunk = self.chunks[idx].copy() chunk_id = chunk["id"] if chunk_id in seen_ids: continue seen_ids.add(chunk_id) chunk["score"] = float(score) if chunk["section_type"] in boosted_sections: chunk["score"] *= 1.5 if persona == "recruiter" and chunk["section_type"] in [ "identity", "education", "skills", "skills_summary", "experience", "experience_summary_v2", "certifications", "kaggle_summary" ]: chunk["score"] *= 1.15 elif persona == "technical" and chunk["section_type"] in [ "architecture_deep_dive_axiomis", "projects", "skills", "skills_summary", "publications", "publications_summary", "philosophy", "clinical_ai_projects", "marketing_ai_projects", "supply_chain_projects", "energy_projects", "computational_biology_projects", "finance_regtech_projects", "education_projects", "neuro_symbolic_projects", "projects_master_list" ]: chunk["score"] *= 1.15 results.append(chunk) results.sort(key=lambda x: x["score"], reverse=True) final_results = [] section_counts = {} for r in results: st = r["section_type"] section_counts[st] = section_counts.get(st, 0) + 1 if section_counts[st] <= 3: final_results.append(r) if len(final_results) >= k: break return final_results def get_stats(self) -> Dict: from collections import Counter dist = Counter(c["section_type"] for c in self.chunks) return { "total_chunks": len(self.chunks), "embedding_dim": EMBEDDING_DIM, "encoder": EMBEDDING_MODEL, "section_distribution": dict(dist) }