Vibe Engines
YouTube

Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path

CODING CHALLENGE · N°06

Top-K Retrieval

Medium AI EngineeringRAGRetrievalEmbeddings

The core of the "R" in RAG: given a query embedding and a set of document embeddings, return the indices of the k most similar documents. Score every doc by cosine similarity, then take the top k — with a stable tie-break so results are deterministic.

The problem

Given a query vector, a list of docs vectors (all the same length as the query), and an integer k, return the indices of the k documents most similar to the query by cosine similarity, ordered from most to least similar. Break ties by smaller index first. (cosine_similarity is already provided.)

SOLVE IT YOURSELF

Your turn — write it

Edit the stub, hit Run (or ⌘/Ctrl + Enter), and watch the hidden tests. Stuck? open the hints under the problem, or Reveal solution is one click away.

YOUR TASK

Implement top_k(query, docs, k): score each doc against the query with the provided cosine_similarity, then return the indices of the top k, most-similar first, ties broken by smaller index.

CPython · WebAssembly
Examples and constraints 4 examples
EXAMPLE 1
Input query = [1, 0] docs = [[1, 0], [0, 1], [1, 1]] k = 2
Output [0, 2]
doc0 is identical (1.0), doc2 is 0.707, doc1 is 0
EXAMPLE 2
Input query = [1, 0], same docs, k = 1
Output [0]
just the best match
EXAMPLE 3
Input query = [1, 0] docs = [[0.6, 0.8], [2, 0], [1, 1], [-1, 0], [1, 0]] k = 3
Output [1, 4, 2]
docs 1 and 4 tie at 1.0 because cosine ignores length, so the smaller index wins; the opposite vector, doc 3, scores −1
EXAMPLE 4
Input query = [1, 0] docs = [[0, 1], [1, 0]] k = 5
Output [1, 0]
edge case: k ≥ len(docs) returns every index, ranked, never more than exists
CONSTRAINTS
  • 0 ≤ k; if k exceeds the number of docs, return all of them ranked.
  • Deterministic: ties broken by ascending index.
  • Reuse the provided cosine_similarity — do not re-derive it.
Hints 4 ideas
HINTS — 4 IDEAS
  1. Score every document: build a list of (similarity, index) pairs.
  2. Sort by similarity descending. For ties, the smaller index should come first.
  3. A single sort key of (-similarity, index) gives exactly that ordering.
  4. Take the first k indices — slicing past the end is fine, it just returns what exists.
Approach, complexity & discussion — open after you solve

Start from brute force

The obvious approach, and the one the reference takes, scores all n documents, sorts all n by similarity, and keeps the first k. It is correct: a sort key of (-similarity, index) gives the required order, ties included, and slicing past the end is safe. It costs O(n·d + n log n) time for d-dimensional vectors and O(n) space for the scored list. The sort orders n − k documents that are immediately thrown away, which matters when k is 5 and n is in the millions. Even then, the larger cost is the O(n·d) scoring pass, which only an index can avoid.

The approach

Score once, then select without a full sort. Keep a min-heap of size k ordered worst-first: with Python’s heapq, push (similarity, -index), so the root has the lowest similarity and, among equals, the larger index; whenever the heap exceeds k entries, pop the root. After the scan the heap holds exactly the top k, and sorting those k gives the output order. heapq.nsmallest(k, ...) on (-similarity, index) pairs does the same and is documented as equivalent to sorted(...)[:k].

The reference keeps the full sort for clarity. It builds scored as (cosine_similarity(query, d), i) tuples, sorts with key=lambda t: (-t[0], t[1]), which ranks by similarity descending and then index ascending, and returns the indices from scored[:k]. Cosine ignores vector length, so [2, 0] and [1, 0] score identically against [1, 0].

1. score every doc against q = [1, 0]d0 [0.6,0.8]0.60d1 [2,0]1.00d2 [1,1]0.71d3 [-1,0]-1.00d4 [1,0]1.002. sort by (-cos, index)d1 1.00d4 1.00d2 0.71d0 0.60d3 -1.003. keep k = 3 -> [1, 4, 2]droppedtie at 1.00: d1 ranks before d4 because index 1 < 4
The third example, k = 3. Length does not move a cosine score, so [2, 0] and [1, 0] tie at exactly 1.0 and the index decides the order; the opposite vector [-1, 0] still ranks, just last.

Walk through an example

On the first example, [1, 1] is 45 degrees from the query and scores √2/2, enough to beat the orthogonal [0, 1] for the second slot.

stepactionresult
1score doc 0, [1, 0]dot 1 ÷ (1 × 1) = 1.0
2score doc 1, [0, 1]dot 0 ÷ (1 × 1) = 0.0
3score doc 2, [1, 1]dot 1 ÷ (1 × √2) = 0.7071
4sort by (-sim, index)[(1.0, 0), (0.7071, 2), (0.0, 1)]
5keep the first k = 2 indices[0, 2]

Complexity

Scoring is O(n·d) for n documents of dimension d. The reference’s full sort adds O(n log n) time and keeps O(n) space for scored; a size-k heap makes selection O(n log k) with O(k) extra space, and quickselect is O(n) on average. At millions of vectors the O(n·d) scan dominates, which an approximate index such as HNSW avoids.

Edge cases to test

  • Ties break by index, and cosine ignores length: with q = [1, 0], the docs [2, 0] at index 1 and [1, 0] at index 4 both score exactly 1.0, so index 1 comes first.
  • k beyond the list: top_k([1, 0], [[0, 1], [1, 0]], 5) returns [1, 0], because slicing past the end is safe, and k = 0 returns [].
  • A negative k is not rejected: scored[:-1] and slice(0, -1) both mean “all but the last”, so k = -1 on the first example’s three docs silently returns [0, 2]. Validate k ≥ 0.
  • Opposite vectors score −1 and still rank, last: top_k([1, 0], [[-1, 0], [0, 1]], 2) returns [1, 0]. Add a similarity threshold if weak or negative matches should not be returned at all.
  • A zero vector has no direction. Python’s cosine_similarity raises ZeroDivisionError. TypeScript gets NaN, and since NaN || (a.i - b.i) falls through to the index comparison, a zero vector at index 0 sorts ahead of a perfect match: topK([1,0], [[0,0],[1,0]], 1) returns [0]. Reject zero vectors at ingestion.

Common mistakes

  • Sorting all n scores at scale when only k are needed: fine for a few hundred documents, wasteful for millions.
  • Using a max-heap of all n elements instead of a bounded min-heap of k.
  • Mishandling ties or fewer than k documents.
  • Sorting the (similarity, index) tuples with reverse=True. That reverses the tie-break too, so among equal scores the larger index comes first.

Follow-ups an interviewer asks next

The corpus holds 10 million vectors. How do you serve queries fast?
Use an approximate nearest-neighbor index, such as HNSW or IVF with product quantization, which examines a small fraction of the vectors per query and still returns most of the true top k. Measure recall@k against this exact brute force on sample queries. Normalize vectors at ingestion so cosine becomes a dot product, which vector indexes compute natively.
How do you combine this with keyword search?
Run BM25 alongside it and merge the two ranked lists, for example with Reciprocal Rank Fusion, which gives each document the sum of 1 / (60 + rank) across the lists. Keyword search catches exact identifiers and rare names that embeddings blur, and rank fusion needs no calibration between the two scoring scales.
The top k are relevant but redundant. What next?
Rerank the candidates. A cross-encoder rescoring the top 50 to 100 improves precision, and Maximal Marginal Relevance trades a little relevance for diversity so near-duplicate chunks do not fill the prompt. Both run on a short list, so their extra cost per query stays small.
Can you avoid recomputing norms on every query?
Yes. Store each document vector already divided by its norm. Cosine with a normalized query is then a plain dot product, and scoring every document is one matrix-vector multiply. It also takes the zero-vector division out of the query path, since zero vectors are rejected once, at ingestion.

Where this shows up

This is the “R” in RAG: rank documents by embedding similarity and take the top k to put in the prompt. Retrieval quality caps answer quality, and at scale this exact top-k step is where an approximate-nearest-neighbor index (HNSW) replaces the brute-force scan to keep it fast.

The brute-force version stays useful even after an index arrives. It is the ground truth for measuring an approximate index: run both on a sample of real queries and report recall@k, the fraction of the exact top k that the index also returned. It is also the right production choice for small corpora, such as one user’s notes or one tenant’s documents, where scanning a few thousand normalized vectors is one matrix-vector multiply and an index would only add build time, memory and a recall loss. The same select-k pattern appears in recommendation candidate generation, in deduplication (finding near-identical chunks before indexing them) and in classic k-nearest-neighbor classification, which differ mainly in what the vectors encode and what happens to the k results.

Check yourself

What does top_k([0, 1], [[1, 0], [0, 1], [1, 1], [0, 3]], 3) return?

