ASK KNOX
beta
LESSON 580

Retrieval Quality: Top-K Is Not a Strategy

Returning the top-K chunks by cosine similarity is a starting point, not an answer — learn how thresholds, reranking, and query rewriting turn approximate recall into precise, trustworthy context.

8 min read·RAG in Production

Why Top-K Retrieval Breaks Production Systems

The simplest RAG retrieval strategy is this: embed the query, run a cosine similarity search, return the top K chunks. Simple, fast, and the source of most retrieval failures in production.

Top-K is not a strategy. It is a starting point that has three problems. First, it returns exactly K results regardless of whether any of them are relevant — if your corpus does not contain an answer to the query, you get K irrelevant chunks that the model then hallucinates confidently from. Second, cosine similarity measures geometric distance in embedding space, not query relevance. Two texts that use similar vocabulary in different contexts score close together even when one is completely off-topic. Third, the top-5 you return to the model is not necessarily the 5 most relevant chunks — it is the 5 chunks whose embeddings happen to be nearest the query embedding, which is only a proxy for relevance.

The production answer is a funnel: approximate recall wide, then progressively narrow with precision gates.

The Similarity Threshold Gate

The first and most important gate is a similarity threshold. The principle is simple: if no retrieved chunk scores above a minimum similarity to the query, treat the query as a no-result rather than generating from thin air.

// lib/threshold-gate.ts
// pgvector returns cosine DISTANCE — 0 = identical, 2 = opposite
// Similarity = 1 - distance
// Keep only chunks where similarity >= threshold

const SIMILARITY_THRESHOLD = 0.72

async function retrieveWithThreshold(queryVec: number[], k: number) {
  const result = await pool.query(
    `SELECT chunk_id, source_id, content,
            (embedding <=> $1) AS distance
     FROM chunks
     ORDER BY embedding <=> $1
     LIMIT $2`,
    [JSON.stringify(queryVec), k * 4] // retrieve wider, filter down
  )

  const passing = result.rows.filter(
    (r) => 1 - parseFloat(r.distance) >= SIMILARITY_THRESHOLD
  )

  if (passing.length === 0) {
    return { chunks: [], refused: true }
  }

  return { chunks: passing.slice(0, k), refused: false }
}

What threshold to use? There is no universal value — it depends on your corpus density, your embedding model, and what "similar enough" means for your domain. But the approach is calibratable: run a small golden set of queries with known-good answers and known-miss queries, and tune the threshold to maximize the gap between the two groups. The RAG-evaluation lesson covers this evaluation discipline in detail.

The threshold gate is what feeds the citations lesson's refusal path. When no chunks pass, the correct answer is "I don't have that in my sources" — not a confident hallucination built from off-topic context.

Metadata Filters: Scope Before Similarity

Before running a similarity search across your entire index, scope it. Metadata filters applied as SQL WHERE clauses are evaluated by the database before the expensive vector comparison, which both improves relevance and reduces cost.

// Apply namespace, access tier, and date filters BEFORE similarity
const result = await pool.query(
  `SELECT chunk_id, source_id, content, (embedding <=> $1) AS distance
   FROM chunks
   WHERE namespace = $2
     AND tier <= $3
     AND updated_at >= $4
   ORDER BY embedding <=> $1
   LIMIT 20`,
  [JSON.stringify(queryVec), namespace, userTier, cutoffDate]
)

For a production knowledge assistant's RAG-backed queries, this means: before scoring any chunk for relevance, eliminate chunks from the wrong namespace and chunks the current user's access tier does not permit. This is not a post-hoc filter — it runs at the database level, which keeps both performance and access control sound. The RAG-operations lesson covers the ACL dimension of this pattern in detail.

Reranking: Converting Recall Into Precision

A similarity search retrieves on recall — it gets you candidates. Reranking converts those candidates into precision — it finds the ones that are actually relevant to the specific query. The approach: retrieve top-20 by similarity, then ask claude-haiku-4-5 to score each candidate for relevance, and keep only the top-5 reranked results.

