🎯 What You'll Learn

  • Explain what an embedding is, and what it is not
  • Compute a dot product and a cosine similarity by hand, in three dimensions
  • Say precisely why cosine is preferred over the raw dot product for text
  • Trace a RAG pipeline end to end, and write out the exact string that reaches the model
  • Identify the two structural facts about retrieval that every RAG vulnerability later depends on
  • Treat a vector store as what it is: a copy of your documents, with its own access-control problem

Where this page sits

Level: Beginner. Multiplication, addition, and one square root. That is the entire mathematical content.

Rung four of Track C. Nothing here requires the earlier rungs, though the honesty about metrics from rung two carries over. This page has one job: give you enough mechanism that RAG security is reasoning rather than memorised advice.

🛑

The scope commitment, stated up front. This page stops at cosine similarity. No eigenvectors, no singular value decomposition, no dimensionality-reduction theory, no derivation of how embedding models are trained. That boundary is deliberate, and it is sufficient: every retrieval-security question you will meet — can the wrong document be retrieved, can a document influence the model, can data leak out of the index — is answerable with dot products and access control. If you find yourself needing more maths to answer a security question about RAG, the question has almost certainly been misdiagnosed.

Text into numbers

Computers compare numbers. They do not compare meanings. So every system that needs to find "documents about this topic" must first convert text into numbers in a way that puts related meanings close together.

That conversion is an embedding: a fixed-length list of numbers produced by a model from a piece of text. Two properties make it useful:

  1. Fixed length. A three-word query and a five-hundred-word document both come out as vectors of exactly the same size — 384, 768, 1024 numbers, whatever the model produces. This is what makes them comparable at all.
  2. Geometry carries meaning. The model was trained so that texts humans consider related land near each other, and unrelated texts land far apart.

Everything downstream — semantic search, RAG, duplicate detection, alert clustering — is that one trick plus a distance calculation.

⚠️

What an embedding is not. It is not a hash: it is not one-way in any cryptographic sense, and it is not collision-resistant by design. It is not an encryption of the text. It is not anonymisation. The honest way to think about an embedding is as a lossy, structure-preserving copy of the text, and reconstructing text from embeddings is an active area of published research rather than a solved impossibility. Anyone who tells you an embedding index is safe to store outside your data boundary "because it's just numbers" has made a category error that a governance reviewer should catch.

Three dimensions you can hold in your head

Real embeddings have hundreds of dimensions, and no individual dimension means anything a human can name. To learn the arithmetic, we cheat: invent a three-dimensional space with hand-chosen axes. It is not how real embeddings work, but the arithmetic is identical, and the arithmetic is the part you need.

Axes: [network-ish, credential-ish, urgency-ish], each from 0 to 1.

TextVector
A — "reset your password now"[0.1, 0.9, 0.8]
B — "password reset link"[0.1, 0.95, 0.2]
C — "port scan detected"[0.9, 0.1, 0.3]

Intuitively A and B are about the same thing and C is about something else. Now prove it arithmetically.

The dot product

Multiply the vectors element by element, then add up the results. That is all.

A · B = (0.1 × 0.1) + (0.9 × 0.95) + (0.8 × 0.2)
      = 0.01 + 0.855 + 0.16
      = 1.025

A · C = (0.1 × 0.9) + (0.9 × 0.1) + (0.8 × 0.3)
      = 0.09 + 0.09 + 0.24
      = 0.42

Larger means more aligned: 1.025 beats 0.42, so A is more like B than like C. Correct answer, right out of the gate.

Why the dot product alone is not enough

The dot product grows when the numbers grow, not only when the agreement grows. Take A and multiply every component by ten — call it A10, [1.0, 9.0, 8.0]. It points in exactly the same direction and means exactly the same thing:

A10 · B = (1.0 × 0.1) + (9.0 × 0.95) + (8.0 × 0.2) = 10.25

Ten times bigger, for no change in meaning. Whether that bites you depends on the model: many embedding models normalise their output to unit length, and for those the dot product and the cosine already agree. Where the model does not normalise, magnitude varies with the text — and a raw dot-product ranking then rewards whatever makes vectors long rather than what makes them relevant. You do not get to assume which kind you have, and cosine is correct in both cases.

Cosine similarity: direction only

The fix is to divide out the lengths, leaving only the angle between the vectors. The length (magnitude, norm) of a vector is the square root of the sum of its squares:

|A| = √(0.1² + 0.9² + 0.8²) = √(0.01 + 0.81 + 0.64) = √1.46 = 1.2083
|B| = √(0.1² + 0.95² + 0.2²) = √(0.01 + 0.9025 + 0.04) = √0.9525 = 0.9760
|C| = √(0.9² + 0.1² + 0.3²) = √(0.81 + 0.01 + 0.09) = √0.91 = 0.9539

Then:

cos(A, B) = (A · B) / (|A| × |B|) = 1.025 / (1.2083 × 0.9760) = 1.025 / 1.1793 = 0.869
cos(A, C) = (A · C) / (|A| × |C|) = 0.420 / (1.2083 × 0.9539) = 0.420 / 1.1526 = 0.364
cos(B, C) = 0.245 / (0.9760 × 0.9539) = 0.245 / 0.9310                        = 0.263

0.869 versus 0.364. The two password texts are strongly similar; neither is much like the port-scan text. And crucially, cos(A10, B) = 0.869 as well — identical, because scaling a vector does not change its direction. That invariance is the entire reason cosine is the default in text retrieval.

Cosine valueInterpretation
1.0Same direction — as similar as this measure can report
~0.8–0.95Strongly related in practice
~0.0Orthogonal — unrelated
−1.0Opposite direction (rare with modern text embeddings, which mostly occupy a narrow cone)
⚙️

The implementation shortcut worth knowing. If you normalise every vector to length 1 when you store it, then |a| = |b| = 1 and the cosine formula collapses to just the dot product. This is why vector databases store normalised vectors and advertise "inner product" search — it is cosine similarity with the division already done. Related identity, for unit vectors: ‖a − b‖² = 2 − 2·cos(a, b), so ranking by Euclidean distance and ranking by cosine give the same order. Our example checks out: 2 − 2(0.869) = 0.2616, which is exactly the squared distance between the normalised A and B.

🚫

Do not over-read the toy example. Real embedding dimensions are not interpretable. There is no "urgency" axis in a real model, dimension 47 does not mean anything nameable, and anyone presenting a diagram that labels real embedding axes with human concepts is illustrating an idea, not reporting a fact. The three-dimensional space above exists so you can do the arithmetic by hand and for no other reason.

Retrieval: the whole algorithm

With cosine similarity in hand, semantic search is four steps and about fifteen lines of code.

1

Chunk

Split your documents into pieces small enough to embed usefully — typically a few hundred words, often with an overlap between consecutive chunks. Store each chunk's text with an identifier.

2

Index

Run every chunk through the embedding model once and store the resulting vectors. This is the expensive step, and it happens ahead of time.

3

Query

Embed the user's question with the same model. Different models produce incompatible vector spaces. If their output dimensions differ, numpy and every vector database raise, and you find out at once. If the dimensions happen to match, nothing raises: you get nonsense rankings that look exactly like results. The silent case is the dangerous one.

4

Rank and take the top k

Compute cosine similarity between the query vector and every stored vector, sort descending, keep the best k — typically 3 to 10.

At small scale, step four compares against every vector, which is called brute-force or exact search. For the corpora you will run locally, brute force is both correct and fast, and you should prefer it. At very large scale, systems use approximate nearest-neighbour indexes — HNSW and IVF are the names you will see — which trade exactness for speed. Two things are worth knowing about them and no more: they are approximate, so the top result is not guaranteed to be the true top result, and that approximation is a behaviour rather than a bug, which occasionally matters when a retrieval failure is being investigated.

Chunking deserves one honest warning, because it causes more real-world retrieval failure than any algorithmic subtlety. If the answer to a question spans a chunk boundary, neither chunk scores well and neither is retrieved — the system confidently answers from worse sources instead of saying it lacks the material. Overlap between chunks mitigates it. Nothing eliminates it.

RAG: what actually reaches the model

Retrieval-augmented generation is retrieval plus one more step: paste what you retrieved into the prompt.

┌──────────────┐    ┌─────────────┐    ┌──────────────────┐
│ user question│───▶│ embed query │───▶│ cosine vs index  │
└──────────────┘    └─────────────┘    └────────┬─────────┘
                                                │ top-k chunks
                                                ▼
                    ┌────────────────────────────────────────┐
                    │ PROMPT ASSEMBLY                        │
                    │  system instructions                   │
                    │  + retrieved chunk 1                   │
                    │  + retrieved chunk 2                   │
                    │  + retrieved chunk 3                   │
                    │  + the user's question                 │
                    └───────────────────┬────────────────────┘
                                        ▼
                              ┌──────────────────┐
                              │       LLM        │───▶ answer
                              └──────────────────┘

The single most clarifying thing you can do is print the assembled prompt. It looks like this:

You are a helpful security assistant. Answer using only the context below.

--- CONTEXT ---
[chunk 41] Password resets must be approved by the service desk after
identity verification via the callback procedure...
[chunk 12] Failed authentication events are logged to the SIEM under
event ID 4625 and retained for 400 days...
--- END CONTEXT ---

Question: how do I reset a user's password?

Now look at that string and notice the two facts that every RAG vulnerability in the rest of this curriculum depends on.

Fact one: it is all one stream of tokens. The model does not receive your system instructions on a privileged channel and the retrieved chunks on an untrusted one. There is no channel. There is a single sequence of text, and the delimiters --- CONTEXT --- are just more text — as forgeable as any other characters. The trust boundary you drew in your architecture diagram does not exist inside the model. That is the mechanism behind prompt injection — LLM01:2026 in the OWASP Top 10 for LLM Applications — and it is why the fix is never "add a stronger delimiter".

Fact two: retrieval decides what the model sees, and a document decides what gets retrieved. Whoever can write into the indexed corpus — a wiki page, a ticket comment, a PDF in a shared drive, a support email — has partial control over the contents of future prompts. They do not need access to your application at all. The corpus is an input path, and it is very rarely treated as one during threat modelling. Text planted in a document to steer a later retrieval and generation is invisible to the person asking the question. That is LLM01:2026 again, arriving indirectly — the attacker never types into your application, they write into your corpus and wait. (OWASP material is licensed CC BY-SA 4.0; the descriptions here are our paraphrase, not their text.)

🔐

The failure that shows up most often in real deployments is duller than either of those, and it is an access-control failure. A vector index built from "all of SharePoint" has, by default, no notion of who may see which chunk. Retrieval finds the most similar chunk, not the most similar chunk this user is entitled to read, so a well-meaning question can surface content the asker could never have opened directly — material reachable by the model that should never have been reachable at all, which is LLM08:2026 Hidden Context Exposure in OWASP's list. The control is per-user filtering applied inside the retrieval query, not a post-hoc filter on the generated answer and certainly not an instruction in the system prompt. Ask of any RAG system: whose permissions are enforced at retrieval time, and by what code?

Build it: CPU-only, one compose file

Everything below runs on a 16 GB laptop with no GPU.

# docker-compose.yml — CPU-ONLY.
services:
  ollama:
    image: ollama/ollama:latest
    volumes:
      - ollama:/root/.ollama
    ports:
      - "11434:11434"

  lab:
    image: python:3.12-slim
    working_dir: /work
    volumes:
      - ./:/work
    depends_on:
      - ollama
    environment:
      OLLAMA_HOST: "http://ollama:11434"
    command: sh -c "sleep 5 && python retrieve.py"

volumes:
  ollama:
docker compose up -d ollama
# A small dedicated embedding model. Check the real size yourself after pulling —
# do not trust a figure written on a web page, including this one.
docker compose exec ollama ollama pull nomic-embed-text
docker compose exec ollama ollama list
# retrieve.py — a complete retriever. Standard library plus one HTTP call.
import json, math, os, urllib.request

OLLAMA = os.environ.get("OLLAMA_HOST", "http://localhost:11434")
MODEL  = "nomic-embed-text"

def embed(text):
    body = json.dumps({"model": MODEL, "prompt": text}).encode()
    req  = urllib.request.Request(f"{OLLAMA}/api/embeddings", body,
                                  {"Content-Type": "application/json"})
    with urllib.request.urlopen(req) as r:
        return json.load(r)["embedding"]

def dot(a, b):    return sum(x * y for x, y in zip(a, b))
def norm(a):      return math.sqrt(dot(a, a))
def cosine(a, b): return dot(a, b) / (norm(a) * norm(b))

CHUNKS = [
    "Password resets require service desk approval and callback verification.",
    "Failed logon attempts are recorded as event ID 4625 in the Windows log.",
    "Nmap performs host discovery before scanning ports on a target subnet.",
    "Retention for authentication logs is 400 days in the central SIEM.",
    "Phishing reports go to the abuse mailbox and are triaged within an hour.",
]

index = [(text, embed(text)) for text in CHUNKS]          # step 2: index once

def search(question, k=2):                                 # steps 3 and 4
    q = embed(question)
    scored = [(cosine(q, v), text) for text, v in index]
    return sorted(scored, reverse=True)[:k]

for hit in search("how do I get my password changed?"):
    print(f"{hit[0]:.3f}  {hit[1]}")