[1, 3, 2]. Docs 1 and 3 both point straight along the query and score 1.0, so they tie and the smaller index goes first; doc 2 scores √2/2 ≈ 0.707 and takes the third slot, while doc 0 is orthogonal at 0.

Someone sorts the (similarity, index) tuples with sorted(..., reverse=True). What do they get on the third example with k = 3?

[4, 1, 2] instead of [1, 4, 2]. Reversing the whole tuple order also reverses the tie-break, so among the two docs at 1.0 the larger index wins. Negate only the similarity, as the reference key (-sim, index) does.

You replace cosine with a raw dot product. For q = [1, 0] and docs = [[1, 0], [10, 10]], how does the ranking change?

Cosine ranks [0, 1] (1.0 against 0.707), but the dot product ranks [1, 0] (10 against 1), because a long vector wins on magnitude alone. The two rankings agree only once every vector is normalized to unit length.

With a size-k min-heap, what is the selection cost when k = 1, and when k = n?

O(n log k) in general, so k = 1 is O(n): a single running best. At k = n it is O(n log n), no better than the full sort, which is why the heap only pays when k is much smaller than n. The O(n·d) scoring pass is the same in every case.

Reference solution

Reference solution — Python
import math

def cosine_similarity(a, b):
    dot = sum(x * y for x, y in zip(a, b))
    na = math.sqrt(sum(x * x for x in a))
    nb = math.sqrt(sum(y * y for y in b))
    return dot / (na * nb)


def top_k(query, docs, k):
    # score every doc, then rank by similarity (desc), tie-break by index (asc)
    scored = [(cosine_similarity(query, d), i) for i, d in enumerate(docs)]
    scored.sort(key=lambda t: (-t[0], t[1]))
    return [i for _, i in scored[:k]]


print(top_k([1, 0], [[1, 0], [0, 1], [1, 1]], 2))   # -> [0, 2]
Reference solution — TypeScript
function cosineSimilarity(a: number[], b: number[]): number {
  let dot = 0, na = 0, nb = 0;
  for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
  return dot / (Math.sqrt(na) * Math.sqrt(nb));
}

function topK(query: number[], docs: number[][], k: number): number[] {
  // score every doc, then rank by similarity (desc), tie-break by index (asc)
  const scored = docs.map((d, i) => ({ s: cosineSimilarity(query, d), i }));
  scored.sort((a, b) => (b.s - a.s) || (a.i - b.i));
  return scored.slice(0, k).map((x) => x.i);
}

console.log(topK([1, 0], [[1, 0], [0, 1], [1, 1]], 2));   // -> [0, 2]

Questions about Top-K Retrieval

Why use cosine similarity for embeddings instead of Euclidean distance?

Cosine similarity compares direction and ignores length, and many embedding models encode meaning mostly in direction, so two texts on the same topic point the same way even when their vectors differ in magnitude. In the examples, the vectors 2, 0 and 1, 0 score exactly the same against the query 1, 0. Once vectors are normalized to unit length the two measures rank identically, because the squared Euclidean distance equals 2 minus 2 times the cosine. Check which measure your embedding model was trained for, and normalize once at ingestion if you use cosine.

How do you get the top k results without sorting everything?

Keep a min-heap of size k while scanning the scores. Its root is the weakest of the current top k; a new document replaces the root only when it scores higher, and each replacement costs O(log k). After the scan the heap holds the top k, which you sort for the final order. That makes selection O(n log k) instead of O(n log n), and quickselect gives O(n) on average. For a few thousand documents the difference is small, which is why the reference sorts for clarity, but at millions of documents it matters.

Why does top-k retrieval need a tie-break rule?

Because exact ties happen: duplicate chunks, documents whose vectors differ only in length, and quantized embeddings can all produce identical scores. Without a rule, tied documents come back in whatever order the sort leaves them, which can change between libraries or after a reindex, so the same query returns different context and tests become flaky. Breaking ties by the smaller index makes the output a function of the input alone. The reference does it by sorting on the pair of negated similarity and index, ranking by similarity first and index second.

What value of k should a RAG system retrieve?

There is no universal value: it is a trade-off between recall and noise. A small k risks missing the passage that holds the answer, and a large k spends tokens and can bury the relevant passage among weaker ones. A common pattern is to retrieve a larger candidate set, rerank it with a stronger model, and pass only the best few chunks into the prompt. Choose the numbers by measuring answer quality on a set of real questions rather than by guessing.

Finished this one? 0 / 76 Challenges done
NextMMR Reranking Next on the “RAG & retrieval” ladder · rung 6 of 7

Explore the topic

See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.