embeddingsvector searchsemantic search

Embeddings & Vector Search

Keyword search misses anything worded differently, and a model cannot answer from data it never saw. Embeddings and vector search fix both: how text becomes vectors, how similarity search works, and how vector databases and RAG are built on top — with runnable examples.

The Whole Process in Four Sentences

Before we begin: the shape of the whole thing, so every part below has somewhere to land.

Embeddings turn text into vectors whose distances track meaning. Similarity math finds the nearest ones. A vector database makes that search fast at scale. RAG puts the results into a prompt so a language model can answer from your data instead of from its memory.

That is the whole process. Every section below expands one of those four sentences, in that order.

What Are Embeddings?

How text becomes a vector, which models produce them, and what the numbers do and don't mean.

The Core Idea

An embedding model turns a piece of text into a fixed-length list of numbers called a vector. Text with similar meaning lands at nearby points in that space.

"dog"    ->  [0.21, 0.78, 0.11, ...]
"puppy"  ->  [0.28, 0.71, 0.19, ...]   near "dog"
"car"    ->  [0.90, 0.09, 0.05, ...]   far from "dog"

A program has no way to tell that "dog" and "puppy" are related words. It can measure the distance between two lists of numbers. Embeddings convert the first problem into the second, which is what makes these possible:

  • finding documents that answer a question without sharing its keywords
  • recommending items similar to one someone already liked
  • grouping related content without predefined categories
  • pulling relevant context into an LLM prompt (RAG, later in this pack)

Getting a Vector

You use a pre-trained model rather than training one. Every major AI provider hosts embedding models behind an API, and open models you can run yourself are on Hugging Face. Vector lengths across them run from a few hundred numbers to a few thousand — bigger is not automatically better for your data.

The examples in this pack use OpenAI's API and the open all-MiniLM-L6-v2 model, because they are widely available and the call shapes are typical. Whatever you use, the request looks about like this:

from openai import OpenAI

client = OpenAI()  # reads OPENAI_API_KEY from the environment

response = client.embeddings.create(
    model="text-embedding-3-small",
    input="The quick brown fox",
)

vector = response.data[0].embedding
len(vector)  # a fixed number, set by the model

Three Things to Know About the Vectors

  • The length is fixed by the model, not the input. A one-word input and a full page both come back as the same number of values. Some models let you request a shorter vector — a smaller, faster index in exchange for some accuracy.
  • The same text always produces the same vector — for the same model. Vectors from two different models are not comparable at all. Switching models means re-embedding everything you have stored.
  • Some models return normalized vectors (length exactly 1). Check your provider's docs rather than assuming — it changes which similarity math is worth using, which is the next section.

External Resources

OpenAI Embeddings Guide
OpenAI

Official guide: models, dimensions, and the create-an-embedding call

Embeddings with Claude
Anthropic

Provider-selection criteria and a worked retrieval example, from Anthropic's docs

Sentence Transformers
SBERT

Open-source library for running embedding models yourself

Getting Started With Embeddings
Hugging Face

Beginner walkthrough from raw text to a working similarity search

Comparing Two Vectors

Cosine similarity, dot product, and Euclidean distance — what differs and when it matters.

The Question

You have two vectors. How close are they?

Cosine Similarity

Cosine similarity measures the angle between two vectors and ignores their length. It runs from -1 (opposite) through 0 (unrelated) to 1 (identical direction).

import numpy as np

def cosine_similarity(a, b):
    a, b = np.asarray(a), np.asarray(b)
    return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))

dog   = [0.21, 0.78, 0.11]
puppy = [0.28, 0.71, 0.19]
car   = [0.90, 0.09, 0.05]

cosine_similarity(dog, puppy)  # 0.99
cosine_similarity(dog, car)    # 0.36

Real vectors have hundreds or thousands of components instead of three, but the arithmetic is exactly this.

The Other Metrics

MetricWhat it measuresUse it when
Cosine similarityAngle between vectorsThe default. Works whether or not vectors are normalized.
Dot productAngle and length togetherVectors are normalized — it gives the same ranking as cosine and skips the division.
Euclidean distanceStraight-line distanceLength carries meaning. Rare for text embeddings.

When vectors are normalized to length 1, these collapse into each other: dot product equals cosine similarity, and Euclidean distance ranks results identically. That is why vector databases let you pick a metric per index and why the choice usually does not change your results — only your compute cost.

Scores Are Relative

A cosine similarity of 0.82 does not mean "82% relevant." Score distributions differ by model and by corpus, so a cutoff that filters noise on one dataset admits garbage on another. Rank by score, then decide a threshold by looking at your own labelled examples.

Searching a Small Corpus

With a few thousand documents you can compare against everything:

query_vector = embed("best pizza in NYC")

ranked = sorted(
    ((doc, cosine_similarity(query_vector, vec)) for doc, vec in stored),
    key=lambda pair: pair[1],
    reverse=True,
)
top_5 = ranked[:5]

