Similarity Search From Scratch

Ma Mahalakshmi V Updated 16 Sep 2026
9 min read ·Lesson 44 of 72

From Vectors to a Ranked List of Results

Lessons 1 and 2 established that embeddings place similar-meaning text close together in a high-dimensional space, and covered how to generate and store a collection of them. This lesson covers what "close together" actually means numerically, and how to turn that notion of closeness into a practical, ranked search: given a query and a collection of stored documents, return the documents most similar in meaning to the query, ordered from most to least similar.

Cosine Similarity: The Standard Metric

The most common way to measure how similar two embedding vectors are is cosine similarity — a measure of the angle between two vectors, rather than the straight-line distance between them. Two vectors pointing in nearly the same direction have a cosine similarity close to 1; two vectors pointing in completely unrelated directions have a cosine similarity close to 0; two vectors pointing in opposite directions have a cosine similarity close to -1.

import math

def cosine_similarity(vector_a: list[float], vector_b: list[float]) -> float:
    dot_product = sum(a * b for a, b in zip(vector_a, vector_b))
    magnitude_a = math.sqrt(sum(a * a for a in vector_a))
    magnitude_b = math.sqrt(sum(b * b for b in vector_b))
    if magnitude_a == 0 or magnitude_b == 0:
        return 0.0
    return dot_product / (magnitude_a * magnitude_b)

vector_a = [1.0, 2.0, 3.0]
vector_b = [1.0, 2.0, 3.0]
vector_c = [-1.0, -2.0, -3.0]

print(cosine_similarity(vector_a, vector_b))  # 1.0 — identical direction
print(cosine_similarity(vector_a, vector_c))  # -1.0 — opposite direction

Cosine similarity is preferred over raw Euclidean distance (straight-line distance) for text embeddings specifically because it measures direction rather than magnitude — two embedding vectors representing the same meaning should be considered similar regardless of small differences in vector length that can arise from factors unrelated to meaning, such as text length. Measuring the angle between vectors, rather than the distance between their endpoints, is exactly what makes this the standard choice for comparing embeddings, and it's why this function, or an equivalent from a numerical library, appears in essentially every hand-built retrieval system.

Building a Simple Semantic Search Function

Combining Lesson 2's embed_and_store() pattern with cosine_similarity() produces a complete, working semantic search over a small in-memory document collection.

def embed_and_store(client, texts: list[str]) -> list[dict]:
    response = client.embeddings.create(model="text-embedding-4", input=texts)
    return [{"text": text, "embedding": data.embedding} for text, data in zip(texts, response.data)]

def semantic_search(client, query: str, stored_documents: list[dict], top_k: int = 3) -> list[dict]:
    query_response = client.embeddings.create(model="text-embedding-4", input=query)
    query_vector = query_response.data[0].embedding

    scored_documents = []
    for document in stored_documents:
        score = cosine_similarity(query_vector, document["embedding"])
        scored_documents.append({"text": document["text"], "score": score})

    scored_documents.sort(key=lambda d: d["score"], reverse=True)
    return scored_documents[:top_k]

documents = [
    "Our return policy allows returns within 30 days of purchase.",
    "Shipping typically takes 3 to 5 business days.",
    "Refunds are processed within 5 to 7 business days after we receive the item.",
    "We offer a 20% discount for orders over $100.",
]

stored_documents = embed_and_store(client, documents)
results = semantic_search(client, "How long until I get my money back?", stored_documents)

for result in results:
    print(f"{result['score']:.3f} — {result['text']}")

Notice that the query "How long until I get my money back?" shares almost no words with "Refunds are processed within 5 to 7 business days after we receive the item," yet semantic search should rank that document highest — exactly the capability Lesson 1 introduced embeddings to provide, and exactly the failure mode a literal keyword search would have missed entirely. The top_k parameter caps how many results are returned, mirroring the same "retrieve only what's actually needed" reasoning Unit 9, Lesson 2 applied to max_num_results for the built-in file search tool, since returning every stored document ranked by score, rather than just the most relevant handful, would waste context when the request that consumes these results only needs the top few.

Why This Is Worth Understanding Even With File Search Available

Unit 9, Lesson 2's file search tool performs exactly this kind of retrieval internally, and for most applications, using it directly remains simpler than building the pipeline shown here. Understanding the mechanics matters for the cases where file search's built-in behavior doesn't fit: a similarity metric other than cosine similarity for a specialized use case, retrieval over data that isn't naturally represented as uploadable documents (structured records, short user-generated snippets, product listings), or a need to combine semantic similarity with other ranking signals (recency, popularity, an explicit business rule) that file search's built-in ranking doesn't expose. Building this pipeline by hand is strictly more work, but it is also strictly more flexible — the trade-off Unit 9, Lesson 2 already flagged when it introduced file search as the platform's built-in implementation of retrieval, versus the option of building a custom one, and the same trade-off Lesson 4 examines directly by comparing this hand-rolled approach against hosted vector stores.

