Index Ai Academy
Phase 1 · Building with LLMs · Context & retrieval

Similarity search

Find the most relevant text by comparing embeddings.

About 25 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

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

  1. Finding the right page
  2. Scoring one document
  3. Top-k and a minimum score
  4. 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:

  1. 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.
  2. 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.

Quick check
Your help center has 2,000 articles. You built the index yesterday. Today, 50 customers each run one search. How many texts get embedded today?
  1. 50
  2. 2,000
  3. 100,000 (all 2,000 articles for each of the 50 searches)
  4. 2,050
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)
It runs right here in your browser, with nothing to install.

Here's what happened, step by step:

  • docs @ query gave [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 @ query means "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.

Quick check
What does this print?
import numpy as np
scores = np.array([0.3, 0.1, 0.7, 0.5])
print(np.argsort(scores)[::-1])
  1. [0.7 0.5 0.3 0.1]
  2. [2 3 0 1]
  3. [1 0 3 2]
  4. [3 2 1 0]
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.

  1. Take the top 3 scores: 0.81, 0.64 and 0.42.
  2. Drop any score below 0.5. That removes 0.42.
  3. 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.

Try it now

Five small steps, and this is the search behind most AI assistants.

Quick check
You search with k = 3 and a minimum score of 0.3. The documents score 0.72, 0.35, 0.29 and 0.10. How many results come back?
  1. 2
  2. 4
  3. 0
  4. 3
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

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))
In the lesson you can change this and run it.

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.

Quick check
Why embed the documents once and store the vectors, rather than embedding them on every search?
  1. So the query doesn't need embedding, which is what makes each search free
  2. The API forbids embedding the same text twice, so a second call would return an error
  3. Stored vectors are more accurate, because the model has more time to work on them
  4. Embedding costs time and money; the documents' vectors don't change between searches
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

Create a free account

Try a quiz question

A matrix of document vectors has shape (5000, 1024). What shape is index @ query_vector?

  1. (5000,): one score per document
  2. (1024,): one score per position in the vector
  3. (5000, 1024): one row of scores per document
  4. A single number: the best score in the matrix

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson