# Similarity Search

Course: [Retrieval-Augmented Generation](https://www.devtutorials.ai/learn/rag) · Section: Vector Search
Source: https://www.devtutorials.ai/learn/rag/vector-search/similarity-search

> Finding the nearest vectors: cosine, dot product and Euclidean distance, top-k selection, a flat in-memory index, and visualizing vector space with PCA, query projection and clusters.

When you search for "coffee near me" on a map app, it does not check every café on Earth one by one in your head. It knows where you are, measures the distance to cafés, and shows the closest few. If you could see the map, you would notice that cafés cluster on busy streets, and that your "nearest five" often sit in one or two clusters.

**Similarity search** is the same idea for vectors: measure how close each chunk is to the question, and return the closest few.

**The main idea:** compare vectors with a **distance metric**: **cosine similarity**, **dot product** or **Euclidean distance**. Keep the **top-k** closest chunks. A simple **in-memory index** is enough for thousands of chunks. You can **see** the vector space by squeezing it to 2D with **PCA**, project the query into it, and watch its nearest neighbours and clusters.

## A Real-World Example

Brightgrid's developers were puzzled: vector search kept returning a long troubleshooting article for unrelated questions. They printed the scores and found the cause. They were using the **dot product** on vectors that were **not normalized**. The long article's vector was simply **bigger**, so its dot product was high for almost any question.

After normalizing every vector, so they all have length 1, the dot product equals cosine similarity, and the long article only showed up for Wi-Fi questions.

They also drew the chunks on a 2D map. Account articles formed one cluster, lamp troubleshooting another, and billing a third. When a question landed between two clusters, they knew to expect mixed results, and could see it straight away.

**In simple words:** pick the right ruler, keep the closest few, and draw the map to see what is going on.

## What You Will Learn

In this lesson, you will learn:

1. The three distance metrics: cosine similarity, dot product and Euclidean distance, and when they agree.
2. Nearest neighbour search: top-k selection and an in-memory vector index.
3. Vector space visualization: 2D PCA scatter plots, projecting a live query, and nearest neighbour clusters.

The RAG Lab's **Vector map** tab draws every chunk as a dot, coloured by category, after squeezing its vector to 2D with PCA. Type a question and its ★ appears, with dashed lines to its top-k neighbours. Switch the metric between cosine, dot and Euclidean, and switch between word-based and meaning-based vectors.

## Distance Metrics

### Cosine Similarity Calculations

**Cosine similarity** measures the **angle** between two vectors and ignores their length. Two vectors pointing the same way score 1, at right angles 0, opposite -1. It is the most common choice for text embeddings, because it compares meaning (direction) without being fooled by length.

Formula: cosine(a, b) = (a · b) / (|a| × |b|), where a · b is the dot product and |a| is the length.

### Dot Product Metrics

The **dot product** multiplies matching numbers and adds them up. It grows with the angle **and** with the lengths. Some models are trained so that length carries meaning, for example how important or confident a text is. Those models say "use dot product" in their documentation. For normalized vectors, the dot product is exactly the cosine, and it is the fastest to compute.

### Euclidean Distance Measurement

**Euclidean distance** is the straight-line distance between the tips of two vectors, like measuring with a ruler. Smaller means closer. For normalized vectors, it ranks results in exactly the same order as cosine.

```js runnable title="Three metrics, raw and normalized"
// Three ways to compare vectors. They agree on normalized vectors, but not on raw ones.
const query = [1, 1];
const docs = {
  short: [1, 1.2], // same direction, small
  long: [4, 2], // different direction, big
};

const dot = (a, b) => a.reduce((s, x, i) => s + x * b[i], 0);
const len = (a) => Math.hypot(...a);
const cosine = (a, b) => dot(a, b) / (len(a) * len(b));
const euclid = (a, b) => Math.hypot(...a.map((x, i) => x - b[i]));

for (const [name, v] of Object.entries(docs)) {
  console.log(
    `${name.padEnd(5)} cosine ${cosine(query, v).toFixed(3)}  dot ${dot(query, v).toFixed(2)}  euclidean ${euclid(query, v).toFixed(2)}`,
  );
}
console.log(
  "cosine and euclidean prefer 'short'; dot prefers 'long' because it is bigger.",
);

const unit = (a) => a.map((x) => x / len(a));
const q = unit(query);
for (const [name, v] of Object.entries(docs)) {
  const u = unit(v);
  console.log(
    `normalized ${name.padEnd(5)} cosine ${cosine(q, u).toFixed(3)}  dot ${dot(q, u).toFixed(3)}  euclidean ${euclid(q, u).toFixed(3)}`,
  );
}
```

Expected output:

```text
short cosine 0.996  dot 2.20  euclidean 0.20
long  cosine 0.949  dot 6.00  euclidean 3.16
cosine and euclidean prefer 'short'; dot prefers 'long' because it is bigger.
normalized short cosine 0.996  dot 0.996  euclidean 0.091
normalized long  cosine 0.949  dot 0.949  euclidean 0.320
```

Use the metric your embedding model recommends. If it does not say, normalize your vectors and use cosine or dot product. Then all three metrics agree on the order, and you cannot be fooled by vector length.

## Nearest Neighbor Search

### Top-K Selection Mechanics

**Top-k** means "the k best results". Sorting every score works, but at scale it is wasteful. A smarter way keeps a small list of the best k seen so far while scanning. A new score only enters the list if it beats the worst one in it.

```js runnable title="Keep the top 3 while scanning"
// Top-k selection: keep only the k best scores while scanning, instead of sorting everything.
const scores = [0.31, 0.82, 0.12, 0.77, 0.45, 0.91, 0.08, 0.66];

function topK(list, k) {
  const best = []; // kept sorted, best first, never longer than k
  list.forEach((score, id) => {
    if (best.length < k || score > best[best.length - 1].score) {
      best.push({ id, score });
      best.sort((a, b) => b.score - a.score);
      if (best.length > k) {
        best.pop();
      }
    }
  });
  return best;
}

console.log(
  topK(scores, 3)
    .map((b) => `#${b.id}=${b.score}`)
    .join("  "),
);
```

Expected output:

```text
#5=0.91  #1=0.82  #3=0.77
```

### In-Memory Vector Indexing

For up to tens of thousands of chunks, the simplest index is the best: keep all vectors in memory, compare the query with every one, and keep the top k. This is called **flat** or **brute-force** search. It is **exact** (it never misses the true nearest neighbour), easy to write, and fast enough at this size. The RAG Lab uses a flat index.

```js runnable title="A tiny in-memory index"
// A tiny in-memory vector index: add vectors with ids, search by cosine.
class FlatIndex {
  constructor() {
    this.ids = [];
    this.vectors = [];
  }
  add(id, v) {
    const n = Math.hypot(...v);
    this.ids.push(id);
    this.vectors.push(v.map((x) => x / n)); // store normalized, so dot product = cosine
  }
  search(q, k) {
    const n = Math.hypot(...q);
    const qn = q.map((x) => x / n);
    return this.vectors
      .map((v, i) => ({
        id: this.ids[i],
        score: v.reduce((s, x, j) => s + x * qn[j], 0),
      }))
      .sort((a, b) => b.score - a.score)
      .slice(0, k);
  }
}

const index = new FlatIndex();
index.add("reset-password#1", [0.9, 0.1, 0.0]);
index.add("lamp-flickering#1", [0.1, 0.9, 0.1]);
index.add("wifi-troubleshooting#1", [0.2, 0.7, 0.6]);
index.add("brightgrid-plus#2", [0.1, 0.0, 0.1]);

for (const hit of index.search([0.1, 0.8, 0.5], 2)) {
  console.log(hit.id, hit.score.toFixed(3));
}
```

Expected output:

```text
wifi-troubleshooting#1 0.983
lamp-flickering#1 0.902
```

The next lesson covers what to do when you have millions of vectors.

## Vector Space Visualization

### 2D PCA Spatial Scatter Plots

You cannot draw 384 dimensions. **PCA** (principal component analysis) finds the two directions in which the points **spread out the most**, and places every point by how far it lies along those two directions. The result is a 2D map that keeps as much of the structure as two dimensions can.

```js runnable title="PCA from 4 dimensions to 2"
// PCA squeezes many dimensions into 2, keeping the directions where points differ most.
const points = {
  "sign in": [0.9, 0.8, 0.1, 0.0],
  password: [0.8, 0.9, 0.0, 0.1],
  flicker: [0.1, 0.0, 0.9, 0.8],
  dimmer: [0.0, 0.1, 0.8, 0.9],
  parcel: [0.5, 0.4, 0.5, 0.4],
};
const names = Object.keys(points);
const X = Object.values(points);
const d = X[0].length;
const mean = X[0].map((_v, j) => X.reduce((s, r) => s + r[j], 0) / X.length);
const C = X.map((r) => r.map((x, j) => x - mean[j])); // centre the data

