Piper Sona

Essays, repertoires, and other things I shouldn't be posting.

Why We Built a Search Engine Instead of a Brain

Every few months someone asks how I let an AI agent answer questions about my own documents — the contracts, the specs, the PDFs I've hoarded for a decade. And every few months the answer they *expect* is the same: "you must have a RAG pipeline." Embeddings, a vector database, chunking strategy, similarity search, reranker. The whole ritual.

I don't have any of that. I have a search engine. Here's why that was the right call, and why the default answer is wrong for this class of problem.

The agent is the reader

RAG exists to solve a specific problem: the model has a fixed, small context, and you have to pre-select the few paragraphs most likely to matter. You chunk, you embed, you retrieve the top-k, you pray the answer was in those chunks and not in the one that scored 0.003 lower.

But the architecture I care about is different. The agent has a large context. It just doesn't have all of the context. So the job of retrieval isn't "guess the answer." It's something closer to a library: triage first, read second. Search returns a ranked list of sections with short snippets — enough for the agent to decide what's worth opening. Then it fetches the exact section, or the whole document, and reads it the way I would.

Once you accept that, the entire RAG stack stops being a feature and starts being a tax.

What it actually looks like

The whole system is three small pieces and one set of rules.

A CLI indexer. Point it at a directory of files — PDFs, Word docs, spreadsheets, Markdown, plain text. For each file it extracts the text, splits it on the document's own header structure, and loads it into two tables: documents (one row per file, with a content hash) and sections (one row per header-delimited section, with its heading). Both tables carry a full-text tsvector, maintained by triggers, backed by a GIN index. A local model also tags and summarizes each document at ingest time — and that step is deliberately fail-soft: if the model is down or returns garbage, the document still gets indexed, just without tags. The index is never hostage to the model.

The database. A boring local Postgres. Queries go through websearch_to_tsquery, which means plain words, "exact phrases", and AND/OR/NOT all just work. Snippets come from ts_headline() — the search engine does the highlighting for you, at zero cost, because it already knows where the matches are. No reranker needed. No second database to babysit.

A small MCP server. Four tools and a stats call: search returns ranked sections with headings, tags, summaries, scores, and snippets; get_section returns the full text of one section; get_document returns the whole document, or just its metadata and heading tree; list_documents and corpus_stats handle discovery. That's the entire API surface. An agent doesn't need fifty endpoints; it needs a good table of contents and a way to open a page.

The rules. A written skill file tells the agent how to behave: search first, read the snippets, fetch only the sections that earn it, and cite the section you're quoting. The retrieval is cheap and lossy on purpose; the reading is where the intelligence lives.

Extract, structure, index, retrieve, read. Five steps, all local, all inspectable. The entire "AI" in the pipeline is a tagger at ingest time and a model with a big context window reading the exact sections at query time. Everything else is plumbing — and it's plumbing you can query yourself, drop from, and rebuild, without retraining anything.

Now the parts that deserve defending — the choices that look wrong until you see what they're actually buying you.

Why not chunk it?

Chunking is the most honest failure point. A 500-token slice doesn't understand that the sentence at position 497 depends on the one at position 1. But documents already have structure — Word headings, Markdown levels, PDF font-size shifts, Excel sheets. The document's own headers are the chunk boundaries the author actually chose. Split on those and every "chunk" is a coherent unit of meaning. No magic number, no overlap parameter, no orphaned half-sentences.

The unit of retrieval becomes a section, not a fragment. And a section is something a human can point at and verify, which matters a lot when the agent has to cite sources.

Why full-text search won

Vector search answers the question "what is similar to this." Full-text search answers "what mentions this." For a document store, the second question is almost always the one you have.

You don't semantically fuzzy-match a contract clause. You don't cosine-search an Excel figure. You want exact terms, ranked, fast, and explainable. Postgres gives you that natively: a GIN-indexed tsvector per section, maintained by triggers, with ts_headline() producing the snippets for free. No embedding model to keep warm, no vector dimension to tune, no "why did it rank that one?" black box. If a word isn't in the document, it doesn't match — and for documents, that's a feature, not a bug. Similarity search happily hands you a paragraph about revenue when you asked for the renewal clause, and calls it close enough.

Also: it's boring. Postgres is a solved, boring, local database. Boring wins. I have made a career of learning that glamorous technology is a liability the moment it needs to be maintained at 2am.

What about just stuffing the context window?

"Context windows are huge now" is a real argument, and a tempting one. But a context window is a working memory, not a library. Three problems: cost and latency scale with everything you stuff in; attention degrades as the window fills — the middle of a 200k-token context is a graveyard; and a model reading five thousand pages doesn't find the clause any better than you do — it just reads all of it, slowly, expensively, and still needs you to tell it what to look for.

Retrieval and context are different jobs. Search gets you to the page. The context window is where you read it. Do both jobs and you get an agent that opens exactly the right section, every time, and wastes none of its attention on the rest.

What this gets you

A system you can inspect. Every answer maps to a section in a table. You can query the table yourself, drop a stale document, watch the index update. The "why did it answer that" question has an SQL answer instead of an embedding-space shrug.

The takeaway isn't "RAG is bad." It's: identify the job. If your user can't articulate the query in words, embeddings are your only bridge — go semantic. But if your user has documents, knows their terminology, and wants citations, you have a search problem. And search problems have had a good answer since the 1970s. Postgres will run it locally, quietly, and it will outlive every vector database I've watched die.

Sometimes the most modern move is to use the boring tool, correctly, and skip the ritual entirely.

— P

← All posts