Embedding, indexing, retrieval, and vector storage internals. Covers how documents get into ChromaDB, how queries work, and the design decisions behind chunking, truncation, and collection layout. See SCORING_ENGINE.md for how retrieved scores are fused and ranked.
The RAG pipeline is the core intelligence layer. It converts unstructured text (resumes, role descriptions, rubric signals, job descriptions) into vector embeddings, stores them in ChromaDB collections, and retrieves similarity scores at query time.
Source Files Indexer ChromaDB
───────────── ───────────────────── ─────────────────────
resume.md ──▶ chunk by ## heading ──▶ resume
archetypes.toml ──▶ synthesize desc+signals ──▶ role_archetypes
──▶ extract signals_negative. ──▶ negative_signals
global_rubric ──▶ synthesize per dimension ──▶ global_positive_signals
──▶ extract signals_negative. ──▶ negative_signals
decisions ──▶ embed jd_text + reason ──▶ decisions
Query Time:
JD text ──▶ Embedder ──▶ query each collection ──▶ ScoreResult
The Embedder class wraps Ollama's async client for both embedding and LLM
classification.
- Model:
nomic-embed-text(configurable viaollama.embed_model) - Input validation: Empty or whitespace-only text raises a
VALIDATIONerror - Truncation: Text exceeding 8,000 characters is truncated using a head+tail strategy before embedding
Real JDs front-load title, company, and overview but place key signals (compensation, hands-on requirements, tech stack) in the final third. Naive head-only truncation loses these signals.
┌──────────────────────────────────────────────────────┐
│ Head: 60% (4,800 chars) │
│ Title, company, overview, requirements │
├──────────────────────────────────────────────────────┤
│ [...] marker │
├──────────────────────────────────────────────────────┤
│ Tail: 40% (3,200 chars) │
│ Comp, tech stack, hands-on details, culture │
└──────────────────────────────────────────────────────┘
- Head ratio: 60% of
max_embed_chars(4,800 chars) - Tail ratio: 40% of
max_embed_chars(3,200 chars) - Marker:
"\n[…]\n"inserted between sections - Constant:
max_embed_chars = 8,000
- Model:
mistral:7b(configurable viaollama.llm_model) - System message: "You are a job listing classifier..."
- Used for disqualification prompts and injection screening
Both embed and classify retry transient failures:
- Max retries: 3
- Backoff: Exponential —
1.0s × 2^(attempt−1)→ 1s, 2s, 4s - Retryable status codes: 408, 429, 500, 502, 503, 504
- Non-retryable: Immediate failure (e.g., 404 model not found)
health_check() runs before any pipeline work:
- Verify Ollama is reachable at
base_url - Check that
embed_modelis pulled - Check that
llm_modelis pulled - Raises
CONNECTIONerror if unreachable,EMBEDDINGerror if models are missing (withollama pull <model>in the suggestion)
@dataclass
class InferenceMetrics:
embed_calls: int = 0
embed_tokens_total: int = 0
llm_calls: int = 0
llm_tokens_total: int = 0
llm_latency_ms_total: int = 0
slow_llm_calls: int = 0 # calls exceeding slow_llm_threshold_msMetrics are accumulated per run and emitted in the session_summary event.
All collections use cosine distance. The VectorStore class wraps ChromaDB's
client with typed methods.
| Collection | Documents | ID Pattern | Key Metadata |
|---|---|---|---|
resume |
Resume section chunks | resume-{slug} |
source="resume", section=heading |
role_archetypes |
Synthesized archetype text | archetype-{slug} |
name=archetype_name |
negative_signals |
Individual signal statements | neg-{source-slug}-{signal-slug} |
source="rubric:{dim}" or "archetype:{name}" |
global_positive_signals |
Synthesized dimension text | pos-{dimension-slug} |
source=dimension_name, signal_count=N |
decisions |
JD text (+ operator reasoning) | {external_id} |
verdict, scoring_signal, reason, board, title, company |
- Required:
resumeandrole_archetypes— anINDEXerror is raised if either is empty at query time - Optional:
decisions,negative_signals,global_positive_signals— return a score of 0.0 if the collection is empty or missing - Query size: Top 3 results per query (
n_results = min(count, 3)) - Upsert semantics:
add_documentsperforms upserts — existing IDs are updated, not duplicated
The Indexer class populates collections from source files. All operations
are idempotent — the target collection is reset (dropped and recreated)
before re-indexing.
resume.md ──▶ split on "## " headings ──▶ one chunk per section
- The
#title heading is skipped ###sub-headings remain within their parent##section- Each chunk is embedded independently
- ID:
resume-{slugified_heading} - Metadata:
{"source": "resume", "section": "Experience"}etc.
role_archetypes.toml ──▶ per archetype:
description + "\n\nKey signals:\n" + signals_positive
──▶ one document per archetype
Combining the narrative description with concrete signal keywords creates richer embeddings that capture both the intent and the vocabulary of the target role.
Two sources feed the negative_signals collection:
- Global rubric — Each dimension's
signals_negativelist produces individual documents:neg-{dimension-slug}-{signal-slug} - Per-archetype — Each archetype's
signals_negativelist produces individual documents:neg-{archetype-slug}-{signal-slug}
Each signal is embedded independently (not synthesized), so the similarity query matches against the most relevant individual signal.
global_rubric.toml ──▶ per dimension:
dimension name + "\n" + signals_positive (joined)
──▶ one document per dimension
Dimensions without signals_positive produce no document. The
"Compensation Red Flags" dimension typically has only negative signals and
produces no positive document.
When the scorer processes a JD:
- Chunk the JD if
len(jd_text) > max_embed_chars(overlap: 2,000 chars) - For each chunk:
a. Embed via
nomic-embed-textb. Queryresume→ distances →fit_scorec. Queryrole_archetypes→ distances →archetype_scored. Querydecisions(whereverdict="yes") → distances →history_scoree. Querynegative_signals→ distances →negative_scoref. Queryglobal_positive_signals→ distances →culture_score - Aggregate: Keep the maximum score per component across all chunks
- Comp score: Parse compensation via regex, compute against
base_salary - Disqualifier: Run LLM disqualification (independent of embedding queries)
- Return
ScoreResultwith all six components + disqualification status
A strong signal in any chunk should count. If chunk 3 of 5 mentions your exact tech stack, that's a genuine fit — even if chunks 1–2 are generic company boilerplate. Taking the maximum preserves the strongest match.
Cosine distance measures directional alignment between vectors, ignoring magnitude. This is ideal for semantic similarity: two documents about the same topic will point in similar directions regardless of length or verbosity.
Each scoring dimension lives in its own collection so that:
- Scores are independently interpretable (fit vs. archetype vs. culture)
- Weights can be tuned per dimension without affecting others
- Collections can be re-indexed independently (e.g., update resume without re-indexing archetypes)
- Missing collections degrade gracefully (0.0 default) rather than blocking the pipeline
- Negative signals are embedded individually because a single red flag in a JD should trigger the penalty. Synthesis would dilute specific signals.
- Positive signals are synthesized per dimension because they represent a holistic quality (e.g., "good culture" = remote + async + autonomy together). The combined embedding captures the concept better than any single keyword.
Standard head-only truncation loses compensation and tech stack details that cluster at the end of JDs. The 60/40 split preserves both the role overview and the concrete details that drive scoring decisions.