← all posts
2026 · 07 / systems

Is your knowledge base actually retrievable?

A knowledge base you can't search is a diary. I built an eval harness that scores recall@k and MRR against my real search engine, and the baseline humbled me.

3 min read·12 July 2026

I’ve written notes for years. I assumed that made me organized. Then I noticed I kept re-deriving things I had already written down, because I couldn’t find them. A knowledge base you can’t retrieve from isn’t a second brain. It’s a diary you never reread.

The uncomfortable part is that “I can’t find it” felt like a vibe, not a number. So I made it a number.

Search quality is a thing you can measure

The trick is to stop testing search by feel. Pick a note. Ask a question that note answers. See where the note lands in the results. Do that across many notes and you get two honest metrics.

Recall@k asks: is the right note in the top k? MRR — mean reciprocal rank — asks how high up it sits, so being #1 counts for a lot more than being #6.

The catch with hand-written questions is that you cheat without meaning to. You echo the note’s title in the query, and title-weighted search hands it back to you. So I wrote each question to deliberately avoid the note’s title words. If a note’s title is about wiring, the question asks about the 404 it fixes, never the word “wiring.”

def score(cases, engine, ks=(1, 3, 5, 10)):
    recall = {k: 0 for k in ks}
    rr_total = 0.0
    for q in cases:
        ranked = engine.search(q.text)          # note ids, best first
        gold_pos = index_of(ranked, q.gold_id)  # 0-based, or None
        if gold_pos is None:
            continue
        rr_total += 1 / (gold_pos + 1)
        for k in ks:
            if gold_pos < k:
                recall[k] += 1
    n = len(cases)
    return {f"recall@{k}": recall[k] / n for k in ks} | {"mrr": rr_total / n}

That’s the whole idea. The engine under test was my actual one: lexical, term-frequency, title-weighted, no semantic fusion. The same thing that answers every real query.

The humbling baseline

Fourteen cases. Small, so I treat it as directional, not precise. Here is what fell out.

MetricValue
recall@10.357
recall@30.571
recall@50.714
recall@101.000
MRR0.531

Read the top and bottom rows together. Recall@10 is a perfect 1.0 — search never truly loses a note. But recall@1 is 0.357. The right note ranked first only about a third of the time.

My search was great at “you’ll find it eventually” and bad at “here’s the one you want.”

Eventually is not good enough. When I ask a question I open the top result, maybe the second. Nobody scrolls to #7 in their own notes.

Why the right note loses

The failure pattern was consistent enough to name: short canonical notes lose to long, chatty ones. Pure term frequency with no length normalization rewards word count.

The clearest example: a short note holding a person’s core facts got buried at rank #7, beaten by a sprawling backend note that happened to mention the relevant term more times. A tight one-liner note lost to a long dev log. A canonical principles note lost to a file that duplicated those same principles verbatim but padded them out. Six of fourteen cases were buried below #3, almost always by something longer.

Title-weighting only saved the day when my question happened to reuse a distinctive title word. Which is exactly the cheat I had tried to design out.

Now it’s a dial, not a shrug

The point of a baseline isn’t the baseline. It’s that every future change now has to beat it on the same fourteen cases. No guessing.

My ranked hypotheses, cheapest first: type-boost — weight people and canonical knowledge notes above dev logs and index pages, which should fix most of the buries. Then length-normalize the term frequency, BM25-style, so long notes stop winning on raw counts. Then down-weight the table-of-contents notes that mention everything. Semantic search comes last, because it’s the biggest change and I want a clean before-number to measure it against.

Retrieval quality used to be a feeling. Now it’s 0.357, and 0.357 is a thing I can move.

← previousMoving shadcn from Radix to Base UI next →Generating OG images at build time with Astro