Similarity Search
By the Syncfusion DevTutorials team · Last updated
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:
- The three distance metrics: cosine similarity, dot product and Euclidean distance, and when they agree.
- Nearest neighbour search: top-k selection and an in-memory vector index.
- 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.
Three ways to measure closeness: cosine compares the angle between vectors, the dot product grows with both angle and length, and Euclidean distance measures the straight-line gap between their tips.
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.
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:
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.
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:
#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.
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:
wifi-troubleshooting#1 0.983
lamp-flickering#1 0.902
The next lesson covers what to do when you have millions of vectors.
Nearest neighbours on the map
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.
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:
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.
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:
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:
A question between two topics
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.