That loop is linear in the number of documents. The next section is about what to do when it stops being fast enough.

External Resources

Vector Similarity Explained
Pinecone

The three common metrics, with the math and when each applies

Semantic Textual Similarity
SBERT

Computing similarity between encoded sentences with model.similarity()

Vector Databases

What an approximate-nearest-neighbor index buys you, the main options, and when you can skip one.

The Scale Problem

Comparing a query against 1,000 stored vectors is a loop. Comparing it against 10 million is a data-structures problem — and that is what a vector database solves.

What They Actually Do

  1. Store vectors alongside metadata — the original text, an ID, fields you want to filter on.
  2. Build an approximate-nearest-neighbor index so a query does not touch every vector.
  3. Return the top matches with scores, usually in milliseconds.

The word to notice is approximate. These indexes trade a small amount of recall for a very large speedup, which is almost always the right trade at scale.

The Options

OptionShapeNotable for
PineconeManaged serviceLeast setup; no infrastructure to run
WeaviateManaged or self-hostedBuilt-in hybrid keyword + vector search
QdrantManaged or self-hostedFast filtered search
ChromaEmbedded / self-hostedRuns locally; good for prototypes
MilvusSelf-hostedLarge-scale deployments
pgvectorPostgres extensionVectors next to data you already have in Postgres
Redis vector searchRedis moduleYou already run Redis

If your data already lives in Postgres, start with pgvector. Adding a second datastore is a real operational cost, and pgvector handles millions of vectors.

The Usage Pattern

Every one of these has the same three-step shape. Pinecone, for example:

from pinecone import Pinecone

pc = Pinecone(api_key="your-key")
index = pc.Index("my-embeddings")

# 1. Write vectors, with metadata you may want to filter or display
index.upsert(
    vectors=[
        {"id": "doc-1", "values": pizza_vector, "metadata": {"text": "Pizza recipe"}},
        {"id": "doc-2", "values": pasta_vector, "metadata": {"text": "Pasta recipe"}},
    ],
)

# 2. Query with a vector you produced the same way
results = index.query(
    vector=embed("italian food"),
    top_k=5,
    include_metadata=True,
)

# 3. Read the matches
for match in results.matches:
    print(match.score, match.metadata["text"])

Chroma, Qdrant, and pgvector differ in the method names, not the shape: write vectors with metadata, query with a vector, read back scored matches.

Metadata is worth populating carefully. Besides giving you the original text to display, it lets you filter before the vector search runs — restricting to one customer, one language, or one document type narrows the candidate set and speeds up the query.

When You Don't Need One

  • Under roughly 10,000 embeddings — NumPy and a sorted list are fine, and simpler to debug.
  • Batch jobs where latency does not matter — brute force finishes while you get coffee.
  • Data that changes on every run, so an index would be rebuilt each time anyway.

External Resources

Pinecone Quickstart
Pinecone

Create an index, upsert vectors, and query it

Chroma Documentation
Chroma

Embedded vector database that runs locally — good for prototypes

pgvector
GitHub

Vector search inside Postgres, no second datastore required

Vector Database Comparison
OpenAI Cookbook

Worked examples across the major vector stores

Weaviate Quickstart
Weaviate

Setting up a vector store with hybrid search built in

Filtering With Metadata
Pinecone

Narrowing the candidate set before the vector search runs

RAG: Retrieval Augmented Generation

Using retrieval to give a language model context it was never trained on, and the four ways it goes wrong.

What RAG Fixes

An LLM only knows what was in its training data. It has never seen your company handbook, last week's incident report, or the customer's account history. RAG (Retrieval Augmented Generation) closes that gap by retrieving relevant text and putting it in the prompt before the model answers.

The Pipeline

question -> embed -> vector search -> top chunks -> prompt with context -> answer

Every piece of that comes from the previous sections. RAG is semantic search with a generation step bolted on the end.

A Worked Example

The user asks: What's our return policy?

  1. Embed the question with the same model used to index the handbook.
  2. Search the vector index, take the top 3 chunks — "Returns accepted within 30 days", "Original receipt required", "Refunds processed in 5-7 business days".
  3. Build a prompt containing those chunks plus the question.
  4. Send it to the model, which answers from the supplied text.

The prompt you assemble in step 3 looks like this:

Answer the question using only the context below. If the context
does not cover it, say so.

Context:
Returns accepted within 30 days
Original receipt required
Refunds processed in 5-7 business days

Question: What's our return policy?

The Code

from openai import OpenAI
from pinecone import Pinecone

client = OpenAI()
index = Pinecone(api_key="your-key").Index("handbook")

question = "What's our return policy?"

query_vector = client.embeddings.create(
    model="text-embedding-3-small",
    input=question,
).data[0].embedding

results = index.query(vector=query_vector, top_k=3, include_metadata=True)
context = "\n\n".join(match.metadata["text"] for match in results.matches)

