Topic 6 of 15 · Core patterns

Embeddings & vector search.

Every RAG system (Topic 7) rests on one idea: turn text into a list of numbers such that "similar meaning" becomes "nearby points." This lesson builds that idea from first principles — generate embeddings, measure similarity, search a small set by hand — before Topic 12 hands the heavy lifting to a real database.

The mental model

A point in space that means something.

An embedding model turns a string into a fixed-length list of floats — typically 768 to 3072 numbers. That list is a coordinate in a very high-dimensional space, positioned so that texts with similar meaning land near each other, regardless of exact wording. "How do I reset my password" and "I forgot my login" end up close together; "How do I reset my password" and "What's the weather today" end up far apart. Nothing here is a lookup table or a keyword match — it's geometry.

Section 0

What an Embedding Is

Structurally, an embedding is just a list[float] — the Kotlin equivalent would be a DoubleArray. What makes it useful isn't the type, it's how it was produced: a model trained so that semantic closeness in meaning becomes geometric closeness in this list of numbers.

Not a hashA hash scrambles input so similar strings produce wildly different outputs. An embedding does the opposite on purpose — similar meaning stays close.
Not keyword matching"Car" and "automobile" share zero characters but land close together — embeddings capture meaning, not spelling.
Fixed lengthA 500-word document and a 3-word query from the same model produce vectors of the same length — the model compresses everything to one fixed-size representation.
Section 1

Generating Embeddings

Same request shape as Topic 2's chat completions, simpler payload: text in, a vector out. No message roles, no conversation — just an input and its coordinate.

OpenAI
from openai import OpenAI
client = OpenAI()

r = client.embeddings.create(
    model="text-embedding-3-small",
    input="How do I reset my password?",
)
vector = r.data[0].embedding
print(len(vector))  # 1536
Gemini
from google import genai
client = genai.Client()

r = client.models.embed_content(
    model="gemini-embedding-001",
    contents="How do I reset my password?",
)
vector = r.embeddings[0].values
print(len(vector))  # 768 (default)
Embedding models are billed and rate-limited like chat models but are far cheaper per call — batching many texts into one request (both SDKs accept a list of strings) is the normal pattern rather than one call per string, especially when embedding an entire document set for Topic 7.
Section 2

Measuring Similarity

Cosine similarity is the standard measure: it compares the angle between two vectors, ignoring their length, and returns a value from -1 (opposite meaning) to 1 (identical meaning). Most embedding models normalize vectors to unit length, which makes cosine similarity equivalent to a plain dot product — cheaper to compute.

import numpy as np

def cosine_similarity(a: list[float], b: list[float]) -> float:
    a, b = np.array(a), np.array(b)
    return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))

password_reset = get_embedding("How do I reset my password?")
forgot_login = get_embedding("I forgot my login")
weather = get_embedding("What's the weather today?")

print(cosine_similarity(password_reset, forgot_login))  # ~0.7-0.9 — related
print(cosine_similarity(password_reset, weather))        # ~0.0-0.2 — unrelated
Score rangeInterpretation
0.8 – 1.0Near-duplicate meaning
0.5 – 0.8Related / same topic
0.2 – 0.5Loosely related at best
Below 0.2Effectively unrelated
These bands are a rough guide, not a law — the right similarity threshold for "relevant enough to retrieve" (Topic 7) depends on your embedding model and your data, and should be tuned against real examples rather than assumed.
Section 3

Brute-Force Search in Python

For a handful to a few thousand items, comparing a query against every stored vector directly is simple, correct, and fast enough — no database required. This is the entire mechanism a vector database automates and accelerates.

documents = [
    "Reset your password from the account settings page.",
    "Our refund policy allows returns within 30 days.",
    "The mobile app supports offline mode since version 4.2.",
]
doc_vectors = [get_embedding(doc) for doc in documents]  # do this once, cache it

def search(query: str, top_k: int = 2) -> list[tuple[str, float]]:
    query_vector = get_embedding(query)
    scored = [(doc, cosine_similarity(query_vector, vec))
              for doc, vec in zip(documents, doc_vectors)]
    scored.sort(key=lambda pair: pair[1], reverse=True)
    return scored[:top_k]

