01_RECAP
In the series index I laid out why my hackbot needs a vuln bank — a durable, searchable memory of how to find and exploit bug classes — and the tradeoffs I’d already committed to. This post is the architecture: the actual stack, and the shape the code takes.
The headline idea, before any of the tooling: this is really two applications wearing one repo. Get that separation right and everything else is detail.
02_THE_STACK
Here’s what I’m building on and why each earned its place:
- Postgres + pgvector — the system of record. Typed records, full-text search, and vector search in one datastore. At sub-million scale I don’t need a dedicated vector DB, and one store means one source of truth.
- Effect — the standard library. The whole system is a pipeline of fallible steps: read a writeup, validate it, embed it, insert it; embed a query, search, rerank, return. Effect models each step as a composable value with typed errors, gives me dependency injection (swap the embedding model or the DB without touching call sites), structured concurrency (run the vector and keyword legs in parallel, safely), and Schema for validating both ingested records and MCP tool inputs. For a system that is essentially “typed pipelines with lots of failure modes,” it’s the right spine.
- Hono — the web server. Small, fast, runs anywhere. It’s the HTTP substrate that lets the knowledge service be reached over plain HTTP and carries MCP when I want a networked transport instead of stdio. Crucially, Hono is what makes the RAG usable without an agent at all — more on that in a second.
bge-m3, run locally — the embedding model. Local because queries carry live target context, and that never leaves the box. It emits dense and sparse vectors from a single model, which feeds both legs of the hybrid search below — no second model to keep version-pinned in lockstep.- 1024 dimensions, no reduction.
bge-m3’s dense vectors are natively 1024-dim, which lands comfortably under pgvector’s 2000-dimension index ceiling. So I index with the plainvectortype and skip the truncation dance a 3072-dim model (liketext-embedding-3-large) would force just to be indexable. That 1024 is now part of the embedding’s fixed identity: same model and the same dimension at both ingest and query, always. - Hybrid search + a reranker — dense vectors for meaning, sparse/keyword for the exact tokens security is full of, a cross-encoder to sharpen the top few.
03_TWO_SEPARABLE_PIECES
This is the part worth slowing down on.
When people hear “RAG system with an MCP tool,” they picture one indivisible thing. It isn’t. There are two independent concerns here, and they meet at a clean boundary:
Piece one — the RAG system. A self-contained knowledge service: ingest, index, retrieve, over Postgres. It knows nothing about who calls it or how. Hand it a query, it hands back ranked chunks. That’s the entire contract.
Piece two — the MCP interface. A protocol adapter that lets an AI agent use the service. The MCP server is a thin translation layer — “agent tool call” in, “RAG service call” out. The MCP client lives inside the agent (OpenCode, in my case) and does the calling. MCP is about who consumes and how — not about what the knowledge is.
These are orthogonal, and each can exist without the other:
- RAG without MCP — expose it over HTTP with Hono for a dashboard, import it as a library from a batch script, or drive it from a CLI. Still completely useful.
- MCP without this RAG — an MCP server can wrap a filesystem, a REST API, anything. MCP implies nothing about vector search.
They’re combined in this project for one reason only: my consumer happens to be an AI agent, and MCP is the clean way to let an agent call tools. That’s a composition choice, not an inherent coupling.
The RAG system is the engine. MCP is one doorway into it.
Build the engine so it doesn’t know which doorway it’s behind — then you can add another door without touching the engine.
Why does this matter beyond tidiness? Because keeping the seam sharp buys real things:
- I can test the RAG core with zero agent in the loop.
- I can swap or add interfaces — a REST frontend, a different agent protocol — without reopening retrieval.
- I can reuse the same core from the ingestion “librarian” path and the agent’s query path.
- The MCP layer stays thin and dumb — just schema and translation — which is exactly what you want sitting at a trust boundary.
04_HIGH_LEVEL_ARCHITECTURE
Concretely, the app is the two ingest/retrieve paths from the index, layered so the core is unaware of its callers:
1 ┌──────────────┐ ┌──────────────┐2 │ MCP server │ │ Hono HTTP │ ← interfaces (thin, swappable)3 └──────┬───────┘ └──────┬───────┘4 └────────┬─────────┘5 ┌───────────────┐6 │ RAG core │ ← Effect services: ingest, retrieve7 │ (Effect) │ typed errors, DI, schema8 └───────┬───────┘9 ┌───────────────┐10 │ Postgres │ ← records + FTS + pgvector11 └───────────────┘The seam is a plain service interface. The core exposes it; the interfaces are two thin call sites over the same thing:
1// The RAG core knows nothing about HTTP or MCP.2interface Retriever {3 readonly retrieve: (q: Query) => Effect.Effect<Chunk[], RetrievalError>4}5 6// Hono exposes it over HTTP...7app.post('/retrieve', (c) => runRetrieve(c.req))8 9// ...and the MCP server exposes the exact same core as a tool.10server.tool('vulnbank_retrieve', RetrieveSchema, (args) => runRetrieve(args))Both doorways call runRetrieve. Neither one is the retrieval — they translate a request into the core’s language and translate the result back. Swap the model behind Retriever, and both doors get the upgrade for free. Add a third door, and the core never notices.
05_NEXT
With the stack settled and the seam drawn, the next post goes inside the engine: The Vuln RAG System — ingestion, embeddings, hybrid search, and reranking, with the security-specific gotchas that make this different from a generic docs-RAG.
The engine first. The doorways can wait — that’s the whole point of keeping them apart.