// Power iteration finds the direction of greatest spread (the first principal component).
function component(exclude) {
  let w = Array.from({ length: d }, (_v, j) => j + 1);
  for (let it = 0; it < 100; it++) {
    const proj = C.map((r) => r.reduce((s, x, j) => s + x * w[j], 0));
    let next = w.map((_v, j) => C.reduce((s, r, i) => s + r[j] * proj[i], 0));
    for (const e of exclude) {
      const dot = next.reduce((s, x, j) => s + x * e[j], 0);
      next = next.map((x, j) => x - dot * e[j]);
    }
    const n = Math.hypot(...next);
    w = next.map((x) => x / n);
  }
  return w;
}
const pc1 = component([]);
const pc2 = component([pc1]);
names.forEach((name, i) => {
  const x = C[i].reduce((s, v, j) => s + v * pc1[j], 0);
  const y = C[i].reduce((s, v, j) => s + v * pc2[j], 0);
  console.log(
    `${name.padEnd(8)} (${x.toFixed(2).padStart(5)}, ${y.toFixed(2).padStart(5)})`,
  );
});
```

Expected output:

```text
sign in  (-0.80, -0.08)
password (-0.80,  0.12)
flicker  ( 0.80, -0.08)
dimmer   ( 0.80,  0.12)
parcel   ( 0.00, -0.08)
```

"sign in" and "password" end up on one side, "flicker" and "dimmer" on the other, and "parcel", which is a bit of everything, in the middle.

### Live Query Vector Projection

Once PCA has found its two directions, you can place **any new vector** on the same map, including a question. That is how the RAG Lab draws the ★. It is a great way to see why a search returned what it did: if the ★ lands between two clusters, results from both are likely.

### Nearest Neighbor Cluster Visualization

**Clusters** are groups of vectors that sit close together, usually one topic each. **k-means** is a simple way to find them: guess some centres, assign each point to its nearest centre, move each centre to the middle of its points, and repeat.

```js runnable title="Find clusters with k-means"
// Clusters: group points by their nearest centre (one round of k-means).
const points = [
  { id: "sign in", v: [0.9, 0.1] },
  { id: "password", v: [0.8, 0.2] },
  { id: "two-step", v: [0.7, 0.1] },
  { id: "flicker", v: [0.1, 0.9] },
  { id: "dimmer", v: [0.2, 0.8] },
];
let centres = [
  [1, 0],
  [0, 1],
];
const dist = (a, b) => Math.hypot(a[0] - b[0], a[1] - b[1]);

for (let round = 1; round <= 2; round++) {
  const groups = centres.map(() => []);
  for (const p of points) {
    const nearest = centres
      .map((c) => dist(p.v, c))
      .reduce((best, d, i, all) => (d < all[best] ? i : best), 0);
    groups[nearest].push(p);
  }
  centres = groups.map((g) =>
    [0, 1].map((j) => g.reduce((s, p) => s + p.v[j], 0) / g.length),
  );
  console.log(
    `round ${round}: ` +
      groups
        .map((g, i) => `cluster ${i + 1} = ${g.map((p) => p.id).join(", ")}`)
        .join(" | "),
  );
}
console.log(
  "centres:",
  centres.map((c) => `(${c.map((x) => x.toFixed(2)).join(", ")})`).join(" "),
);
```

Expected output:

```text
round 1: cluster 1 = sign in, password, two-step | cluster 2 = flicker, dimmer
round 2: cluster 1 = sign in, password, two-step | cluster 2 = flicker, dimmer
centres: (0.80, 0.13) (0.15, 0.85)
```

A 2D map **loses information**. Two dots can look close on the map while being far apart in the real 384 dimensions, and the other way round. Use the map to explore and explain. Use the real similarity scores to decide.

Try a question that sits between topics, and watch where its ★ lands:

## Common mistakes

- **Mixing metrics**, for example building the index with cosine and querying with Euclidean on unnormalized vectors.
- **Long chunks winning every search** because of their vector length under dot product.
- **Comparing scores across models**: a cosine of 0.6 means different things in different models.
- **Trusting the 2D map** over the real scores.
- **Forgetting to filter** (archived, permissions) before taking the top k, so the top k is filled with chunks you then throw away.

## Exercises

Each exercise ends with a value on the last line. Press **Check** to compare it with the expected answer.

## What to Remember

- **Cosine** compares direction. **Dot product** also rewards length. **Euclidean** measures straight-line distance. On **normalized** vectors, all three give the same order.
- Use the metric your model recommends, and **normalize** vectors when you store them.
- **Top-k** keeps the k closest results. A **flat in-memory index** is exact and fast enough for thousands of chunks.
- **PCA** squeezes vectors to a 2D map. You can **project** the query onto it and see its **nearest neighbours** and **clusters**.
- A 2D map is for **exploring**. Real scores are for **deciding**.

Next: **Vector Indexing**, storing millions of vectors and searching them fast with HNSW and IVF.
