redrob-ranker / scripts /build_fast_indexes.py
Venkat7123's picture
Deploy candidate ranker
37b0787
Raw
History Blame Contribute Delete
5.41 kB
from __future__ import annotations
import argparse
import json
import logging
import sys
import time
from pathlib import Path
import faiss
import numpy as np
from rank_bm25 import BM25Okapi
from sklearn.feature_extraction.text import HashingVectorizer
from tqdm import tqdm
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from src.core.config import DATA_DIR
from src.core.constants import CANDIDATES_PATH
from src.ingestion.normalizer import normalize_redrob
from src.ingestion.parser import ProfileParser
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def _build_document_text(profile) -> str:
parts: list[str] = []
if profile.raw_text:
parts.append(profile.raw_text)
parts.extend(s.name for s in profile.skills)
for exp in profile.experience:
parts.append(exp.title)
parts.append(exp.company)
parts.append(exp.description)
for edu in profile.education:
parts.append(edu.institution)
if edu.field:
parts.append(edu.field)
if profile.professional and profile.professional.current_title:
parts.append(profile.professional.current_title)
if profile.professional and profile.professional.current_company:
parts.append(profile.professional.current_company)
return " ".join(p for p in parts if p)
def _tokenize(text: str) -> list[str]:
return text.lower().split()
def _save_offset_index(profiles_path: Path, profile_ids: set[str], output_path: Path) -> None:
offsets: dict[str, int] = {}
with open(profiles_path, encoding="utf-8") as f:
while True:
offset = f.tell()
line = f.readline()
if not line:
break
line = line.strip()
if not line:
continue
try:
raw = json.loads(line)
except json.JSONDecodeError:
continue
cand_id = raw.get("profile_id") or raw.get("candidate_id") or raw.get("id")
profile_nested = raw.get("profile", {})
if isinstance(profile_nested, dict) and not cand_id:
cand_id = (
profile_nested.get("profile_id")
or profile_nested.get("candidate_id")
or profile_nested.get("id")
)
if cand_id and str(cand_id) in profile_ids:
offsets[str(cand_id)] = offset
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w") as f:
json.dump(offsets, f)
logger.info("Offset index saved: %d entries", len(offsets))
def build_fast_indexes(profiles_path: Path = CANDIDATES_PATH, batch_size: int = 2000) -> None:
start = time.perf_counter()
index_dir = DATA_DIR / "indexes"
index_dir.mkdir(parents=True, exist_ok=True)
faiss_path = index_dir / "faiss_index.bin"
id_map_path = index_dir / "faiss_id_map.json"
bm25_path = index_dir / "bm25_index.pkl"
offset_path = index_dir / "offset_index.json"
meta_path = index_dir / "index_meta.json"
parser = ProfileParser()
vectorizer = HashingVectorizer(
n_features=384,
alternate_sign=False,
norm="l2",
lowercase=True,
token_pattern=r"(?u)\b\w+\b",
)
index = faiss.IndexHNSWFlat(384, 32, faiss.METRIC_INNER_PRODUCT)
profile_ids: list[str] = []
corpus_tokenized: list[list[str]] = []
batch_texts: list[str] = []
loaded = 0
skipped = 0
def flush_batch() -> None:
if not batch_texts:
return
sparse = vectorizer.transform(batch_texts)
dense = sparse.astype(np.float32).toarray()
index.add(dense)
batch_texts.clear()
logger.info("Building fast indexes from %s", profiles_path)
for raw in tqdm(parser.parse_jsonl_file(profiles_path), desc="Profiles", unit="profile"):
try:
profile = normalize_redrob(raw)
text = _build_document_text(profile)
profile_ids.append(profile.profile_id)
corpus_tokenized.append(_tokenize(text))
batch_texts.append(text)
loaded += 1
if len(batch_texts) >= batch_size:
flush_batch()
except Exception:
skipped += 1
flush_batch()
logger.info("Loaded %d profiles (%d skipped)", loaded, skipped)
faiss.write_index(index, str(faiss_path))
with open(id_map_path, "w") as f:
json.dump(profile_ids, f)
with open(meta_path, "w") as f:
json.dump({"embedding": "hashing", "dimension": 384}, f)
logger.info("FAISS index saved: %d vectors", index.ntotal)
import pickle
with open(bm25_path, "wb") as f:
pickle.dump({"corpus_tokenized": corpus_tokenized, "id_map": profile_ids}, f)
logger.info("BM25 index saved: %d documents", len(profile_ids))
_save_offset_index(profiles_path, set(profile_ids), offset_path)
logger.info("All fast indexes built in %.1fs", time.perf_counter() - start)
def main() -> None:
parser = argparse.ArgumentParser(description="Build fast full-dataset indexes")
parser.add_argument("--profiles", type=Path, default=CANDIDATES_PATH)
parser.add_argument("--batch-size", type=int, default=2000)
args = parser.parse_args()
build_fast_indexes(args.profiles, args.batch_size)
if __name__ == "__main__":
main()