Run it and read the scores. The password chunk should rank first by a clear margin, and the Nmap chunk should sit at the bottom. Then try a question the corpus cannot answer — "what is our ransomware payment policy?" — and watch it return chunks anyway, with scores that are lower but not zero.

🔎

That last experiment is the important one. Retrieval always returns something. top_k is a fixed count, not a relevance judgement, so a query with no good match still yields the k least-bad chunks — and if the pipeline pastes them into a prompt without checking the score, the model will answer a question your documents never covered, using material that merely resembles it. The mitigation is a similarity floor: discard hits below a threshold and let the system say it does not know. Choosing that floor is exactly the operating-point problem from rung three, in a new costume.

Grade yourself

# test_retrieval.py — invariant-based grading. No model output is compared as text.
import math
from retrieve import cosine, search

A = [0.1, 0.9, 0.8]
B = [0.1, 0.95, 0.2]
C = [0.9, 0.1, 0.3]

def test_hand_computed_cosines():
    assert abs(cosine(A, B) - 0.869) < 0.005
    assert abs(cosine(A, C) - 0.364) < 0.005

def test_scaling_a_vector_does_not_change_cosine():
    A10 = [v * 10 for v in A]
    assert abs(cosine(A, B) - cosine(A10, B)) < 1e-9

def test_cosine_is_symmetric_and_self_similarity_is_one():
    assert abs(cosine(A, B) - cosine(B, A)) < 1e-12
    assert abs(cosine(A, A) - 1.0) < 1e-9

def test_ranking_invariant_not_text_equality():
    # Grades the ORDER the retriever produces, never the model's wording.
    top = search("how do I get my password changed?", k=1)[0][1]
    assert "Password resets" in top

def test_unanswerable_query_still_returns_k_results():
    # The point of the test: retrieval cannot abstain on its own.
    assert len(search("what is our ransomware payment policy?", k=3)) == 3

Grading mechanism: hidden pytest, invariants only. Three of the five tests are pure arithmetic against the hand-computed values on this page and are fully deterministic. The two that touch the embedding model assert a ranking property and a result count — never a string produced by a model. That distinction is the rule for every lab in this curriculum: grade the invariant, because model output is not stable and a test that pins it will fail for reasons that teach nobody anything.

🚫

What this does not prove. Five chunks is not a corpus and brute-force cosine over five vectors is not a vector database. Nothing here demonstrates that your production retrieval is correct, that your chunking strategy is sound, or that your index is access-controlled — the lab has no access control at all, by design, because the absence is the lesson. And the three-dimensional hand example is a teaching device, not a model.

Self-check

1

Arithmetic

Compute the cosine similarity of [1, 0, 0] and [0, 1, 0] in your head. What does the answer mean?

2

Why cosine

A colleague ranks documents by raw dot product and finds that long documents always win. Explain the cause in one sentence and give the fix.

3

Same model

A pipeline indexes chunks with one embedding model and embeds queries with a different one. Under what condition does it raise an error, and what happens when it does not?

4

The trust boundary

Point at the exact place in the assembled prompt where the boundary between instructions and untrusted data is enforced. What did you find?

5

Access control

Your RAG assistant indexes the whole intranet. A contractor asks it a plausible HR question. State the control that must exist and where in the pipeline it must run.

Cheat sheet

TermMeaning
EmbeddingFixed-length vector produced from text by a model
Dot productElement-wise multiply, then sum. Grows with magnitude and alignment
Norm√(sum of squares) — the vector's length
Cosine similaritydot(a,b) divided by norm(a) × norm(b) — direction only, magnitude-invariant
Normalised vectorsLength 1, so cosine = dot product. Why vector DBs use inner product
ChunkA slice of a document, embedded and stored as one unit
top-kThe k highest-scoring chunks. A count, never a relevance judgement
ANN (HNSW, IVF)Approximate search for scale. Approximate on purpose
RAGRetrieve, paste into the prompt, generate
Similarity floorThe threshold below which you should retrieve nothing and say so

Where to go next

NextWhy
AI Literacy: LLM MechanicsThe final rung. What happens to that assembled prompt once the model receives it.
AI Literacy: Thresholds and ConfidenceThe similarity floor above is a threshold decision — the same trade, in a new domain.
Prompt Injection and RAG security labsOnce rung five is done, you have everything needed to attack and defend this pipeline.

Rung complete. You can compute a cosine similarity by hand, explain why it beats the raw dot product, trace a RAG pipeline from question to assembled prompt, and name the two structural facts — one token stream, and a corpus that is an input path — that every retrieval vulnerability rests on. One rung left.

Sign into track progress and send feedback.