The reranking implementation uses claude-haiku-4-5 because reranking is a classification task — score each candidate 0–10 for relevance — that does not require the full capability of the answering model. The haiku call costs a fraction of a sonnet call while producing reliable relevance scores:

// lib/rerank.ts
async function rerankCandidates(
  query: string,
  candidates: Chunk[],
  topK: number
): Promise<Chunk[]> {
  const candidateList = candidates
    .map((c, i) => `${i + 1}. ${c.content.slice(0, 200)}`)
    .join("\n")

  const response = await client.messages.create({
    model: "claude-haiku-4-5",
    max_tokens: 512,
    messages: [{
      role: "user",
      content: `Query: ${query}\n\nCandidates:\n${candidateList}\n\n` +
        `Return JSON: { "rankings": [{ "index": <1-based>, "relevance_score": <0-10> }] }`,
    }],
  })

  // Parse defensively — reranker failure falls back to threshold-passing order
  try {
    const text = response.content[0].type === "text" ? response.content[0].text : ""
    const match = text.match(/\{[\s\S]*\}/)
    if (!match) return candidates.slice(0, topK)

    const { rankings } = JSON.parse(match[0]) as {
      rankings: Array<{ index: number; relevance_score: number }>
    }

    return rankings
      .sort((a, b) => b.relevance_score - a.relevance_score)
      .slice(0, topK)
      .map((r) => candidates[r.index - 1])
      .filter(Boolean)
  } catch {
    return candidates.slice(0, topK) // graceful degradation
  }
}

The rerank step is a single bounded model call — no retries or loops to manage — and if it throws, the catch falls back to threshold order, so the pipeline degrades gracefully rather than failing the query.

Query Rewriting: Bridging Vocabulary Gaps

Users do not speak corpus vocabulary. Your corpus has "execution latency optimization strategies" — the user types "how do I fix the slow thing in my bot." The vector embedding of those two strings will be similar enough to retrieve something, but probably not the best chunks.

Query rewriting uses claude-haiku-4-5 to expand and normalize the user's query before embedding:

async function rewriteQuery(rawQuery: string): Promise<string> {
  const response = await client.messages.create({
    model: "claude-haiku-4-5",
    max_tokens: 128,
    messages: [{
      role: "user",
      content:
        "Rewrite this search query using precise technical vocabulary for a software engineering knowledge base. " +
        "Return only the rewritten query, no explanation.\n\nQuery: " + rawQuery,
    }],
  })

  return response.content[0].type === "text"
    ? response.content[0].text.trim()
    : rawQuery // fall back to original on failure
}

For compound questions — "what changed in the indexing pipeline and how does that affect query latency?" — multi-query retrieval decomposes the question into sub-queries, retrieves for each, and merges the result sets before reranking. This ensures both dimensions of the compound question get dedicated retrieval coverage.

Measuring Retrieval Quality

Every tuning decision — threshold, K, rerank model — needs a measurement layer. The minimum viable measurement is recall@K on a golden set: a collection of (query, expected-chunk-ids) pairs where you know which chunks are the correct answer.

// Recall@K: what fraction of expected chunks appear in the top-K results?
function recallAtK(expected: string[], retrieved: string[], k: number): number {
  const topK = new Set(retrieved.slice(0, k))
  const hits = expected.filter((id) => topK.has(id)).length
  return hits / expected.length
}

Run this before and after any retrieval change — threshold adjustment, K change, rerank model change, embedding model change. If recall@K drops, you have regressed retrieval quality. The RAG-evaluation lesson builds the full evaluation framework; this is the retrieval-specific slice.

This academy's AI tutor runs exactly this kind of measurement when retrieval configuration changes: a golden set of 30 representative questions, and recall@5 is the gate that must hold.

What's Next

The citations lesson builds on the retrieval pipeline you have now configured: given a set of high-quality, threshold-gated, reranked chunks, how do you assemble a prompt that produces cited answers and ensures citations are verified receipts — not hallucinated references? The answer lives in the prompt assembly order and in treating citations as a contract, not a nice-to-have.