Hemprasad Badgujar
Add bm25 prefilter stage; unify candidates cache; validate reasoning quality
cd5f9d4 Download src/config.py from hembad/reranker: direct link, hf CLI and curl.
- Browser
- Download file 20.2 kB
-
https://huggingface.co/spaces/hembad/reranker/resolve/main/src/config.py
- Command line
-
hf download hf://spaces/hembad/reranker/src/config.py
-
curl -L -o config.py https://huggingface.co/spaces/hembad/reranker/resolve/main/src/config.py
20.2 kB
| """Central configuration β ONE place for every tunable in the engine. | |
| Values load from ``config/settings.yaml`` (anchored to the repo root, so it resolves from the CLI, the | |
| Streamlit app, or a container alike); env vars ``RERANKER_*`` OVERRIDE the YAML (e.g. | |
| ``RERANKER_DEBUG__CHECKPOINTS=1`` beats whatever settings.yaml says), via ``YamlConfigSettingsSource`` in | |
| ``settings_customise_sources`` below β NOT by reading the YAML and passing it as constructor kwargs, which would | |
| make it pydantic-settings' highest-priority source and silently shadow env vars for every nested field (the | |
| actual bug this replaced: ``RERANKER_HARD_FILTER__INACTIVE_DAYS=90`` was a no-op). Stage settings classes are | |
| added here as each phase lands β this is the single source of magic numbers. Access via ``load_settings()`` | |
| (cached for the process). | |
| """ | |
| from __future__ import annotations | |
| from functools import lru_cache | |
| from pydantic import BaseModel | |
| from pydantic_settings import ( | |
| BaseSettings, | |
| PydanticBaseSettingsSource, | |
| SettingsConfigDict, | |
| YamlConfigSettingsSource, | |
| ) | |
| from common.paths import REPO_ROOT | |
| SETTINGS_PATH = REPO_ROOT / "config" / "settings.yaml" | |
| class DebugSettings(BaseModel): | |
| """Debug/inspection toggles. All default OFF β zero overhead, byte-identical runs.""" | |
| # Persist each phase's output frame to a Parquet checkpoint for inspection (K13). OFF in normal runs. | |
| checkpoints: bool = False | |
| # Where checkpoints are written (relative paths resolve under the repo root). | |
| checkpoint_dir: str = "data/reports/debug" | |
| class ComputeSettings(BaseModel): | |
| """CPU thread budget shared by numpy/polars/tokenizers (env vars), ONNX (intra-op threads) and the | |
| llama.cpp client (``models.n_threads``, when that itself is left at its own auto default of 0). | |
| ``cpu_threads: 0`` (default) = auto β every core this process can actually use, cgroup-aware on Linux (see | |
| ``common.runtime.resolve_cpu_threads``). Set a positive value to cap the whole engine below that, e.g. to | |
| leave headroom for other processes on a shared host. | |
| """ | |
| cpu_threads: int = 0 | |
| class QualitySettings(BaseModel): | |
| """Weights for the 7 query-independent quality pillars; their blend is ``candidate_quality`` (βsum 1.0).""" | |
| skills: float = 0.22 | |
| experience: float = 0.20 | |
| reliability: float = 0.14 | |
| reputation: float = 0.12 | |
| engagement: float = 0.12 | |
| verification: float = 0.10 | |
| education: float = 0.10 | |
| class ModelSettings(BaseModel): | |
| """Model repos + GGUF sampling. JD parse (+ T1 reasoning polish, when enabled) uses Qwen3-1.7B (Q4 GGUF, | |
| llama.cpp); cached under data/.""" | |
| index_gen_llm_repo: str = "lmstudio-community/Qwen3-1.7B-GGUF" # JD-parse LLM (~29s gen; benchmarked vs | |
| index_gen_llm_gguf: str = "Qwen3-1.7B-Q4_K_M.gguf" # Qwen3.5-2B/Qwen3-4B/Qwen3-0.6B/Gemma-3 β see README | |
| embedder_broad: str = "minishlab/potion-base-32M" # static broad embedder (Model2Vec); A/B winner | |
| embedder_precision: str = "onnx-community/granite-embedding-english-r2-ONNX" # granite-r2 768-d ONNX | |
| reranker: str = "keisuke-miyako/granite-embedding-reranker-english-r2-onnx-int8" # cross-encoder (int8) | |
| # ^ potion-base beat potion-retrieval on OUR pool (gap 0.117 vs 0.08, 91.6% vs 81% AI-relevant in top-500) | |
| # because ideal_summary is profile-register β symmetric similarity, not asymmetric queryβdoc retrieval. | |
| n_ctx: int = 6144 # objective JD-parse prompt (~3.4K tokens) + max_tokens 1536 left only ~600 tokens of | |
| # headroom at 4096 β too tight for a longer JD; Qwen3-1.7B supports up to 32768 natively | |
| n_threads: int = 0 # 0 β llama.cpp auto (all cores) | |
| temperature: float = 0.8 # default (HyDE prose); objective parse β 0.0 | |
| top_p: float = 0.95 | |
| max_tokens: int = 1024 | |
| class JdSettings(BaseModel): | |
| """JD-parse inputs/outputs. The unified extraction JSON is generated live each run (no committed fallback).""" | |
| path: str = "problem/job_description.md" # raw JD prose (live-parse input) | |
| extraction_path: str = "data/jd_extraction.json" # ONE combined artifact: objective + subjective + summary | |
| # NOT data/artifacts/ β that dir is gitignored/dockerignored (model cache only), so this demo-critical | |
| # artifact would never ship with the repo or image. data/ itself IS tracked (candidates.jsonl lives there). | |
| # Objective-extraction recall. With temperature>0, draw up to ``parse_attempts`` samples and keep the | |
| # RICHEST (best-of-N), stopping early once total skills reach ``min_skills``. temperature=0.0 β a single | |
| # greedy/deterministic pass. | |
| parse_temperature: float = 0.5 # sampling temperature for the objective parse | |
| parse_attempts: int = 1 # samples to draw when temperature>0; keep the most skills | |
| # A prior attempts=2 test measured a 10/32/24-skill swing across 3 same-prompt trials at temperature=0.5, | |
| # i.e. attempts=1 CAN land an unlucky single sample below min_skills with no retry. Mitigated (not | |
| # eliminated) by dropping target_industries/exclude_companies from the prompt and sharpening the | |
| # must_have_skills/job_titles instructions β freed prompt/output budget for the two fields that matter. | |
| min_skills: int = 14 # early-stop target for the best-of-N sampling path | |
| parse_max_tokens: int = 1024 # objective-parse generation budget (raise if JSON truncates) | |
| parse_retry_on_failure: int = 2 # extra tries on a HARD failure (unparseable JSON, | |
| # or valid JSON with no titles AND no skills) β separate from parse_attempts (which samples for RICHNESS | |
| # even on success). Measured: ~1/9 single-attempt trials failed outright with no recovery before this. | |
| # Was 1536: never actually hit by any observed run β the richest good response seen (32 skills) used ~800 | |
| # tokens. 1024 keeps ~28% headroom above that while bounding the worst-case cost of a rare runaway/verbose | |
| # generation that never emits a natural stop token (unbounded on a 2-vCPU box, this is real wall-clock). | |
| class HardFilterSettings(BaseModel): | |
| """Candidate-only hard-filter toggles (the funnel entry). Each is independent; default-OFF unless noted.""" | |
| honeypot: bool = True # drop validation_failed (planted bad profiles) | |
| inactive: bool = True # drop inactive: dormant (days_since_active>N) OR active_validated==0 | |
| inactive_days: int = 180 # days_since_active threshold for "dormant" | |
| # granular integrity checks (already OR'd into validation_failed; individually opt-in for diagnosis) | |
| career_dates_invalid: bool = False | |
| yoe_impossible: bool = False | |
| date_order_invalid: bool = False | |
| salary_incoherent: bool = False | |
| class Bm25PrefilterSettings(BaseModel): | |
| """Step 04.5 β NEW early stage, right after hard_filter and BEFORE potion_broad/soft_filter. A cheap | |
| lexical BM25 pre-cut so the expensive potion embedding only has to touch ``top_n`` survivors instead of | |
| the full hard_filter pool (~74K rows). Location hard-drop also moves here (was in composite_narrow). | |
| This does NOT replace composite_narrow's later multi-signal blend (potion+soft+bm25) β that still runs | |
| once potion_score/soft_score exist, for the real pre-granite cut. This stage only prunes obviously | |
| irrelevant candidates early using JD-keyword overlap alone. Falls back to a full passthrough if the JD | |
| spec has zero lexical signal anywhere (empty/failed parse) rather than doing an arbitrary id-order cut. | |
| """ | |
| enabled: bool = True | |
| top_n: int = 10000 # keep the top N by lexical (BM25) relevance | |
| class BroadRetrievalSettings(BaseModel): | |
| """Step 05 potion broad β narrow the (now bm25-prefiltered) pool by cosine vs the JD ideal_summary.""" | |
| enabled: bool = True | |
| top_n: int = 500 # keep the top N by potion cosine β was top_pct=0.20; | |
| # a fixed count matches composite_narrow/precision (both already fixed counts) and stays predictable | |
| # regardless of how many rows survive hard_filter/bm25_prefilter. | |
| class SoftFilterSettings(BaseModel): | |
| """Step 06 soft_filter β two parameter types: JD-JSON hard FILTERS (drops) + PENALTY score, keep top-%. | |
| filter_parameters DROP rows; penalty_parameters (z-legs + JD soft signals + multi-signal combos) SCORE. | |
| """ | |
| top_pct: float = 0.50 # keep top 50% by soft_score | |
| # filter_parameters (hard drops) come from the JD spec (acceptable_countries) β no static config here | |
| # penalty_parameters β base single-signal z-legs | |
| weight_engagement: float = 0.40 # z(score_engagement) | |
| weight_company: float = 0.267 # z(company_size_rank) | |
| weight_production: float = 0.20 # z(score_skills) | |
| weight_years: float = 0.133 # z(years-band proximity) | |
| # penalty_parameters β JD soft signals | |
| industry_boost: float = 0.10 # + when current_industry β target_industries | |
| company_penalty: float = 0.20 # β when current_company β exclude_companies | |
| # penalty_parameters β multi-signal combos (product of two signals β strong on BOTH) | |
| combo_seniority_skills: float = 0.15 # senior title Γ z(skills) | |
| combo_recency_engagement: float = 0.15 # recently-active Γ z(engagement) | |
| combo_verification_experience: float = 0.10 # z(verification) Γ z(experience) | |
| recency_days: int = 90 # "recently active" threshold for the recency combo | |
| z_clip: float = 3.0 # clip each z-leg to Β±this | |
| class CompositeNarrowSettings(BaseModel): | |
| """Step 07 β field-scoped BM25 (lexical) + composite blend (potion + soft + bm25) β keep top_n for embedding.""" | |
| top_n: int = 200 # survivors granite will embed (embed only what we keep) | |
| # Was 500 -> 250 -> 200: at n_threads=2 (HF Space / free Colab), granite_precision costs ~0.74s/row β 500 | |
| # rows took 372s of a 300s total wall-clock budget on its own. Tightened further alongside the new | |
| # bm25_prefilter/potion_broad top_n stages, still leaving headroom above the final ensemble.top_n=100 cut. | |
| weight_potion: float = 1.0 # z(potion_score) weight in the blend | |
| weight_soft: float = 1.0 # z(soft_score) weight | |
| weight_bm25: float = 1.0 # z(bm25_score) weight (bm25_score = the field blend below) | |
| bm25_k1: float = 1.5 # BM25 term-frequency saturation | |
| bm25_b: float = 0.75 # BM25 length normalization | |
| # Field-scoped BM25: each JD keyword bag is matched against the candidate text section it targets, then the | |
| # per-field z-scores are blended with these weights (skills/titles carry more lexical signal than the rest). | |
| bm25_weight_skills: float = 1.0 # must_have_skills + nice_to_have β core/familiar_skills_text | |
| bm25_weight_titles: float = 1.0 # job_titles β identity_text + experience_text | |
| bm25_weight_education: float = 0.5 # education_fields β education_text | |
| bm25_weight_locations: float = 0.5 # locations_preferred + acceptable β candidate location | |
| bm25_weight_industries: float = 0.5 # target_industries β industries_text | |
| z_clip: float = 3.0 # clip each z-signal to Β±this | |
| class PrecisionSettings(BaseModel): | |
| """Step 08 β granite precision embedding (ONNX) over the composite-narrow survivors.""" | |
| onnx_file: str = "onnx/model_quantized.onnx" # int8 (3.6x faster on CPU); "onnx/model.onnx" = fp32 | |
| batch_size: int = 32 # ONNX inference batch | |
| max_length: int = 128 # truncate (texts p50=308; signal is early) β O(nΒ²) win | |
| top_n: int = 125 # keep top N by granite_score for the cross-encoder | |
| # Was 300 -> 150 -> 125: at n_threads=2, cross_encoder costs ~0.6s/row β 300 rows took 180s. Tightened in | |
| # step with composite_narrow.top_n (still 25 rows of headroom above ensemble.top_n=100). | |
| class RerankSettings(BaseModel): | |
| """Step 09 β cross-encoder rerank (granite-reranker-r2 ONNX) over the granite survivors.""" | |
| onnx_file: str = "model_quantized.onnx" # int8, self-contained | |
| batch_size: int = 32 | |
| max_length: int = 128 # query+doc pair length (O(nΒ²) β halves vs 256) | |
| class EnsembleSettings(BaseModel): | |
| """Step 10 β final fusion: weighted z-blend of the signal tiers β final_score β top_n (the submission set).""" | |
| top_n: int = 100 # final shortlist size (top-100 submission) | |
| weight_reranker: float = 0.50 # cross-encoder (most precise) | |
| weight_granite: float = 0.30 # granite bi-encoder precision | |
| weight_composite: float = 0.20 # broad blend (potion + soft + bm25) | |
| z_clip: float = 3.0 | |
| class ReasoningSettings(BaseModel): | |
| """Step 11 β multi-band JD-grounded explainability per finalist. | |
| ``mode``: ``template`` (current default) = pure deterministic NLG (no LLM, any tier); ``llm`` = NLG + | |
| T1-only polish (reuses the Qwen3-1.7B JD-parse model). T2 also got LLM polish at one point β first | |
| LFM2.5-350M (3/15 grounded, dropped), then the reused JD-parse model (better, but 25 sequential rows spent | |
| ~90s of the ~95s budget for modest gain over just the top 10) β narrowed back to T1 alone for a small, | |
| predictable wall-clock cost, then ``template`` turned LLM polish off entirely. The ``explanation`` column + | |
| artifact are ALWAYS LLM-free for all 100 β only the T1 CSV ``reasoning`` line is ever model-polished, and | |
| only when ``mode == "llm"``. | |
| """ | |
| mode: str = "template" # "template" (pure NLG, fast, DEFAULT) | "llm" (T1-only polish) | |
| recent_days: int = 90 # last-activity window for the "active this quarter" fact | |
| max_tokens: int = 96 # polish one-liner cap (T1) | |
| explain_llm_top_n: int = 20 # legacy; superseded by the tier ranges below | |
| # Rank-tier bands (non-overlapping, cover 1..100). T1βLLM polish (reused, only if mode=="llm"); T2/T3/T4 pure NLG. | |
| tier1_top: int = 10 # ranks 1..tier1_top = T1 (4 sentences, no gap, LLM if enabled) | |
| tier2_top: int = 25 # ranks ..tier2_top = T2 (3-4 sent, no gap, pure NLG) | |
| tier3_top: int = 75 # ranks ..tier3_top = T3 (3-4 sent, β€1 gap, pure NLG) | |
| sentences_top: int = 4 # T1 sentence count | |
| sentences_other: int = 4 # T2/T3/T4 max sentence count (min 3) | |
| max_neg_in_reasoning: int = 1 # β€1 soft gap in the CSV reasoning (T3/T4 only) | |
| variant_select: str = "potion_topk" # "potion_topk" (grounded) | "random" (blind) | |
| variant_topk: int = 3 # sample among the top-k closest variants | |
| llm_exec: str = "sequential" # "sequential" | "batched" | |
| llm_budget_seconds: float = 95.0 # wall-clock cap on the Step-11 LLM; over it β rows keep NLG | |
| # (graceful degradation so 2-vCPU stays under the 5-min budget) | |
| # Hard per-CALL timeout (not the same as llm_budget_seconds, which only gates the NEXT call β it can't stop | |
| # one already in flight). A single generation was observed taking 1996s on real deployment hardware with | |
| # nothing to interrupt it; this bounds any one row to a sane ceiling regardless of what the model does. | |
| llm_call_timeout_seconds: float = 45.0 | |
| explanations_path: str = "data/reports/explanations.json" # full per-candidate breakdown artifact | |
| class SubmissionSettings(BaseModel): | |
| """Step 12 β write submission.csv (contract: candidate_id, rank, score, reasoning; 100 rows).""" | |
| path: str = "submission.csv" # default output path | |
| top_n: int = 100 # exactly 100 data rows (challenge rule) | |
| score_decimals: int = 6 # round the normalized score | |
| class Settings(BaseSettings): | |
| """Root settings. Direct construction (``Settings()``, ``Settings(debug=...)``, ``.model_copy(...)``) uses | |
| ONLY class defaults + ``RERANKER_*`` env vars β no YAML β so tests and internal overrides stay disk- | |
| independent and predictable. For the real, YAML-backed config use ``load_settings()``. | |
| ``extra="forbid"`` is deliberate β an unknown key in settings.yaml is a typo, not silently ignored. | |
| Add a field here whenever you add a key to the YAML. | |
| """ | |
| model_config = SettingsConfigDict(env_prefix="RERANKER_", env_nested_delimiter="__", extra="forbid") | |
| debug: DebugSettings = DebugSettings() | |
| compute: ComputeSettings = ComputeSettings() | |
| quality: QualitySettings = QualitySettings() | |
| models: ModelSettings = ModelSettings() | |
| jd: JdSettings = JdSettings() | |
| hard_filter: HardFilterSettings = HardFilterSettings() | |
| bm25_prefilter: Bm25PrefilterSettings = Bm25PrefilterSettings() | |
| broad_retrieval: BroadRetrievalSettings = BroadRetrievalSettings() | |
| soft_filter: SoftFilterSettings = SoftFilterSettings() | |
| composite_narrow: CompositeNarrowSettings = CompositeNarrowSettings() | |
| precision: PrecisionSettings = PrecisionSettings() | |
| rerank: RerankSettings = RerankSettings() | |
| ensemble: EnsembleSettings = EnsembleSettings() | |
| reasoning: ReasoningSettings = ReasoningSettings() | |
| submission: SubmissionSettings = SubmissionSettings() | |
| class _YamlBackedSettings(Settings): | |
| """Internal β adds ``settings.yaml`` as a source, ranked BELOW env vars and ABOVE class defaults. Used only | |
| by ``load_settings()``; never construct this directly (construct ``Settings`` for disk-independent defaults). | |
| """ | |
| model_config = SettingsConfigDict(yaml_file=SETTINGS_PATH, yaml_file_encoding="utf-8") # merges with Settings' | |
| def settings_customise_sources( | |
| cls, settings_cls: type[BaseSettings], | |
| init_settings: PydanticBaseSettingsSource, env_settings: PydanticBaseSettingsSource, | |
| dotenv_settings: PydanticBaseSettingsSource, file_secret_settings: PydanticBaseSettingsSource, | |
| ) -> tuple[PydanticBaseSettingsSource, ...]: | |
| """Source priority (highest first): explicit constructor kwargs > ``RERANKER_*`` env vars > | |
| ``settings.yaml`` > ``.env`` > secrets. ``YamlConfigSettingsSource`` (a proper settings source, not a | |
| hand-parsed dict passed as init kwargs) is what lets env-var overrides reach nested fields β a plain | |
| dict given as init kwargs would outrank ``env_settings`` and silently shadow it for every field the | |
| YAML touches (the bug this replaced: ``RERANKER_HARD_FILTER__INACTIVE_DAYS=90`` used to be a no-op).""" | |
| return ( | |
| init_settings, env_settings, YamlConfigSettingsSource(settings_cls), dotenv_settings, | |
| file_secret_settings, | |
| ) | |
| def load_settings() -> Settings: | |
| """Load ``Settings`` from config/settings.yaml (defaults if absent), with ``RERANKER_*`` env vars taking | |
| priority over the YAML. Cached for the process.""" | |
| return _YamlBackedSettings() | |