Cosine Similarity Top-K Retrieval
Problem Statement
Given a query embedding and a gallery of candidate embeddings from a CLIP-style dual encoder, return the indices of the k most similar candidates and their cosine similarities.
Background
A dual encoder makes retrieval a nearest-neighbour lookup in a shared space. Similarity is cosine, not raw dot product, because the contrastive objective only ever constrained directions - vector magnitude carries no meaning and an unnormalised dot product would just rank long vectors first:
sim(q,gi)=∥q∥2∥gi∥2q⋅gi
Normalise both sides and the cosine is a plain dot product, which is why production retrieval stores pre-normalised vectors and uses an inner-product index.
Rank descending and take the first k. Ties must break toward the lower index - a stable sort of the negated scores does this for free.
Your Task
Implement:
def retrieve_topk(query, gallery, k):
Return [indices, sims] where indices is a list of k ints (best first) and sims is the matching list of cosine similarities, each rounded to 4 decimals.
Input Format
- query - list of d floats
- gallery - list of m lists of d floats
- k - integer with 1 <= k <= m
Output Format
[[int, ...], [float, ...]], both lists of length k.
Sample
q = [1.0, 0.0]
gal = [[0.0, 2.0], [3.0, 0.0], [1.0, 1.0]]
print(retrieve_topk(q, gal, 2))
Output:
[[1, 2], [1.0, 0.7071]]
Example:
q = [1.0, 0.0] gal = [[0.0, 2.0], [3.0, 0.0], [1.0, 1.0]] print(retrieve_topk(q, gal, 2))
[[1, 2], [1.0, 0.7071]]
Cosines against [1,0] are 0.0, 1.0 and 0.7071. Gallery item 1 points the same way as the query despite being three times longer - magnitude is normalised away - so it ranks first, then item 2 at 0.7071.
Constraints:
1 <= m <= 200,1 <= d <= 64; no vector is the zero vector- Rank by COSINE similarity - normalise, do not use the raw dot product
- Descending order; ties break toward the smaller index (use a stable sort)
- Round each similarity to 4 decimals and avoid emitting
-0.0 - Return plain Python ints and floats
1. Background Knowledge
Cosine Similarity measures the cosine of the angle between two non-zero vectors in an inner product space. It is a measurement of orientation and not magnitude, making it ideal for comparing high-dimensional embeddings where direction matters more than length. In Vision-Language Models (VLMs) like CLIP, text and image embeddings are projected into a shared latent space. The contrastive learning objective used to train these models aligns matching pairs by maximizing their cosine similarity, effectively normalizing the vectors during training or inference. Consequently, the magnitude of the embedding vectors is often arbitrary or normalized to unit length, rendering raw dot products unreliable if vectors are not pre-normalized.
The formula for cosine similarity between a query vector q and a gallery vector gi is:
sim(q,gi)=∥q∥2∥gi∥2q⋅giwhere ∥v∥2=∑j=1dvj2 is the L2 norm (Euclidean length). If both vectors are already unit-normalized (∥q∥2=1 and ∥gi∥2=1), the denominator becomes 1, and cosine similarity simplifies to the dot product q⋅gi. However, in general retrieval tasks, you must explicitly compute the norms to ensure correctness unless the problem guarantees pre-normalized inputs.
Top-K Retrieval is a fundamental operation in information retrieval and recommendation systems. It involves computing a relevance score for every candidate in a database (gallery) and returning the k candidates with the highest scores. This is essentially a nearest-neighbor search problem. When k is small relative to the gallery size m, efficient algorithms can avoid sorting the entire list, but for moderate m, a full sort or a partial sort is often sufficient and simpler to implement.
2. Algorithm Approach
The core approach is a brute-force nearest-neighbor search using cosine similarity. Since the gallery size m is typically manageable in "Easy" level problems, we can compute the similarity score for every candidate in the gallery against the query.
- Normalization: Compute the L2 norm of the query vector and each gallery vector.
- Scoring: For each gallery vector, compute the dot product with the query, then divide by the product of their norms to get the cosine similarity.
- Ranking: Store the similarity scores along with their original indices.
- Selection: Sort the candidates based on their similarity scores in descending order.
- Tie-Breaking: Ensure that if two candidates have the same similarity score, the one with the lower original index appears first. This is a stable sort requirement.
- Extraction: Take the top k entries from the sorted list and return their indices and scores.
3. Step-by-Step Strategy
- Compute Query Norm: Calculate the L2 norm of the query vector. Let this be nq.
- Initialize Results List: Create a list to store tuples of (similarity_score, original_index).
- Iterate Through Gallery:
- For each vector gi in gallery at index i:
- Compute the L2 norm of gi, let this be ni.
- Compute the dot product: dot=∑j=0d−1q[j]×gi[j].
- Compute cosine similarity: sim=nq×nidot.
- Append (sim, i) to the results list.
- Sort the Results:
- Sort the list of tuples.
- Primary key: Similarity score in descending order.
- Secondary key: Original index in ascending order (to handle ties correctly).
- In Python, you can achieve this by sorting with a key that negates the score for descending order, or by using reverse=True and ensuring the sort is stable (Python's sort is stable). If using reverse=True, you should sort by (-score, index) or simply rely on stability if you sort by score descending and the original list was ordered by index. A robust way is to sort by (-sim, i).
- Extract Top-K:
- Slice the first k elements from the sorted list.
- Separate the indices and scores into two lists.
- Format Output:
- Round each similarity score to 4 decimal places.
- Return [indices, sims].
4. Common Pitfalls
- Division by Zero: If a gallery vector is a zero vector (all zeros), its norm is 0, leading to a division by zero error. While rare in valid embedding spaces, it's good practice to handle or assume non-zero vectors.
- Incorrect Tie-Breaking: The problem specifies that ties must break toward the lower index. If you simply sort by score descending, the relative order of tied elements depends on the sort algorithm's stability. Python's sort is stable, meaning if you sort by score descending, elements with equal scores will retain their original relative order (which is ascending index if you iterated in order). However, explicitly including the index in the sort key (e.g., key=lambda x: (-x, x)) is safer and clearer.
- Floating Point Precision: Cosine similarity calculations involve floating-point arithmetic. Small errors can occur. Ensure you round the final output to 4 decimal places as specified, but do not round intermediate calculations prematurely.
- Confusing Dot Product with Cosine Similarity: If the vectors are not unit-normalized, the dot product is not the cosine similarity. Always divide by the product of the norms unless you are certain the inputs are pre-normalized.
- Index Off-by-One: Ensure you are returning the original indices from the gallery list, not the indices from the sorted list.
5. Time & Space Complexity
- Time Complexity:
- Computing norms and dot products for all m gallery vectors takes O(m⋅d), where d is the dimensionality of the vectors.
- Sorting the m results takes O(mlogm).
- Total time complexity is O(m⋅d+mlogm). For typical embedding dimensions (d≈512 or 768) and moderate gallery sizes, this is efficient.
- Space Complexity:
- We store a list of m tuples (score, index), which takes O(m) space.
- The output lists take O(k) space.
- Total space complexity is O(m).