for doc, score in search("how do I get my money back"):
    print(f"{score:.3f}  {doc}")
# 0.71  Our refund policy allows returns within 30 days.
# 0.31  The mobile app supports offline mode since version 4.2.
Checkpoint: run this against at least five documents you write yourself, including one deliberately unrelated one, and confirm the ranking matches your intuition about relevance before moving on.
Section 4

Why Brute Force Stops Scaling

Section 3's search is O(n) — every query compares against every stored vector. Fine at a few thousand documents, unworkable at a few million: that's where Approximate Nearest Neighbor (ANN) indexes come in, trading a small amount of accuracy for a massive speed gain by not checking every vector.

ScaleApproach
Up to ~10K vectorsBrute-force cosine similarity in memory (Section 3) — often faster to build than to argue about
10K – millionsAn ANN index (HNSW, IVFFlat) inside a vector database
Millions+, filtered searchA vector database with metadata filtering, sharding, and index tuning
Topic 12 builds the "millions" tier using PostgreSQL's pgvector extension, which adds exactly this kind of ANN index to a database you likely already run. Don't reach for a dedicated vector database on day one — most products never outgrow Postgres for this.
Section 5

Chunking Text Before Embedding

Embedding models have an input length limit, and — more importantly — a single vector represents a single "topic" poorly once the text covers many unrelated things. A 50-page document embedded as one vector produces a blurry average that matches nothing well. Split it into smaller pieces first.

def chunk_text(text: str, chunk_size: int = 500, overlap: int = 50) -> list[str]:
    chunks = []
    start = 0
    while start < len(text):
        end = start + chunk_size
        chunks.append(text[start:end])
        start = end - overlap  # overlap keeps context from being cut mid-thought
    return chunks
This is a deliberately simple character-count chunker to build the intuition. Topic 7 covers smarter strategies — splitting on paragraph or sentence boundaries, and sizing chunks around the retrieval unit that actually makes sense for your content (a FAQ entry, a support ticket, a doc section).
Section 6 · Checkpoint

Capstone: A Tiny Semantic Search Engine

Combine embedding generation, cosine similarity, and chunking into one reusable search function over a small set of documents — the exact shape Topic 7 wraps an LLM around.

import numpy as np
from openai import OpenAI

client = OpenAI()

def get_embedding(text: str) -> list[float]:
    r = client.embeddings.create(model="text-embedding-3-small", input=text)
    return r.data[0].embedding

def cosine_similarity(a: list[float], b: list[float]) -> float:
    a, b = np.array(a), np.array(b)
    return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))

class TinySearchIndex:
    def __init__(self, documents: list[str]):
        self.documents = documents
        self.vectors = [get_embedding(doc) for doc in documents]

    def search(self, query: str, top_k: int = 3) -> list[tuple[str, float]]:
        query_vector = get_embedding(query)
        scored = [(doc, cosine_similarity(query_vector, vec))
                  for doc, vec in zip(self.documents, self.vectors)]
        scored.sort(key=lambda pair: pair[1], reverse=True)
        return scored[:top_k]


if __name__ == "__main__":
    index = TinySearchIndex([
        "Reset your password from the account settings page.",
        "Our refund policy allows returns within 30 days of purchase.",
        "The mobile app supports offline mode since version 4.2.",
        "You can export your data as a CSV from the settings menu.",
        "Support is available Monday through Friday, 9am to 6pm IST.",
    ])
    for doc, score in index.search("can I get a refund"):
        print(f"{score:.3f}  {doc}")
Expected output: the refund policy document ranks first with a noticeably higher score than the others, even though the query shares no exact words with it beyond "refund."
  • Built TinySearchIndex and ran at least three different queries against it
  • Tried a query using completely different wording than any document (e.g. "get my cash back" instead of "refund") and confirmed the right document still ranks first
  • Added a sixth, deliberately unrelated document and confirmed it consistently scores lowest across your test queries
  • Can explain out loud why this index rebuilds embeddings for every document on every startup, and why that becomes a problem — Topic 12 fixes exactly this by persisting vectors in a real database
  • Next up

    Topic 7: RAG

    Wrap this topic's search function with an LLM to build grounded, cited answers over your own documents.