Combining Semantic Search With Metadata Filtering

A realistic search often needs to combine semantic similarity with hard filters based on other attributes — restricting results to a specific category, date range, or status before or after ranking by similarity.

documents_with_metadata = [
    {"text": "Our return policy allows returns within 30 days.", "category": "policy", "embedding": None},
    {"text": "Shipping typically takes 3 to 5 business days.", "category": "logistics", "embedding": None},
    {"text": "Refunds are processed within 5 to 7 business days.", "category": "policy", "embedding": None},
]

texts = [doc["text"] for doc in documents_with_metadata]
response = client.embeddings.create(model="text-embedding-4", input=texts)
for doc, data in zip(documents_with_metadata, response.data):
    doc["embedding"] = data.embedding

def semantic_search_with_filter(client, query: str, stored_documents: list[dict], category: str, top_k: int = 3) -> list[dict]:
    filtered_documents = [doc for doc in stored_documents if doc["category"] == category]
    query_vector = client.embeddings.create(model="text-embedding-4", input=query).data[0].embedding

    scored = [
        {"text": doc["text"], "score": cosine_similarity(query_vector, doc["embedding"])}
        for doc in filtered_documents
    ]
    scored.sort(key=lambda d: d["score"], reverse=True)
    return scored[:top_k]

results = semantic_search_with_filter(client, "how do refunds work", documents_with_metadata, category="policy")

Filtering to category == "policy" before computing similarity scores, rather than ranking the entire collection and filtering afterward, is both more efficient (fewer similarity computations) and more correct (a highly similar result from the wrong category never has a chance to crowd out a less similar but correctly categorized one). This combination of a hard structural filter with a soft semantic ranking is a common and useful pattern in real retrieval systems, mirroring the metadata filters option Unit 9, Lesson 2 introduced for the built-in file search tool, implemented here explicitly by hand.

Scaling Beyond a Small In-Memory Collection

The semantic_search() function above computes a similarity score against every single stored document for every single query — a linear scan that works fine for a few thousand documents but becomes noticeably slow for a collection with millions, since the cost of a single search grows directly with the size of the collection.

def semantic_search_naive_complexity_note(num_documents: int, num_queries_per_day: int) -> int:
    """Illustrative — a naive linear scan performs one similarity computation
    per stored document, per query, so total daily comparisons scale with
    the product of collection size and query volume."""
    return num_documents * num_queries_per_day

print(semantic_search_naive_complexity_note(1_000_000, 10_000))  # 10 billion comparisons per day

For a collection at this kind of scale, a specialized vector database or approximate nearest-neighbor index (structures designed specifically to avoid comparing a query against every stored vector, trading a small amount of retrieval accuracy for a large improvement in search speed) becomes necessary — which is, again, exactly the kind of underlying infrastructure Unit 9, Lesson 2's vector stores manage for you, and the exact question Lesson 4 addresses directly.

Vectorizing the Comparison With NumPy

The pure-Python cosine_similarity() function above is easy to follow but recomputes the same kind of loop-based arithmetic repeatedly; for anything beyond a small collection, using NumPy's vectorized array operations produces the same result meaningfully faster, since NumPy performs the underlying arithmetic in optimized, compiled code rather than a Python-level loop.

import numpy as np

def semantic_search_vectorized(client, query: str, stored_documents: list[dict], top_k: int = 3) -> list[dict]:
    query_vector = np.array(client.embeddings.create(model="text-embedding-4", input=query).data[0].embedding)
    document_matrix = np.array([doc["embedding"] for doc in stored_documents])

    # Cosine similarity for every stored document against the query, computed
    # as a single vectorized operation rather than one comparison at a time.
    dot_products = document_matrix @ query_vector
    document_norms = np.linalg.norm(document_matrix, axis=1)
    query_norm = np.linalg.norm(query_vector)
    scores = dot_products / (document_norms * query_norm)

    ranked_indices = np.argsort(scores)[::-1][:top_k]
    return [{"text": stored_documents[i]["text"], "score": float(scores[i])} for i in ranked_indices]

The document_matrix @ query_vector line computes the dot product between the query and every stored document's embedding in one matrix operation, replacing what would otherwise be a separate Python-level loop iteration per document — for a collection of even a few thousand documents, this difference is directly noticeable, and it becomes the only practical approach well before reaching the millions-of-documents scale discussed above. This is a reasonable middle ground between the simple, purely illustrative loop version shown earlier and a full dedicated vector database — a comparison Lesson 4 covers in more detail.

Testing Similarity Search Logic Without Real Embeddings

Following this course's dependency-injection pattern, the ranking and filtering logic can be tested with fabricated vectors, avoiding the cost and non-determinism of a real embedding call for every test run.

def test_semantic_search_ranks_by_similarity():
    stored_documents = [
        {"text": "closely related document", "embedding": [1.0, 0.0, 0.0]},
        {"text": "unrelated document", "embedding": [0.0, 1.0, 0.0]},
    ]
    query_vector = [0.9, 0.1, 0.0]

    scored = [
        {"text": doc["text"], "score": cosine_similarity(query_vector, doc["embedding"])}
        for doc in stored_documents
    ]
    scored.sort(key=lambda d: d["score"], reverse=True)

    assert scored[0]["text"] == "closely related document"
    print("PASS: semantic search ranking correctly places the more similar vector first")

test_semantic_search_ranks_by_similarity()

Using small, hand-constructed vectors with an obviously correct expected ranking (rather than real embeddings from real text) lets the ranking and filtering logic itself be verified quickly and deterministically, reserving real embedding calls for a smaller set of end-to-end tests confirming actual text produces the semantic relationships you expect.

Common Mistakes

Using raw Euclidean distance instead of cosine similarity for text embeddings, without accounting for the fact that cosine similarity's focus on direction rather than magnitude is specifically why it's the standard choice for this kind of comparison.

Ranking a full collection by similarity before applying a hard structural filter, wasting computation and risking an off-topic result outranking a correctly filtered one.

Scaling a hand-rolled linear-scan search to a very large document collection, when the cost of comparing every query against every stored vector grows directly with collection size and becomes impractical well before reaching millions of documents.

Rebuilding a custom embeddings and similarity pipeline for a use case the built-in file search tool already handles well, taking on unnecessary implementation and maintenance work.

Best Practices

Use cosine similarity as the default metric for comparing text embeddings, understanding why it's preferred over raw distance measures for this specific kind of comparison.

Apply hard structural filters before ranking by semantic similarity, rather than after, for both efficiency and correctness.

Recognize the scale at which a hand-rolled linear scan stops being practical, and reach for a dedicated vector database or the built-in file search tool once a collection grows large enough that per-query search time becomes a genuine problem.

Test ranking and filtering logic with small, hand-constructed vectors with known expected outcomes, reserving real embedding calls for a smaller set of tests that confirm actual text produces the expected semantic relationships.

0 Comments

Reviewed before they appear

No comments yet.

OpenAI SDK
Introduction to the OpenAI SDK Setting Up Python Creating an API Key Your First Call — client.responses.create() and response.out Understanding Billing, Credits, and What a Request Costs Why Responses Replaced Chat Completions Anatomy of a Request: model, input, and instructions Anatomy of a Response: The Typed output Array, Not Just Text Roles: User, Assistant, and Developer/System Choosing a Model, and Reading the Models Page Instead of Mem Instructions vs. Input Writing Prompts That Get Consistent Results Few-Shot Examples Reasoning Models and the reasoning Parameter Debugging a Prompt That Misbehaves Why Streaming Matters for User Experience stream=True and Iterating Over Events Handling the Event Types You Actually Care About Background Mode for Long-Running Jobs Project — Add Live Streaming to Your Chatbot The Problem With Parsing Free Text JSON Schema and Strict Mode Pydantic Models With the SDK's Parse Helpers Handling Refusals and Validation Failures Project — A Resume-to-JSON Extractor Working With input_image input_file, PDFs, and the Files API Image Generation Speech-to-Text and Text-to-Speech Project: A PDF Question-Answering Script What Function Calling Is Defining a Tool Schema The Full Loop Multiple Tools Errors, Timeouts, and Untrusted Arguments Project: A Weather Assistant Web Search File Search and Vector Stores Code Interpreter Remote MCP Servers and Connectors Project: A Research Assistant What an Embedding Is, Without the Maths Generating and Storing Embeddings Similarity Search From Scratch Hosted Vector Stores vs. Rolling Your Own A Small RAG App Over a Folder of Notes Agents vs. a Single API Call — When You Need One pip install openai Giving Agents Tools Handoffs and Multi-Agent Triage Guardrails and Approvals Tracing and Observing What Your Agent Did A Multi-Agent Support Desk Error Codes and What Each One Means Retries, Timeouts, and Backoff Rate Limits and Spend Limits Prompt Caching and Cost Optimisation The Batch API for Bulk Work Async Clients and Concurrency Moderation and Safety Best Practices Designing the App Backend With FastAPI Streaming to a Simple Frontend Deploying and a Cost/Safety Checklist Why Web Search Is Useful for Current Information Using the Web Search Tool with the Responses API Configuring Search Behavior for Application Use Cases Understanding Citations and Source Attribution Where to Go Next Building a Research Assistant with Web Search Combining Web Search with Structured Outputs Handling Conflicting or Low-Quality Web Sources
Ask about this post
AI Ask about this post

Ask questions about Similarity Search From Scratch and get answers drawn from it.

Signed-in readers only.