from pathlib import Path import os import faiss import numpy as np import pandas as pd from sklearn.feature_extraction.text import HashingVectorizer from sentence_transformers import SentenceTransformer def load_embedding_model(model_name: str = "sentence-transformers/all-MiniLM-L6-v2") -> SentenceTransformer: local_only = os.getenv("LOCAL_MODEL_ONLY", "0") == "1" return SentenceTransformer(model_name, local_files_only=local_only) def embed_texts(model: SentenceTransformer, texts: list[str], batch_size: int = 128) -> np.ndarray: embeddings = model.encode( texts, batch_size=batch_size, normalize_embeddings=True, show_progress_bar=True, ) return np.asarray(embeddings, dtype="float32") def build_faiss_index(embeddings: np.ndarray) -> faiss.Index: index = faiss.IndexFlatIP(embeddings.shape[1]) index.add(embeddings) return index def retrieve_top_k( candidates: pd.DataFrame, job_description: str, top_k: int = 1000, model_name: str = "sentence-transformers/all-MiniLM-L6-v2", tfidf_prefilter_k: int | None = 10000, ) -> pd.DataFrame: candidates = tfidf_prefilter_candidates(candidates, job_description, top_n=tfidf_prefilter_k) model = load_embedding_model(model_name) print(f"Embedding {len(candidates)} filtered candidate profiles.") candidate_embeddings = embed_texts(model, candidates["profile_text"].tolist()) index = build_faiss_index(candidate_embeddings) job_embedding = embed_texts(model, [job_description], batch_size=1) scores, indices = index.search(job_embedding, min(top_k, len(candidates))) retrieved = candidates.iloc[indices[0]].copy() retrieved["semantic_score"] = scores[0] return retrieved.reset_index(drop=True) def tfidf_prefilter_candidates( candidates: pd.DataFrame, job_description: str, top_n: int | None = 10000, ) -> pd.DataFrame: if top_n is None or top_n <= 0 or len(candidates) <= top_n: out = candidates.copy() out["tfidf_score"] = 1.0 return out.reset_index(drop=True) texts = candidates["profile_text"].fillna("").astype(str).tolist() vectorizer = HashingVectorizer( lowercase=True, stop_words="english", ngram_range=(1, 2), n_features=2**18, alternate_sign=False, norm="l2", ) matrix = vectorizer.transform([job_description] + texts) scores = (matrix[1:] @ matrix[0].T).toarray().ravel() keep = np.argsort(scores)[-top_n:][::-1] out = candidates.iloc[keep].copy().reset_index(drop=True) out["tfidf_score"] = scores[keep] print(f"TF-IDF prefilter kept {len(out)} of {len(candidates)} candidates") return out def save_processed(candidates: pd.DataFrame, path: str | Path) -> None: Path(path).parent.mkdir(parents=True, exist_ok=True) candidates.to_csv(path, index=False, encoding="utf-8")