Every AI resume tool I tried had the same flaw: it wrote impressive bullets about work I had never done. For a job seeker that is worse than useless — it becomes a liability in the interview. So I gave HuntKit one rule: no claim without a source.
This post walks through how that rule is enforced, from ingest to the final answer.
The shape of the system
HuntKit is a Turborepo monorepo with two apps:
- a NestJS API (auth, profile, jobs, analyze and admin modules) on Render
- a Next.js dashboard on Netlify
Everything lives in one PostgreSQL database with the pgvector extension, accessed through Prisma.
1. Ingest: chunk and embed the profile once
A resume and a few project write-ups are split into small, self-contained chunks. Each chunk is embedded with OpenAI's text-embedding-3-small (1536 dimensions) and stored next to its text.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE profile_chunks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
profile_source_id uuid NOT NULL,
user_id uuid NOT NULL,
chunk_index int NOT NULL,
content text NOT NULL,
embedding vector(1536)
);
Embedding happens once per source, not on every analysis — that keeps each job analysis fast and cheap.
2. Retrieve: top 8 chunks by cosine similarity
When a job description comes in, it is embedded the same way and compared against that user's chunks. The eight closest chunks become the only evidence the model is allowed to use.
const results = await this.prisma.$queryRaw<RetrievedChunk[]>`
SELECT c.id, c.content, ps.title AS source_title,
1 - (c.embedding <=> ${vectorLiteral}::vector) AS score
FROM profile_chunks c
JOIN profile_sources ps ON ps.id = c.profile_source_id
WHERE c.user_id = ${userId}::uuid AND ps.status = 'ready'
ORDER BY c.embedding <=> ${vectorLiteral}::vector
LIMIT ${limit}
`;
Why not a separate vector database? For an MVP, keeping vectors in the same Postgres as users and jobs means one backup, simple joins and one bill.
3. Refuse to guess when there is no evidence
If retrieval returns nothing — for example, the user has not added a profile yet — the API does not call the model at all. It records a low_context run and tells the user to add profile sources first. An honest "I don't know" beats a confident invention.
4. Generate with citations
Each retrieved chunk is passed to the model labelled with its id and source:
[chunkId: 3f2c…] (source: Resume, score: 0.712)
Designed and shipped NestJS, TypeScript and PostgreSQL backend services…The model (gpt-4o-mini) returns structured JSON — match score, strengths, gaps, tailored bullets and likely interview questions — where strengths reference the chunkId they came from. The API returns those citations to the dashboard, so every suggestion can be traced back to a line of the user's real profile.
5. Log every AI run
Every analysis writes a row to an ai_runs table: model, prompt and completion tokens, latency, status, and the ids of the retrieved chunks. When an answer looks wrong, I can see exactly what the model was given instead of guessing.
Try the live demo or read the code on GitHub.

