RAG on a CV-Sized Corpus: How This Site Answers Questions About Me
rag embeddings mongodb-atlas geminiopenaigrounded-generation

When I decided this portfolio should be chat-first, I had one non-negotiable rule: the assistant may only talk about what I actually published. No invented employers, no inflated years of experience, no "confident" filler when the corpus has nothing useful.

That rule is exactly what Retrieval-Augmented Generation (RAG) is for. RAG does not replace the language model — it constrains it. Before the model writes a sentence, the system retrieves relevant documents from a trusted store and puts those documents into the prompt. The model answers from that context (or admits it does not know), instead of relying on parametric memory.

On a marketing site that would be a nice-to-have. On a recruiter-facing CV chatbot it is the product.

Why RAG fits this site

Visitors ask free-form questions:

  • "Have you worked with Kafka?"
  • "Tell me about the NotNull Online project."
  • "Which company did you stay at the longest?"

The answers live in the same collection that powers the public pages: published knowledge items (projects, experience, skills, articles, about, testimonials). Every published item is both a SEO page and a RAG document. There is no separate "chat knowledge base" to keep in sync — the site is the corpus.

That design only works if retrieval and generation stay honest. The rest of this article walks the pipeline as it actually runs in production: OpenAI embeddings into MongoDB Atlas, hybrid retrieval, Gemini for chat, then grounding gates before anything reaches the UI.

Ingestion: whole items, no chunking

Classic RAG tutorials start with PDF chunking. I skipped that on purpose.

My corpus is a personal CV: roughly a few dozen knowledge items, a few kilobytes of structured text. Chunking would add boundary noise without buying recall. Each published item is one embeddable document.

When I create or update an item in admin, the backend builds an embedding input and calls the embedding API asynchronously:

Type: {type}
{title}
{summary}
{content}
{metadata_search_text}
Tags: {tags}

Metadata matters as much as prose — technologies, company, role, proficiency, years, and date ranges all become searchable text so a question like "C# projects" can match structured fields, not only narrative.

Defaults in production:

Concern Choice
Embedding provider OpenAI-compatible /embeddings
Model text-embedding-3-large
Dimensions 1536 (must match the Atlas vector index)
Similarity Cosine
Chat LLM Google Gemini (gemini-3.1-flash-lite, temperature 0.2)

Chat completions never go to OpenAI. Embeddings never go through Gemini. Splitting providers kept the vector index stable while I changed chat models for cost and EU disclosure reasons.

Drafts are not embedded. Publishing (or an admin reindex) is what puts a vector on the document. Atlas then indexes the embedding field for $vectorSearch and the text fields for $search.

flowchart LR
  Admin[Admin create or update] --> Persist[Persist knowledge item]
  Persist --> EmbedSpawn[Spawn embedding job]
  EmbedSpawn --> Build[Build embedding input]
  Build --> OpenAI[OpenAI text-embedding-3-large]
  OpenAI --> Store["Store embedding 1536-d float vector"]
  Store --> AtlasV[Atlas Vector Search index]
  Persist --> AtlasT[Atlas Text Search index]

Hybrid retrieval (never vector-only)

A query embedding alone is a poor fit for exact names, acronyms, and short technology tokens. Full-text search alone is a poor fit for paraphrases ("message bus work" vs "Kafka"). So every RAG call runs both, in parallel:

  1. Embed the user question.
  2. $vectorSearch top 5 + $search top 5 (published only, optional content-type filter).
  3. Min-max normalize each channel's scores.
  4. Merge and dedupe by item id; items present in both channels get a +0.1 boost.
  5. Optional focus-term filter, then take the final top 8.

The merge parameters look like this in practice:

vector_top_k = 5
text_top_k   = 5
final_top_k  = 8
hybrid_boost = 0.1   # when an item hits both channels
min_score    = 0.0

Hybrid retrieval is mandatory here. Vector-only was an early temptation and a deliberate non-goal: recruiter queries are often lexical ("C#", "Axum", employer names), and those fail silently when you only do cosine similarity.

From retrieval to a grounded answer

Retrieval is only half of RAG. The other half is how the prompt is built and what happens after the model streams tokens.

On each chat turn that reaches RAG:

  1. Hybrid retrieve the top hits.
  2. Load the full published catalog as compact structured lines (more on why in the next article — analytical questions need the whole set, not top-k prose).
  3. If both retrieval and catalog are empty, return a fixed refusal: "I don't have information about that."
  4. Build a system message: constant system prompt + optional portfolio PDF URL + catalog block + retrieved prose.
  5. Append server-side session history and the latest user message.
  6. Stream from Gemini (SSE when the client asks for text/event-stream).
  7. Post-process: strip inline [Sources: …] noise, run grounding validation, fall back to a deterministic excerpt or the no-information message, derive source chips.

The system prompt is a reviewed constant. The important constraints, in plain language:

  • Answer only from the catalog and retrieved details.
  • Reasoning over real facts is allowed; inventing facts is not.
  • Do not emit markdown portfolio URLs or footnote-style citations — the UI owns sources and entity links.
sequenceDiagram
  participant User
  participant ChatAPI as Chat API
  participant ChatSvc as Chat service
  participant Embed as Embedding API
  participant Atlas as Atlas Search
  participant Catalog as Catalog
  participant LLM as Gemini
  participant Guard as Grounding gates

  User->>ChatAPI: POST /api/chat
  ChatAPI->>ChatSvc: answer stream message plus session
  ChatSvc->>Embed: embed query
  Embed-->>ChatSvc: query vector
  par Hybrid retrieve
    ChatSvc->>Atlas: text search top 5
    ChatSvc->>Atlas: vector search top 5
  end
  Atlas-->>ChatSvc: merged top 8 hits
  ChatSvc->>Catalog: load published catalog
  Catalog-->>ChatSvc: compact facts
  ChatSvc->>LLM: system plus catalog plus hits plus history
  LLM-->>ChatSvc: token stream
  ChatSvc->>Guard: validate plus fallback plus sources
  Guard-->>ChatAPI: answer plus sources
  ChatAPI-->>User: SSE token / sources / done

Grounding is not "please don't hallucinate"

Prompt instructions alone are not enough. Models still emit confident claims and occasionally name-drop titles that were never retrieved. I treat grounding as defense in depth:

  1. Prompt contract — no fabrication; refuse when the context lacks the fact.
  2. Grounding validation — reject answers that assert portfolio facts without support in the provided context.
  3. Grounded fallback — if the model refuses or fails validation, replace the answer with an excerpt from the best hit, or the fixed no-information string.
  4. Structured sources — chips with slug, title, and type derived from hits (and catalog items named in the answer), capped at eight. The model must not invent URLs.

Streaming is part of the UX contract too. The API can return JSON, but the chat UI prefers SSE: early sources, then token pieces (whitespace-split for smoother rendering), then a possible corrective sources event after finalize, then done. Sessions live in MongoDB with a short TTL (~7 days); the browser only keeps a session id in sessionStorage.

What this buys me

For a CV-sized corpus, RAG is not "search Google and summarize." It is a thin, opinionated loop:

publish once → embed once → retrieve hybrid → reason only on provided facts → gate the output → show sources.

That is enough for descriptive questions ("tell me about X"). Analytical questions ("longest job?", "how many Rust projects?") need one more idea: put the entire structured catalog in context. That choice — and why I did not build a tool-calling agent for it — is the next article.

Updated Aug 12, 2026