Chapter 38 · Retrieval

Embeddings, for someone shipping

What a vector represents, the four properties that matter in production, and the three intuitions that are wrong in ways that cost you retrieval quality.

11 min read

Enough about embeddings to build well, and no more. This is not a chapter about how they are trained; it is about the properties you rely on and the ones you must not.

It assumes the first ingest is done, so there are 44,387 real vectors to reason about.

What a vector is

An embedding model maps text to a fixed-length list of numbers — 1,536 of them for the model in the config — positioned so that texts with similar meanings sit near each other.

"Near" is cosine distance: the angle between two vectors, ignoring their length.

// The whole of similarity search, in one function. export function cosineSimilarity(a: number[], b: number[]): number { let dot = 0, normA = 0, normB = 0 for (let i = 0; i < a.length; i++) { dot += a[i] * b[i] normA += a[i] * a[i] normB += b[i] * b[i] } return dot / (Math.sqrt(normA) * Math.sqrt(normB)) } // Which is why most providers return unit-length vectors: when |a| = |b| = 1, // cosine similarity IS the dot product, and the dot product is much faster.
-- pgvector gives you three operators. Use the one matching how the vectors -- were produced, or every score is subtly wrong. -- <=> cosine distance (1 - cosine similarity) -- <#> negative inner product -- <-> Euclidean distance select 1 - (embedding <=> $1) as similarity from chunks order by embedding <=> $1;

⚠️ The index must be built for the same operator the query uses. An index created with vector_l2_ops is not used by a <=> query, and the symptom is a sequential scan: correct results, catastrophic latency, no error.

The four properties that matter

Fixed length regardless of input

A three-word chunk and a 500-word chunk both become 1,536 numbers. The longer one's meaning is averaged into the same budget, which is the mechanism behind the dilution argument in chapter 30 — not a metaphor, the literal arithmetic.

Similarity is relative, never absolute

A score of 0.72 means nothing on its own. It means something relative to the other scores for the same query, and relative to the distribution for this model on this corpus.

-- Calibrate before choosing a score floor. Run it on questions whose answers -- you know, and on nonsense questions, then compare the distributions. select percentile_cont(0.50) within group (order by sim) as p50, percentile_cont(0.90) within group (order by sim) as p90, percentile_cont(0.99) within group (order by sim) as p99, max(sim) from ( select 1 - (embedding <=> $1) as sim from chunks limit 5000 ) s;

On Atlas's corpus with this model: p50 around 0.18, p99 around 0.52, best match for a well-posed question 0.6–0.8. A floor of 0.3 — the number chapter 9 guessed — sits above the 99th percentile of random chunks, which is why it works. It is a corpus-and-model constant, not a universal one.

Symmetric by default, and questions are not answers

This is the subtle one and it is worth dwelling on.

A general-purpose embedding model maps a question and its answer into the same space, and the two are not especially close. "How long do customers have to return damaged goods?" and "Damaged goods may be returned within 30 days of delivery" share few words and differ in form — one is interrogative, one declarative.

Three mitigations, in increasing order of effort:

// 1. Asymmetric models: separate query and document encoders, or a prefix // that tells the model which side it is embedding. Free if your model // supports it, and frequently the single largest easy win. const docVector = await embed(`passage: ${chunk.content}`) const queryVector = await embed(`query: ${question}`) // 2. Embed the heading path with the chunk, which is chapter 32's result: // it puts the question's topic words into the document side. const embedText = `${headingPath.join(' > ')}\n\n${content}` // 3. Hypothetical document embedding: have a model write a plausible answer // to the question and embed THAT, so you are comparing answer to answer. // Costs a model call per query. Chapter 46.

Mitigation 2 is the one already in place, and it is a large part of why that change moved recall@12 from 0.84 to 0.91.

Numbers, names and codes embed badly

An embedding captures meaning, and SKU-4411 has very little meaning. Models tokenise it into fragments, and SKU-4411 and SKU-4412 end up almost identical — which is exactly backwards for a catalogue.

// Test it on your own corpus rather than trusting the general claim. const [a, b, c] = await embed(['SKU-4411', 'SKU-4412', 'SKU-9983']) console.log(cosineSimilarity(a, b), cosineSimilarity(a, c)) // 0.981 0.974 — essentially indistinguishable

This is the single strongest argument for hybrid retrieval: exact tokens need exact matching, and no embedding model will do that job. Any corpus with part numbers, account codes, clause numbers, error codes or version strings needs keyword search alongside vectors, and for Atlas's product specifications that is most questions.

Three wrong intuitions

"A higher score means a better answer." It means textually closer in this model's space. A chunk that quotes the question and answers it wrongly scores higher than one that answers it correctly in different words.

"Similar embeddings mean similar meaning." Mostly. Negation is the famous failure: "damaged goods may be returned" and "damaged goods may not be returned" are typically above 0.95 similar. The one token that reverses the meaning is one token among hundreds.

// Worth measuring once on your own model, because the magnitude surprises people. const [yes, no] = await embed([ 'Damaged goods may be returned within 30 days.', 'Damaged goods may not be returned under any circumstances.', ]) console.log(cosineSimilarity(yes, no)) // ~0.96

⚠️ The consequence is operational: retrieval will cheerfully return the clause that says the opposite of what the reader needs. Only the model reading the passage distinguishes them, which is a reason to send the whole passage rather than a summary, and a reason chapter 110 measures groundedness rather than assuming it.

"More dimensions are better." More dimensions cost more storage, more memory and more compute per comparison, and the quality relationship is weak above a point. A 1,536-dimension model and a 3,072-dimension one from the same family typically differ by a point or two of recall and double the index size.

// Some models support truncation: a shorter prefix of the vector that is // still usable. Measure before assuming the full length is needed. const full = await embed([text], { dimensions: 3072 }) const short = await embed([text], { dimensions: 1024 }) // Score both against the test set. On Atlas's corpus the gap was 0.008 recall.

Storage and cost, concretely

// 1,536 dimensions × 4 bytes (float32) = 6,144 bytes per vector. const bytesPerVector = 1536 * 4 const chunks = 44_387 console.log(`${((bytesPerVector * chunks) / 1e6).toFixed(0)} MB of vectors`) // 273 MB
1,536 dim3,072 dim
Per vector6.1 KB12.3 KB
44,387 chunks273 MB546 MB
10M chunks61 GB123 GB

At Atlas's scale the choice is irrelevant. At ten million chunks it is an infrastructure decision, and that is the scale at which quantisation starts to matter:

-- halfvec: 2 bytes per dimension instead of 4. Halves storage and memory for -- a typically small recall cost — measure it, as always, against the test set. alter table chunks alter column embedding type halfvec(1536); create index on chunks using hnsw (embedding halfvec_cosine_ops);

The operational rules

Four, and they are the whole of what goes wrong in production.

One model per index. Vectors from different models are not comparable. Mixing them produces results that are not wrong in a detectable way — they are just ranked by nothing.

-- Make it impossible rather than remembered. alter table chunks add column embedding_model text not null default 'text-embedding-3-small'; alter table chunks add constraint chunks_single_model check (embedding_model = 'text-embedding-3-small');

Normalise consistently. Whatever preprocessing is applied at ingest — lowercasing, whitespace collapsing, the heading-path prefix — must be applied identically at query time. A mismatch degrades every result slightly and silently.

// So preprocessing lives in one function used by both sides, never twice. export const prepareForEmbedding = (text: string) => text.replace(/\s+/g, ' ').trim().slice(0, MAX_EMBED_CHARS)

Batch, and sort by the returned index. Covered in chapter 9; the sort matters because a provider is not obliged to return results in request order.

Cache query embeddings. The same questions recur, and an embedding is deterministic for a given model and input.

// A small LRU cuts median latency by 50-150ms on repeated questions. const queryCache = new LRU<string, number[]>({ max: 10_000, ttl: 3_600_000 }) export async function embedQuery(question: string): Promise<number[]> { const key = prepareForEmbedding(question) const hit = queryCache.get(key) if (hit) return hit const [vector] = await embed([key]) queryCache.set(key, vector) return vector }

The failure mode: the index built with the wrong operator

Atlas's index is created during the first migration:

-- ❌ The default ops class is L2, and nothing warns you. create index chunks_embedding on chunks using hnsw (embedding vector_l2_ops);

Queries use <=>, which is cosine. Postgres cannot use an L2 index for a cosine ordering, so it falls back to a sequential scan over 44,387 rows.

Everything works. Results are correct — a sequential scan computes exact distances, so quality is better than the approximate index would give. The only symptom is that retrieval takes 340ms instead of 12ms, which inside a four-second budget nobody notices.

At 44,387 chunks it is invisible. At two million it is eleven seconds and an incident.

-- The check. Run it after every migration that touches the index. explain (analyze, buffers) select id from chunks order by embedding <=> $1 limit 12; -- Look for 'Index Scan using chunks_embedding'. 'Seq Scan' means the ops -- class does not match the operator.
// And as a test, so a future migration cannot reintroduce it. test('vector search uses the index', async () => { const { rows } = await db.query( `explain select id from chunks order by embedding <=> $1 limit 12`, [toVector(await embed(['any question']).then((v) => v[0]))], ) expect(rows.map((r) => r['QUERY PLAN']).join('\n')).toMatch(/Index Scan/) })

A correctness-preserving performance bug is the hardest kind to find, because every test passes. Assert on the query plan, not only on the results.

Key points

  • Cosine similarity is the angle between vectors. The index's ops class must match the query's operator or you silently get a sequential scan.
  • Fixed length regardless of input is why long chunks dilute. It is arithmetic, not metaphor.
  • Scores are relative. Calibrate the floor against your own corpus and model; 0.3 is an Atlas constant, not a universal one.
  • Questions and answers are not close by default. Use asymmetric prefixes if available, and embed the heading path.
  • Numbers, SKUs and codes embed almost identically. That is the strongest argument for hybrid retrieval.
  • Negation barely moves a vector. Retrieval will return the clause that says the opposite; only the model reading it can tell.
  • More dimensions buy little and cost linearly. Measure truncation before paying for the full length.
  • One model per index, enforced by a constraint. Identical preprocessing on both sides, in one shared function.
  • Cache query embeddings. Assert on the query plan in a test.

Next: choosing an embedding model — the criteria that matter, and what changing one later actually costs.