Vibe Engines is free, interactive AI engineering & system design — runs in your browser. Pick a learning path →
Top-K Retrieval
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.)
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.
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.
Examples and constraints 4 examples
query = [1, 0]
docs = [[1, 0], [0, 1], [1, 1]]
k = 2[0, 2]query = [1, 0], same docs, k = 1[0]query = [1, 0]
docs = [[0.6, 0.8], [2, 0], [1, 1], [-1, 0], [1, 0]]
k = 3[1, 4, 2]query = [1, 0]
docs = [[0, 1], [1, 0]]
k = 5[1, 0]- 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
- Score every document: build a list of
(similarity, index)pairs. - Sort by similarity descending. For ties, the smaller index should come first.
- A single sort key of
(-similarity, index)gives exactly that ordering. - Take the first
kindices — 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].
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.
| step | action | result |
|---|---|---|
| 1 | score doc 0, [1, 0] | dot 1 ÷ (1 × 1) = 1.0 |
| 2 | score doc 1, [0, 1] | dot 0 ÷ (1 × 1) = 0.0 |
| 3 | score doc 2, [1, 1] | dot 1 ÷ (1 × √2) = 0.7071 |
| 4 | sort by (-sim, index) | [(1.0, 0), (0.7071, 2), (0.0, 1)] |
| 5 | keep 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. kbeyond the list:top_k([1, 0], [[0, 1], [1, 0]], 5)returns[1, 0], because slicing past the end is safe, andk = 0returns[].- A negative
kis not rejected:scored[:-1]andslice(0, -1)both mean “all but the last”, sok = -1on the first example’s three docs silently returns[0, 2]. Validatek ≥ 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_similarityraisesZeroDivisionError. TypeScript getsNaN, and sinceNaN || (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 withreverse=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.
Explore the topic
See this alongside everything else on the same subject — handbooks, system designs, challenges and tools, in one place.
More Challenges
- Two SumThe classic warm-up: find the two numbers that add up to a target. Brute force is O(n²) — a hash map gets you to one pass, O(n). Solve it in Python or TypeScript, right in your browser, with hidden tests and a reveal-solution button.Read →
- Valid ParenthesesThe canonical stack problem: decide whether every bracket is closed by the right type, in the right order. A stack turns nested matching into a single pass. Solve it in Python or TypeScript with hidden tests.Read →
- Fizz BuzzThe famous screening question. Print 1…n, but multiples of 3 become "Fizz", of 5 become "Buzz", and of both become "FizzBuzz". Easy — the catch is testing divisibility in the right order. Solve it in Python or TypeScript.Read →
- SoftmaxThe function at the end of every classifier and language model: turn raw scores (logits) into a probability distribution. Implement the numerically stable version so big logits do not overflow. Solve it in Python or TypeScript.Read →
- Cosine SimilarityThe measure behind every embedding search and RAG system: how aligned are two vectors, ignoring their length? Dot product over the product of magnitudes — 1 identical, 0 orthogonal, -1 opposite. Solve it in Python or TypeScript.Read →
- Token-Level F1The metric behind QA evaluation (SQuAD and friends): how well does a predicted answer overlap a reference as a bag of words? Compute token precision and recall, then their harmonic-mean F1. Solve it in Python or TypeScript.Read →