What Is an Embedding?

What a text embedding is, how similarity becomes geometry, and how my political mail catalog turns each piece into 1,536 numbers with OpenAI's text-embedding-3-small. Part 1 of 3 on the catalog's search.

Systems & Engineering

An embedding is a list of numbers that stands in for a piece of text. A model reads the text and returns a vector of fixed length, and it is trained so that texts with similar meanings produce vectors that point in similar directions. “Protect Medicare for our seniors” and “keep your retirement benefits safe” share almost no words, but their embeddings sit close together. A keyword search can only find “Medicare” where someone wrote “Medicare”. A search over embeddings can find the idea.

This is the first of three posts about the search behind a catalog of political mail I built at work: about 9,800 mailers, digital ads, and handouts that staff search in plain language. This one covers what an embedding is and how the catalog makes one for each piece. The second covers storing and searching embeddings in Postgres, and the third is a production bug that capped the semantic half of every search at 40 documents.

Meaning as geometry

Formally, an embedding model is a function from text to a point in dd-dimensional space:

f:text→Rdf : \text{text} \rightarrow \mathbb{R}^{d}

For the model the catalog uses, d=1,536d = 1{,}536. Every piece of mail becomes a point in a 1,536-dimensional space, and so does every search query.

The individual dimensions aren’t labeled. No single number means “healthcare” or “negative tone”. What carries meaning is position relative to other points: the model learned to place texts with similar meanings near each other. That is the only property search needs. If a query lands near a piece, the piece is probably about what the query is asking for.

Measuring “near”

Nearness is measured by angle, not by straight-line distance. Cosine similarity compares the directions of two vectors and ignores their lengths:

cos⁡θ=q⋅x∥q∥ ∥x∥=∑i=1dqixi∑i=1dqi2 ∑i=1dxi2\cos\theta = \frac{q \cdot x}{\lVert q \rVert \, \lVert x \rVert} = \frac{\sum_{i=1}^{d} q_i x_i}{\sqrt{\sum_{i=1}^{d} q_i^2}\,\sqrt{\sum_{i=1}^{d} x_i^2}}

It runs from −1, for vectors pointing in opposite directions, to 1, for vectors pointing the same way. pgvector works with cosine distance, 1−cos⁡θ1 - \cos\theta, so that smaller means closer and a search can sort in ascending order.

OpenAI’s embeddings come back normalized to length 1. When ∥q∥=∥x∥=1\lVert q \rVert = \lVert x \rVert = 1, the denominator disappears and cosine similarity is just a dot product:

cos⁡θ=∑i=11536qi xi\cos\theta = \sum_{i=1}^{1536} q_i \, x_i

So comparing a query to one document costs 1,536 multiplications and additions. Comparing it to every document in the catalog costs 9,828 times that. That number comes back in the next post.

Which model, and how many numbers

The catalog uses OpenAI’s text-embedding-3-small. OpenAI’s third-generation embedding models come in two sizes: text-embedding-3-small returns 1,536 numbers by default, and text-embedding-3-large returns 3,072. Both accept a dimensions parameter that shortens the vector. The catalog doesn’t pass one, so every embedding is the small model at its full default length:

response = self.openai_client.embeddings.create(
    model="text-embedding-3-small",
    input=text,
)
return response.data[0].embedding  # 1,536 floats

The database enforces the same size. The column is declared as a 1,536-dimension vector, and pgvector rejects a vector of any other length, so a mismatch fails when the vector is written instead of quietly producing nonsense when it is searched:

search_vector = Column(Vector(1536), nullable=True)

What gets embedded

The obvious choice would be to embed each piece’s text. The catalog doesn’t. It embeds a short, labeled description assembled from Claude’s reading of the piece. Every piece goes through the same pipeline before it has a vector:

flowchart TD
  piece["A piece arrives from Dropbox or an upload<br/>(PDF or image)"] --> claude["Claude reads the page images and returns structured JSON:<br/>summary, message, issues, type, tone, names"]
  claude --> rules["A rules layer resolves client, state, and date,<br/>each with a confidence level"]
  rules --> build["build_embedding_text assembles a labeled description"]
  build --> openai["text-embedding-3-small returns 1,536 floats"]
  openai --> pg["Postgres stores the vector<br/>with its model, version, and provenance"]

The description is a set of labeled lines, strongest signal first, with any empty field left out:

lines = list(filter(None, [
    # Core semantic identity — highest signal, weighted first
    line("SUMMARY", summary, "llm_extraction"),
    line("MAIN_MESSAGE", extracted.get("main_message"), "llm_extraction"),
    line("CALL_TO_ACTION", extracted.get("call_to_action"), "llm_extraction"),
    # Issue vocabulary — bridges query language to taxonomy language
    line(
        "ISSUES",
        ", ".join(canonical_terms) if canonical_terms else None,
        "taxonomy_mapping",
    ),
    # … category, document type, campaign type, tone, audience,
    # client, opponent, campaign, state, election year
]))

text = "\n".join(lines) if lines else filename

For an invented mailer, the result looks like this:

SUMMARY: Contrast mailer on prescription drug prices and Medicare.
MAIN_MESSAGE: Protect Medicare for our seniors.
CALL_TO_ACTION: Vote early by November 3.
ISSUES: Medicare, Prescription Drug Prices
CATEGORY: comparison
SUBCATEGORY: Healthcare Policy
DOCUMENT_TYPE: mailer
CAMPAIGN_TYPE: general
TONE: contrast
CLIENT: Committee to Elect Dana Brooks
STATE: OH
ELECTION_YEAR: 2026

Each part of that shape has a reason. The labels are there so the model can tell “Florida” the state from “Florida” the topic. The order follows what the code calls semantic importance, with the summary and main message first. The ISSUES line carries the catalog’s own taxonomy terms, so the words staff search with and the words the catalog files under end up in the same vector. And document type, campaign type, tone, and category come from fixed lists that Claude has to choose from, which makes them low-risk to include.

The two fields the rules layer resolves get more caution. A wrong client or state is worse than none, so the description uses the rules layer’s answer only when it is rated high or medium confidence:

trusted = {"HIGH", "MEDIUM"}
client_trusted = (
    client_canonical and (client_confidence or "").upper() in trusted
)
client = client_canonical if client_trusted else ent.get("client_name")
state_trusted = state and (state_confidence or "").upper() in trusted
doc_state = state if state_trusted else None

An uncertain state is left out entirely. An uncertain client falls back to the name Claude read off the piece.

Every line that makes it into the description is also recorded, with its value, its source, and its confidence, in an embedding_provenance column beside the vector. If a piece turns up in the wrong searches, that record says exactly what text produced its vector, without rerunning the pipeline. The vector also carries the model name and a version number, currently 3, which goes up whenever the way the description is built changes. A backfill script finds every piece on an older version and embeds it again.

The embedding is made once, as the last step of processing a piece. It has to wait for the rules layer, because the description needs the client and state and their confidence levels:

# Generate the embedding now that client_canonical, state, and their
# confidence values are populated. This is the single authoritative embedding
# call for the document — no embedding is generated during AI processing.
synthesized_text, provenance = AIService.build_embedding_text(
    document.ai_analysis,
    filename=document.filename,
    client_canonical=document.client_canonical,
    client_confidence=document.client_confidence,
    state=document.state,
    state_confidence=document.state_confidence,
)
embeddings = ai_service.generate_embeddings_sync(synthesized_text)
document_service.update_document_embeddings_sync(
    document_id,
    embeddings,
    embedding_model=AIService.EMBEDDING_MODEL,
    embedding_version=AIService.EMBEDDING_VERSION,
    embedding_provenance=provenance,
)

Embedding the query

A search query takes a much shorter path. Whatever someone types in the search box is embedded as is, with the same model. Queries of three characters or fewer skip embedding and use keyword search alone. Query embeddings are cached in Redis for an hour, keyed by the lowercased query, so a repeated search doesn’t call OpenAI again:

if len(query.strip()) > 3:
    embed_cache_key = f"embed:{query.strip().lower()}"
    cached_embedding = self.redis_client.get(embed_cache_key)
    if cached_embedding:
        query_embedding = json.loads(cached_embedding)
    else:
        query_embedding = await self.ai_service.generate_embeddings(query)
        self.redis_client.set(
            embed_cache_key, json.dumps(query_embedding), ex=3600
        )
# (error handling trimmed)

That leaves an asymmetry. A piece is embedded as a dozen labeled lines; a query is embedded as a few words. They can still be compared because the same model placed both in the same space, not because they look alike. Whether the asymmetry helps or hurts is something I haven’t measured. The catalog’s search relevance has no formal evaluation yet, and building one is on my list.

Where the vectors go

Every completed piece now has 1,536 numbers. Finding the pieces nearest a query means comparing the query against all of them, or finding a way not to. The catalog keeps its vectors in the same Postgres database as everything else about each piece, and searches them with an extension called pgvector. That is the next post: Vectors in Postgres.