7RAG and Agentic Search

Text embeddings

5 min read977 words

You've chunked a document. The chunks are sitting in a list. A user asks a question. How do you find the chunks that are actually relevant to that question — without relying on exact keyword matches, which famously fail when the user says "automobile" and your document says "car"? The answer is text embeddings, and they're what makes semantic search work.

Semantic search vs keyword search

Keyword search looks for exact matches. A user searches for "authentication" and gets passages containing the word "authentication." It's fast, deterministic, and completely breaks the moment a user types "login" or "sign-in" — both of which mean the same thing but share no letters with "authentication."

Semantic search looks for passages that mean the same thing as the query, even when the words don't match. It relies on converting both the query and every chunk into a numerical representation that captures meaning, then finding chunks whose numerical representations are close to the query's.

That numerical representation is the text embedding.

What a text embedding is

A text embedding is a long list of numbers that encodes the meaning of a piece of text. The list is typically hundreds or thousands of numbers long, and each number falls between -1 and +1. Every one of those numbers is a kind of "score" for some quality of the input — some dimension of meaning that the embedding model has learned during training.

The process:

  1. You feed a string into an embedding model.
  2. The model outputs a fixed-size list of floats.
  3. Similar strings produce similar lists. Unrelated strings produce very different ones.

The crucial caveat: you don't know what each number represents. It's tempting to imagine that dimension 0 represents "how medical is this text," dimension 1 represents "how angry is this text," and so on. That's a useful mental model, but it's not true — the actual dimensions are whatever the model learned during training, and most of them are not interpretable to humans. What matters in practice is that the distances between embeddings track the distances between meanings.

How search uses embeddings

The recipe for semantic search:

  1. Embed every chunk once, during preprocessing. Store the chunks and their embeddings.
  2. When a user queries, embed the query using the same model.
  3. Compare the query embedding against every stored chunk embedding using a similarity metric (cosine similarity, typically).
  4. Return the top N chunks with the highest similarity scores.

The next lesson covers the similarity math in detail. For now, the important thing is that the distance between two embeddings tracks the semantic distance between their source texts. "Authentication" and "login" end up as nearby vectors; "authentication" and "banana bread" don't.

VoyageAI for embeddings

Anthropic doesn't currently provide an embedding API, so we use VoyageAI — a dedicated embedding provider with a strong general-purpose model. The setup is a small detour:

  1. Sign up at voyageai.com (free tier is plenty for the course).
  2. Create an API key.
  3. Add it to your .env file as a separate variable from your Anthropic key:
VOYAGE_API_KEY="your-voyage-key-here"

Install the library alongside the Anthropic SDK:

pip install voyageai

Generating an embedding

Set up the client and write a helper function:

from dotenv import load_dotenv
import voyageai

load_dotenv()
client = voyageai.Client()

def generate_embedding(text, model="voyage-3-large", input_type="query"):
    result = client.embed([text], model=model, input_type=input_type)
    return result.embeddings[0]

Three things to note:

  • model="voyage-3-large" is VoyageAI's recommended general-purpose model at the time of writing. Newer versions will release — check VoyageAI's docs for the current recommendation when you build.
  • input_type="query" tells the model that this text is a search query, not a document. VoyageAI's models are trained to embed queries and documents slightly differently for better retrieval. When embedding your chunks for storage, use input_type="document" instead.
  • The result is a list of floats, typically around 1,024 dimensions for voyage-3-large.

Test it:

embedding = generate_embedding("How do I reset my password?")
print(len(embedding))  # e.g. 1024
print(embedding[:5])    # [0.012, -0.057, 0.104, ...]

That list of ~1,024 numbers is the numerical fingerprint of the query. Every chunk you want to search through will have its own fingerprint, and similarity search is just comparing those fingerprints.

What happens next

Generating embeddings is half the picture — the easy half. The interesting part is comparing them to each other efficiently and correctly. In the next lesson you'll walk through the full RAG flow end to end, including:

  • Normalising embeddings so the comparison math is clean.
  • Cosine similarity — the metric that turns two embeddings into a single "how close are these?" number.
  • Storing embeddings in a vector database so you're not recomputing similarities against thousands of chunks from scratch on every query.
  • Assembling the retrieved chunks into a prompt for the final Claude call.

Everything you've done so far — the chunking, the embedding — is preprocessing. The next lesson is where it all comes together into an answer.

Key Takeaways

  • 1Keyword search breaks when the user's wording doesn't match the document's wording; semantic search fixes that by comparing meaning instead of exact strings.
  • 2A text embedding is a fixed-length list of numbers (typically 1,024+ dimensions) that encodes the meaning of a piece of text, with similar texts producing similar vectors.
  • 3The individual dimensions of an embedding are not human-interpretable — what matters is that distances between embeddings track distances between meanings.
  • 4Anthropic does not currently provide embeddings; use VoyageAI with a separate API key stored in your .env file alongside ANTHROPIC_API_KEY.
  • 5voyage-3-large is the recommended general-purpose model, and the input_type parameter should be 'query' for user questions and 'document' for stored chunks.
  • 6Generating an embedding is just one function call — the interesting work is in comparing them efficiently and retrieving the most similar stored chunks.