Skip to content
ADR-00110 Aug 2026
ADR-001acceptedAI Infrastructure

Retrieval architecture for a low-traffic, high-scrutiny RAG assistant

A managed vector database is the usual answer for RAG, and at this corpus size it buys nothing. The whole index is about 480 KB, so it lives in the route handler's own memory instead: no network hop, no monthly floor cost, and nothing to be down when someone opens the demo mid-interview.

  • Next.js Route Handlers
  • Server-Sent Events
  • Voyage embeddings
  • Groq / Gemini / OpenAI / Claude
  • BM25
  • Reciprocal rank fusion
01Problem & context

What forced a decision

The assistant on this site answers questions about my own architecture writing. The corpus is currently ~20k tokens of MDX that changes only when I publish, and traffic is bursty and low — a handful of recruiters and engineers, clustered around whenever a link gets shared. The hard constraint is that it must never be down or embarrassing when someone opens it during a hiring conversation, and it must not carry a standing monthly cost for a personal site.

Hard constraints

  • Corpus is small (~20k tokens, 120 chunks) and changes only at deploy time, never at runtime.
  • Traffic is bursty and low; a per-hour idle cost is pure waste.
  • Cold-start latency is user-visible — this is a demo people open once and judge.
  • A stranger can drive the LLM, so per-IP abuse and token spend must be bounded.
  • No standing infrastructure I have to patch, monitor, or renew.
02Options evaluated

What was on the table

Each option carries the one sentence that justifies its verdict.

  1. Option 01rejected

    Managed vector DB (Pinecone / Weaviate)

    Push embeddings to a hosted index; query it over the network per request.

    For

    • Scales to millions of vectors without any rethinking.
    • Metadata filtering and hybrid search available out of the box.
    • Index updates are decoupled from deploys.

    Against

    • Adds a network round-trip to every query, against an index this size that needs none.
    • Carries a monthly floor cost regardless of traffic.
    • A third-party outage takes the demo down during the exact conversation it exists for.
    • Free tiers on these services are periodically culled; the demo would silently rot.

    BecauseIt solves scale I do not have while adding a network hop to the hot path, a monthly floor cost, and an availability dependency I do not control — on a corpus that fits in memory several times over.

  2. Option 02rejected

    Postgres + pgvector

    Store embeddings in a Postgres column and query with the vector distance operators.

    For

    • One datastore for relational and vector data; no second consistency model.
    • Well-understood operationally; ordinary backups and migrations apply.
    • Portable across every cloud and local development.

    Against

    • Requires provisioning and paying for a database this site otherwise does not need.
    • Serverless Postgres cold starts land on the user-visible path.
    • Connection pooling from serverless functions is its own class of problem.

    BecauseThe right answer the moment there is already a Postgres in the stack, or the moment retrieval needs to join against relational data. Neither is true here, and provisioning a database purely to hold 56 vectors inverts the cost.

  3. Option 03chosen

    Build-time embeddings, in-process hybrid index

    Embed the corpus during the build, ship the vectors as a static artifact, and run cosine similarity plus BM25 in the route handler's own memory.

    For

    • No network hop and no cold-start database on the retrieval path.
    • Zero standing cost; retrieval spend is bounded by the embedding API at build time only.
    • The index is a build artifact, so it is versioned and rolls back with the deploy.
    • No runtime dependency that can be down when it matters.

    Against

    • Does not survive corpus growth — brute-force scoring is linear in chunk count.
    • Re-embedding requires a deploy, so content and index cannot drift apart, but also cannot update independently.
    • Index ships in the serverless bundle, so bundle size grows with the corpus.

    BecauseAt this size the entire index is smaller than a single hero image. Keeping it in process removes the network hop, the idle cost, and the vendor dependency at once, and the corpus is immutable between deploys — the exact condition that makes a build-time index correct rather than lazy.

03Trade-off matrix

What each option costs

Scored on the axes that mattered here. The option I chose is weaker than the alternatives on at least one row, which is usually where the interesting conversation starts.

Trade-off matrix comparing 3 options across 5 criteria
CriterionManaged vector DB (Pinecone / Weaviate)Postgres + pgvectorBuild-time embeddings, in-process hybrid indexChosen
Query latency (retrieval only)In-process scoring has no network hop; serverless Postgres pays a cold start.WeakStrong
Cost at ~100 queries/monthBoth hosted options carry a monthly floor; the static index carries none.WeakStrong
Scales past a few thousand chunksThis is the axis the chosen option deliberately gives up.StrongWeak
Operational surfaceWeakStrong
Availability independenceAdequateStrong
04Decision