prompt = (
    "Answer the question using only the context below. "
    "If the context does not cover it, say so.\n\n"
    f"Context:\n{context}\n\nQuestion: {question}"
)

Then send prompt to whichever chat model you use — that call is one line of an SDK and is the same call you would make without retrieval. Everything above it is the part RAG adds.

Where RAG Goes Wrong

  • Bad chunking. Split a document mid-argument and neither half retrieves well. See the next section.
  • Wrong chunks retrieved. The most common cause of a bad answer is a good model reading the wrong text. Log what was retrieved, not just what was said.
  • Too much context. Stuffing 50 chunks in costs tokens and buries the relevant one.
  • The model ignores the context anyway. Instruct it to answer only from the supplied text and to say when the text does not cover the question — and check that it complies.

Related Concepts

Retrieval-Augmented Generation (RAG)

External Resources

Question Answering Using Embeddings
OpenAI Cookbook

End-to-end retrieval plus generation in one notebook

RAG Using Pinecone
Anthropic Cookbook

Retrieval with a vector store, generation with Claude

Advanced RAG Techniques
LlamaIndex

Re-ranking, query expansion, and other fixes for weak retrieval

Chunking

Why documents get split before embedding, the two ways to split them, and why this is the setting worth tuning.

Why Split At All

A single vector for a fifty-page PDF represents the average of everything in it, which is to say nothing in particular. Retrieval happens over the pieces, so documents get split before they are embedded — and how you split them decides what can be found.

Fixed-Size Chunks

The simple approach: a set number of words per chunk, with a little overlap so a sentence that straddles a boundary still appears whole somewhere.

def chunk_text(text, chunk_size=500, overlap=50):
    words = text.split()
    step = chunk_size - overlap
    return [
        " ".join(words[i:i + chunk_size])
        for i in range(0, len(words), step)
    ]

It ignores meaning entirely, which is the drawback — a definition and the sentence that qualifies it can land in different chunks.

Structure-Aware Chunks

Splitting on the document's own boundaries — headings, sections, paragraphs — usually retrieves better, because each chunk is then about one thing. Keep the source title and document ID in each chunk's metadata so a retrieved chunk can be traced back and shown with its source.

The Tradeoff

Chunk sizeRetrieval behaviorCost
SmallPrecise matches, missing surrounding contextMore vectors to store and search
LargeFull context, diluted match against the queryFewer, weaker matches; more tokens in the prompt

There is no correct answer here, only an answer for your documents and your questions. It is worth testing directly — chunking changes retrieval quality more than swapping embedding models does, and it is far cheaper to change.

What Embedding Costs

Hosted embedding models are priced per input token, and in a retrieval system they are almost never the expensive part — generation costs more. Two habits keep it that way:

  • Embed in batches. Every API takes a list of inputs. One request with a hundred texts beats a hundred requests.
  • Store the vector; never recompute it. Text you have already embedded should be embedded once, with the vector saved alongside the record.

Running an open model yourself trades the per-call price for a machine to run it on. all-MiniLM-L6-v2 is 22.7M parameters — about 90 MB — and works on a CPU, though bulk indexing is much faster with a GPU.

External Resources

Chunking Strategies for LLM Applications
Pinecone

Fixed-size, recursive, and structure-aware splitting compared

OpenAI API Pricing
OpenAI

Current per-token prices, including the embedding models

Choosing Your Setup

A decision guide across the pack's pieces, plus how to pick an embedding model for your own data.

Which Piece Do I Reach For?

If you need to...Reach forNotes
Compare a handful of textsCosine similarity in NumPyNo database, no service
Search under ~10k documentsIn-memory vectors, brute forceSimpler to build and debug than an index
Search millions of documentsA vector databasePick by where your data already lives
Match exact identifiersKeyword searchSemantic search is the wrong tool here
Match meaning and identifiersHybrid searchWeaviate, Qdrant, Pinecone support it directly
Let an LLM answer from your dataRAGRetrieval quality is the hard part, not the prompt
Squeeze out more accuracyA rerankerUsually a bigger win than a bigger embedding model

Choosing an Embedding Model

  • Starting out: any provider's general-purpose model. They are cheap, and the differences between them will not be your bottleneck.
  • Data cannot leave your infrastructure: an open model you host, such as all-MiniLM-L6-v2.
  • Specialized content — code, legal, finance, medicine: domain-tuned models exist and usually beat general ones inside their domain.

Public benchmarks like MTEB give you a shortlist, not an answer. Write down twenty real questions and the document that should come back for each, then rank your candidates on that; the ordering often differs from the leaderboard. That same set is what you use to compare chunk sizes and similarity metrics, so it is worth building early.

External Resources

MTEB Leaderboard
Hugging Face

Benchmark rankings for embedding models — a shortlist, not an answer

Pretrained Models
SBERT

The open models you can host yourself, with their sizes and tradeoffs

Related Study Packs

production ragml fundamentalsagent harnesses