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
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.
What was on the table
Each option carries the one sentence that justifies its verdict.
- 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.
- 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.
- 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.
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.
| Criterion | Managed vector DB (Pinecone / Weaviate) | Postgres + pgvector | Build-time embeddings, in-process hybrid indexChosen |
|---|---|---|---|
| Query latency (retrieval only)In-process scoring has no network hop; serverless Postgres pays a cold start. | — | Weak | Strong |
| Cost at ~100 queries/monthBoth hosted options carry a monthly floor; the static index carries none. | — | Weak | Strong |
| Scales past a few thousand chunksThis is the axis the chosen option deliberately gives up. | — | Strong | Weak |
| Operational surface | — | Weak | Strong |
| Availability independence | — | Adequate | Strong |
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
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.
How it fits together
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.
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
- 0.06 ms
- Retrieval latency (p50)
- 12 / 12
- Recall@6
- $0.00
- Standing monthly cost
Output of `npm run ingest` over 6 ADRs, 1 playbook and 4 posts; ~20,048 tokens, mean 167 per chunk.
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.
`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.
No provisioned datastore. Embedding spend occurs at build time only; generation is per-query.
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 float32Under 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.
Why hybrid rather than pure vector search
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.