Similarity search
Find the most relevant text by comparing embeddings.
Create a free account Try part of it here
Why it matters
When you ask a shop's help bot a question, it first has to find the right help article. That finding step is similarity search. It powers "related products" and suggested support articles. Above all, it's the first half of retrieval-augmented generation (RAG), where a model answers from your own documents. Say a model should answer from your help center or last quarter's reports. First your code has to find the few passages that matter out of thousands. The method is short: embed once, compare fast, return the best few.
What you'll be able to do
- Build a small search index by embedding a set of documents once
- Find the top-k most similar documents for a query
- Score every document at once with a matrix of vectors
- Use a minimum score so weak matches aren't returned
What it covers
- Finding the right page
- Scoring one document
- Top-k and a minimum score
- Worked example: search the help center
Finding the right page
Picture a library with a helpful librarian. When new books arrive, the librarian reads each one once and writes a short note about what it covers. Later you ask a question. The librarian doesn't reread every book. They compare your question with their notes and hand you the closest few.
Similarity search works the same way. In the last lesson you met embeddings: lists of numbers that capture what a text means. Texts with similar meanings get similar lists. Each list is called a vector. Similarity search uses them in two phases:
- Index (once, ahead of time). Embed every document and store the vectors. This stored set is called the index. It plays the part of the librarian's notes.
- Query (on every search). Embed the search text, which is called the query. Compare it with every stored vector. Then return the top k documents with the highest scores. The letter k stands for "how many." It's usually small, like 3.
Embedding is the slow part, and it costs money, because each call goes to an embedding model. So you embed the documents once and reuse their vectors. After that, each search costs one embedding call for the query, plus some quick arithmetic.
Show the answer
50
The articles were embedded once, yesterday. Each search embeds only its own query, so today that's 50 texts.
Scoring one document
Each comparison gives a score: a number for how close a document is to the query. You met this score in the last lesson. It's cosine similarity, and closer to 1 means more similar.
Most embedding APIs, including the practice one, return vectors with length 1. For vectors like these, the cosine is the same as the dot product. To find a dot product, multiply the matching numbers in the two lists. Then add up the results.
Here's a small example with three numbers per vector. (The practice model uses 24, and real models use hundreds or thousands.) The query is [1.0, 0.0, 0.0] and doc 0 is [0.8, 0.6, 0.0].
- First numbers: 0.8 × 1.0 = 0.8
- Second numbers: 0.6 × 0.0 = 0
- Third numbers: 0.0 × 0.0 = 0
- Add them up: 0.8 + 0 + 0 = 0.8
So doc 0 scores 0.8 against this query.
In plain words: the score is "multiply matching numbers, then add." It comes out high when the document's numbers are big in the same places as the query's.
Scoring every document at once
You could score the documents one at a time in a Python loop. With 10,000 documents, that loop is slow. NumPy can do the whole job in one step.
First, stack the document vectors into a matrix. A matrix is a grid of numbers, like a spreadsheet. Here each row holds one document's vector. Then the @ sign multiplies the matrix by the query. This is called matrix multiplication. It takes the dot product of every row with the query, so you get one score per document. Doc 0 below is the one you scored by hand.
import numpy as np docs = np.array([ [0.8, 0.6, 0.0], # doc 0 [0.0, 0.6, 0.8], # doc 1 [0.6, 0.8, 0.0], # doc 2 ]) query = np.array([1.0, 0.0, 0.0]) scores = docs @ query # one score per row print(scores) best = np.argsort(scores)[::-1] # indexes, highest score first print("ranking:", best)
Here's what happened, step by step:
docs @ querygave[0.8 0. 0.6]. Doc 0 scores 0.8, as you worked out by hand. Doc 1 scores 0, and doc 2 scores 0.6.np.argsort(scores)returns positions, not scores. They're the positions that would sort the scores from lowest to highest:[1 2 0].[::-1]reverses that order, so the best comes first:[0 2 1].
In plain words:
docs @ querymeans "score every document against the query."np.argsort(scores)[::-1]means "list the documents from best to worst."
Go deeper Why the dot product is the cosine here
Cosine similarity is the dot product divided by the two vectors' lengths. A vector's length is the square root of its numbers squared and added up. For doc 0 that's 0.8 × 0.8 + 0.6 × 0.6 + 0 × 0 = 0.64 + 0.36 = 1. The square root of 1 is 1, so doc 0 has length 1. The query has length 1 too.
Dividing by 1 × 1 changes nothing. So for length-1 vectors, the dot product and the cosine are the same number. That saves a division for every document on every search. If your vectors don't have length 1, divide each document vector by its length once, when you build the index. Do the same to each query vector. In NumPy that's v / np.linalg.norm(v).
Go deeper When the collection gets huge
One matrix multiplication still touches every stored vector. That's fine for thousands of documents. For millions, the time adds up. Vector databases, covered soon, store the vectors in ways that let them check only the likely candidates. The answers are then very close to the exact top k, but not always identical.
import numpy as np scores = np.array([0.3, 0.1, 0.7, 0.5]) print(np.argsort(scores)[::-1])
Show the answer
[2 3 0 1]
argsort lists positions from the lowest score to the highest: [1 0 3 2]. Reversing it puts position 2 (score 0.7) first. It prints positions, not the scores themselves.
Top-k and a minimum score
Think of asking a friend to recommend a restaurant. You want their best two or three picks. You don't want a ranked list of every place in town. That's top-k: return only the k highest-scoring documents. A k between 3 and 10 is typical.
You also want your friend to say "I don't know" when they don't know. Say a customer asks your help bot about the weather. Without a cutoff, search still returns the least-bad article, and the bot answers from it. A minimum score fixes this. Any document scoring below it is left out, even if it made the top k. If nothing clears the bar, search returns nothing.
Here's a worked example. Five articles score 0.81, 0.64, 0.42, 0.28 and 0.05 against a query. You ask for k = 3 with a minimum score of 0.5.
- Take the top 3 scores: 0.81, 0.64 and 0.42.
- Drop any score below 0.5. That removes 0.42.
- You get two articles back, scoring 0.81 and 0.64.
There's no single right minimum score. It depends on the embedding model. To find a good one, try real queries and look at the scores. Note where good matches tend to land and where bad ones land. Then set the cutoff between them.
For marketers: this is the same idea as a lookalike audience. You describe what you want as a point in a space and ask for the nearest neighbors. The minimum score is your relevance cutoff.
Five small steps, and this is the search behind most AI assistants.
Show the answer
2
The top 3 scores are 0.72, 0.35 and 0.29. Then 0.29 is below 0.3, so it's dropped. Two results are left.
Worked example: search the help center
Now the real thing. Fetch the practice shop's help articles and embed them once, as one index. Each article goes in as its title plus its text. Then answer several searches from that same index. The search function does every step from this lesson: score all the articles, take the top k, and drop anything under the minimum score.
import numpy as np import requests API = "https://api.gradient.test/v1" docs = requests.get(f"{API}/docs").json()["data"] def embed(texts): data = requests.post(f"{API}/embeddings", json={"input": texts}).json()["data"] return np.array([item["embedding"] for item in data]) # Index once: title plus text, one row per article index = embed([d["title"] + ". " + d["text"] for d in docs]) print("index shape:", index.shape) def search(query, k=2, min_score=0.3): scores = index @ embed([query])[0] order = np.argsort(scores)[::-1][:k] return [(round(float(scores[i]), 3), docs[i]["title"]) for i in order if scores[i] >= min_score] for q in ["when will my order arrive", "my zip broke", "is there a team discount", "what's your CEO's name"]: print(q, "→", search(q))
index.shape is (11, 24). That means eleven articles, with 24 numbers each. The index took one embedding call. After that, every search embeds only its query.
Look at the last question. The help center says nothing about the CEO, so every article scores low. The minimum score filters out those weak matches, and search correctly returns an empty list. That's what keeps a chatbot from "answering" with an unrelated article.
Show the answer
Embedding costs time and money; the documents' vectors don't change between searches
Only the query is new on each search. Re-embed a document only when its text changes.
Practice what you just read
- 2 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 4 review cards for spaced repetition
Try a quiz question
A matrix of document vectors has shape (5000, 1024). What shape is index @ query_vector?