Engineering · Aug 18, 2026 · 7 min read

Hybrid search, with half the pipeline deliberately empty

Cross-project search shipped as a six-stage hybrid pipeline in which semantic retrieval and reranking are real classes that return nothing — on purpose. Why the empty boxes are the design, and the scoring bug a borrowed constant smuggled in.

Until this week, LLMBrain could only find something if you already knew which project and which doc it lived in. That's fine for a filing cabinet and useless for a brain — the whole point of cross-project memory is answering "how did we solve this before?" when before was a different repo. So search now spans every project the caller can reach: docs, issues, decisions, one query.

The interesting part is what shipped alongside the working part: half a pipeline that does nothing, on purpose.

Real classes that return nothing

The plan was always hybrid search — lexical retrieval for exact words, semantic retrieval for meaning, a reranker to sort out the merge. The classic mistake is to ship lexical now and bolt the rest on later, discovering at bolt-on time that the service signature, the route, the MCP tool and the CLI all assumed one retriever.

So the pipeline shipped whole on day one:

query → [lexical ⨯ semantic] → RRF fusion → rerank → blend → hydrate

Semantic retrieval and reranking are real classes implementing real protocols — they just return nothing, and report why (reason="no embedding backend configured"). They are called on every single search. When pgvector lands, turning semantic retrieval on is a new implementation of an existing protocol, not a change to anything that calls it.

There's a second payoff hidden in that: with one live retriever, RRF fusion is monotone in the lexical ranking and blend is the identity function — so today's results are exactly a lexical search, while every stage of the hybrid machinery is exercised by every query and every test. Nothing will be run for the first time on the day it matters.

"Found nothing" must never look like "switched off"

Every stage emits a StageReport: was it active, why not, how many candidates in and out, how long it took. This came from a debugging premonition rather than a debugging session — a search that returns nothing because the corpus has no match, and a search that returns nothing because a stage silently no-oped, are indistinguishable from the outside and worlds apart in what you should do next.

"Found nothing" and "switched off" must never look the same.

Fuse positions, never scores

Lexical retrieval scores with Postgres ts_rank; a future semantic retriever scores with cosine similarity. Those numbers share no scale — normalizing one against the other is a guess wearing a lab coat. So fusion uses Reciprocal Rank Fusion, which only looks at positions: a document at rank 3 in one list and rank 5 in another gets 1/(60+3) + 1/(60+5), and the raw scores never meet.

The one real bug in this slice came from borrowing a constant without re-deriving it. A prior art system awards a flat +0.05 bonus to the top result of each retriever — a sensible nudge in its scoring regime. But at k=60, an entire RRF term is worth about 0.016. Imported as-is, the bonus meant "ranked first by one retriever" crushed "found by every retriever," which is precisely backwards. A test caught it; the bonus is now 0.5 / (k + 1) — proportional to the currency it's paid in.

Access control flows in, not around

Search spans every project the caller can reach — which makes scoping a security boundary, not a feature. The accessible-project set is computed once, by the same code that gates every other project read, and passed into each retriever as an input. A future retriever physically cannot forget to filter, because it never sees unfiltered data. Bolting a WHERE clause on at the end would have worked too — until the first retriever that someone writes without it.

Two smaller decisions, recorded so they read as decisions and not accidents. Snippets are highlighted with « » instead of markdown bold, because the corpus is markdown — ** was already in the text. And there's no query-expansion stage at all: the caller is an agent that can write a precise query and rephrase on a miss. Machinery for vague queries serves users this system doesn't have.

Next in this slice: filling the empty boxes — pgvector and chunked embeddings for the semantic retriever, then LLM reranking. The pipeline won't change shape when they land. That was the point.

SearchPostgresRRFArchitecture