What I chose

Embed the corpus at build time with Voyage, ship the vectors as a static JSON artifact, and run hybrid retrieval — BM25 lexical scoring fused with dense cosine similarity via reciprocal rank fusion — inside the route handler. Generation streams over SSE from the first available of an ordered provider chain, so a free tier answers by default and a paid one only bills when the free tiers are down. Retrieval and generation are separated behind an interface so the index can move without the generation path changing.

Build-time embeddings, in-process hybrid index

Reversibility

Cost to undo

I bounded the lock-in on three axes. Retrieval sits behind a `Retriever` interface whose only contract is `search(query) -> Chunk[]`, so swapping the in-process index for pgvector is one new implementation and a changed import — the route handler does not know which is behind it. The embedding provider sits behind an `embed(texts) -> number[][]` function, so moving off Voyage is one file, with a re-index as the only migration. The generation call sits behind a `generate()` boundary with no framework wrapper: adding or reordering a provider is an entry in one list, and three of the four share a single OpenAI-wire adapter, so the prompt stays portable. The decision that is genuinely expensive to reverse is the corpus-size assumption, so that is the one with a written-down trigger to revisit it.

05System blueprint

How it fits together

Hybrid retrieval and generation pipelineAt deploy time the MDX corpus is chunked, each chunk is embedded with Voyage, and the result is written to a static index that ships with the build and is loaded into memory on cold start. Per request, a visitor query splits into two parallel retrieval paths that read that same index. The dense path embeds the query and scores it by cosine similarity; the lexical path tokenises the query and scores it with BM25 over the same chunks. The two ranked lists are combined with reciprocal rank fusion, the top passages are selected, and the first available model provider in an ordered chain generates an answer, streamed to the browser over server-sent events. A dashed edge shows the degraded path: if the query embedding fails, the BM25 ranking alone feeds fusion. A telemetry channel on the same stream reports the timing of each stage and which provider served the answer.BUILD TIME — runs on deployMDXcorpusChunk~215 tokEmbedcorpusStatic vector indexREQUEST PATH — runs per queryloaded on cold startQueryvisitorEmbedquery · 1024dCosine top-kdenseTokenisestem · stopBM25 top-ksame chunksRRF fusionrank-basedTop passageswith citationsGenerationfirst of chainfallback: BM25-only if the query embedding failsSSE streamanswer + telemetry
Fig. 1 — The index is built on deploy and read on every request. Two retrieval paths score against it — teal is dense, yellow is lexical — and are fused by rank rather than score. The dashed edge is the degraded mode: the BM25 ranking alone answers if the query embedding fails.
06Failure modes

What happens when it breaks

The ways this design can break, and what it is set up to do when they happen.

  • The primary generation provider is unavailable or rate-limits the request

    major
    Blast radius
    Generation only. Retrieval still succeeds and returns cited sources.
    Detection
    Non-2xx from the provider SDK, caught at the `generate()` boundary.
    Mitigation
    The request falls through to the next provider in the chain, and the trace panel names the one that served it rather than hiding the switch. Fallback is skipped once a provider has streamed its first token, since splicing two models mid-sentence produces prose neither of them wrote. If every provider fails, the UI degrades to retrieved passages with citations and an explicit notice rather than an empty chat box.
  • A visitor scripts the endpoint to burn tokens

    major
    Blast radius
    Token spend, capped. No data exposure — the corpus is public content.
    Detection
    Per-IP request counter in the route handler; spend visible in the console.
    Mitigation
    Fixed-window per-IP rate limit, a hard cap on `max_tokens`, and a rejected request budget that returns 429 rather than degrading silently.
  • Query embedding call fails or is rate-limited

    minor
    Blast radius
    Dense retrieval only; lexical retrieval is unaffected.
    Detection
    Caught at the embedding boundary and reported on the telemetry stream.
    Mitigation
    Retrieval falls back to BM25-only scoring. Answer quality degrades on paraphrased queries but the system stays up, and the telemetry panel says so. This is not hypothetical: the embedding tier in use allows 3 requests per minute, so a burst of visitors trips it and the trace shows `lexical-only` until the window resets.
  • Corpus grows past the point where brute-force scoring is viable

    minor
    Blast radius
    Retrieval latency degrades linearly; bundle size grows.
    Detection
    Build-time assertion on chunk count and artifact size.
    Mitigation
    The documented trigger to swap the `Retriever` implementation for pgvector. The build fails rather than silently shipping a slow index.
