Thesis

Job boards match on keywords, and keyword matching fails the exact moment a candidate and a posting describe the same skill in different words. AICA Bot’s core claim: treating resume-to-job matching as a retrieval problem in embedding space recovers matches keyword search structurally cannot find. Pairing the retrieval score with a generated explanation makes that match something a person can actually act on.

The problem

A posting for “backend engineer” and a resume that says “server-side developer” don’t overlap on keywords, so both sides miss a match that should’ve been obvious. Keyword search doesn’t know those phrases mean roughly the same thing, it only knows whether strings match. Multiply that across every synonym pair in a technical field and the miss rate compounds fast.

Approach

AICA Bot treats matching as a retrieval problem, not a search problem. Resumes and job postings both get embedded into the same vector space with a FAISS index backing the similarity search, so “closeness” is measured by meaning instead of shared substrings.

ingest.py
def embed_resume(resume_text: str) -> list[float]:
chunks = split_into_sections(resume_text)
return [embedding_model.embed(chunk) for chunk in chunks]
match.py
def find_matches(resume_embedding, job_index, k=10):
scores = job_index.search(resume_embedding, k)
return rank_by_score(scores)

A resume section on “built REST APIs in Go” lands close to a posting saying “backend services in Golang,” even with no word overlap. Distance in embedding space stands in for semantic similarity.

Why chunking by section, specifically

My first version embedded the whole resume as one blob, one vector per resume. Match quality was mediocre in a way that took a while to diagnose, nothing crashed, it just quietly matched worse than it should have.

Splitting by section fixed it. A backend posting now matches specifically against the experience section about backend work, not diluted by an unrelated projects blurb.

Why reasoning, not just a score

A bare similarity score doesn’t tell a job seeker why their resume matched. “72% match” means nothing to someone deciding whether to spend twenty minutes on a cover letter. The reasoning pass takes the top-k matches and asks the model to explain the connection in plain language.

Slower and costs more per match, an extra model call on top of the vector search. Worth it because the product isn’t “here’s a number,” it’s “here’s a match you can trust.” A score without a reason is just a guess with extra steps.

What broke along the way

FAISS is fast but dumb about updates. Built for a mostly-static index, not one constantly getting new postings inserted. Rebuilding the whole index on every posting worked at a hundred, started lagging past a few thousand, switched to a batched rebuild instead.

The other rough edge was keeping the reasoning pass grounded instead of hallucinating a plausible connection. Fixed it by forcing the prompt to quote the specific snippet it’s basing the explanation on. “Explain why this matched” became “cite your source.”

Stack

FastAPI backend, Next.js frontend, LangChain for the RAG orchestration, Claude for the reasoning pass, Supabase/Postgres for persistence.

Result

90%+ match accuracy against a hand-labeled validation set, with reasoning attached to every match instead of a bare score. The validation set was built specifically to include near-miss, different-words-same-meaning pairs, not just easy exact-keyword cases a simpler system would already get right.