07Numbers

What it costs to run

Each figure says where it came from, so you can judge how much weight to give it.

120
Chunks indexed

Output of `npm run ingest` over 6 ADRs, 1 playbook and 4 posts; ~20,048 tokens, mean 167 per chunk.

0.06 ms
Retrieval latency (p50)

Measured by `npm run eval` over 12 golden queries, in-process, lexical path only (p95 0.60 ms). Excludes the query-embedding network call, which dominates once the dense path is enabled.

12 / 12
Recall@6

`npm run eval` golden set, lexical-only. Honest caveat: the corpus is six documents, so this measures that the pipeline works, not that retrieval is hard. The number only becomes meaningful as the corpus grows.

$0.00
Standing monthly cost

No provisioned datastore. Embedding spend occurs at build time only; generation is per-query.

08Notes

The longer version

Why this ADR exists

Almost every RAG tutorial reaches for a managed vector database in the first paragraph. That is the right call at scale, and it is why I nearly did the same thing here before working out what the index would actually weigh.

A vector store earns its keep when the corpus is large, changes independently of deploys, and gets queried often enough for the index to pay for itself. This corpus is small, it only changes when I publish, and it gets queried a handful of times a week. None of the conditions hold, so I went looking for what does fit.

The sizing argument

The whole decision turns on one calculation, done before any code was written:

~20,000 tokens of MDX
  ÷ ~167 tokens per chunk (semantic split, ~40 token overlap)
  = 120 chunks
  × 1024 dimensions × 4 bytes
  ≈ 480 KB of raw float32

Under half a megabyte. That fits in the smallest serverless runtime several times over, and comparing a query against 120 vectors is arithmetic that finishes in microseconds. The managed option would have spent more time on the network round-trip than the whole in-process scan takes.

I did not want this to rest on me remembering the limit, so the build checks it: npm run ingest fails above 4,000 chunks, roughly 33× the current corpus. When that fires, it is the trigger to move the retriever to pgvector.

Dense retrieval on its own does badly on the questions this assistant actually gets. Someone asking "what did he use for rate limiting?" wants a specific term, and embeddings are lossy about precisely those rare tokens: proper nouns, library names, error codes.

So retrieval runs two rankings and combines them:

  • BM25 over tokenised chunks, which is very hard to beat on exact terms and needs no model call.
  • Dense cosine similarity over Voyage embeddings, which handles paraphrase and questions that share no vocabulary with the source text.

They are combined with reciprocal rank fusion rather than a weighted sum of scores. Summing scores would mean calibrating the two systems against each other, and BM25 scores and cosine similarities have no shared scale to calibrate on. RRF only looks at rank position, which avoids the problem and means I do not have to retune weights every time the corpus changes.

Why generation runs on a chain rather than one model

The first version of this called Claude directly, and the write-up said so. What changed is not a quality judgement — it is that the retrieval half is the part worth showing, and the retrieval half costs nothing to run. Paying per token so a stranger can watch a demo work was the only standing cost left in the system, and it was attached to the least interesting component.

So generation now tries providers in order and takes the first that answers: a free tier by default, a second free tier behind it, and metered providers last, where they only bill if both free tiers are down. The switch is reported in the trace rather than hidden, because a demo that quietly degrades is making a claim it has not earned.

Two things this cost me, both worth stating. Open-weight models hold citation discipline less reliably — one would not stop emitting 【2†L4-L9】 no matter how the prompt was worded, so the stream is normalised in code as well as asked for in the prompt. And ranking providers by cost rather than quality means the default answer is not the best answer available; that is the trade I chose, knowing the passages under it are identical either way.

What I would do differently at 100× the corpus

Move the index to pgvector, keep the hybrid strategy, and feed fusion from an ANN recall set instead of a brute-force scan. The retrieval interface is already shaped for that swap, which is why I drew the boundary where I did.

One caveat worth stating: I have not load-tested this past a single user, since a single user is the real workload. So the latency figures below tell you the pipeline is fast, not that it holds up under concurrency.

ADR-001Status — accepted