Build a tiny vector database
The best way to make the ideas stick is to build a small version yourself. In about a hundred lines of TypeScript we'll implement upserts, deletes, metadata filters, cosine search — and then a simple IVF index to see approximate search in action.
Version 1: exact search
type Metadata = Record<string, string | number | boolean>;
type Record_ = { id: string; vector: Float32Array; metadata: Metadata };
type Hit = { id: string; score: number; metadata: Metadata };
const normalise = (v: number[]): Float32Array => {
const len = Math.hypot(...v) || 1;
return Float32Array.from(v, (x) => x / len);
};
const dot = (a: Float32Array, b: Float32Array) => {
let s = 0;
for (let i = 0; i < a.length; i++) s += a[i] * b[i];
return s; // vectors are normalised, so this is cosine similarity
};
export class TinyVectorDB {
private records = new Map<string, Record_>();
constructor(private dims: number) {}
upsert(id: string, vector: number[], metadata: Metadata = {}) {
if (vector.length !== this.dims) throw new Error(`expected ${this.dims} dims`);
this.records.set(id, { id, vector: normalise(vector), metadata });
}
delete(id: string) {
this.records.delete(id);
}
search(query: number[], k = 5, filter: Metadata = {}): Hit[] {
const q = normalise(query);
const matches = (m: Metadata) => Object.entries(filter).every(([key, val]) => m[key] === val);
const hits: Hit[] = [];
for (const r of this.records.values()) {
if (!matches(r.metadata)) continue; // pre-filtering
hits.push({ id: r.id, score: dot(q, r.vector), metadata: r.metadata });
}
return hits.sort((a, b) => b.score - a.score).slice(0, k);
}
}
Try it with toy 3-dimensional "embeddings":
const db = new TinyVectorDB(3);
db.upsert("cat", [0.9, 0.1, 0.0], { kind: "animal" });
db.upsert("kitten", [0.85, 0.2, 0.0], { kind: "animal" });
db.upsert("car", [0.0, 0.1, 0.95], { kind: "vehicle" });
db.search([0.88, 0.15, 0.0], 2);
// → [{ id: "cat", score: 0.99… }, { id: "kitten", score: 0.99… }]
db.search([0.88, 0.15, 0.0], 2, { kind: "vehicle" });
// → [{ id: "car", score: 0.01… }]
This is a complete, correct, exact vector store. Swap the toy vectors for real embeddings and it works for thousands of documents.
Notice how little "magic" there is: normalise, dot product, sort. Everything a production vector database adds is about doing this faster, at larger scale, more safely.
Version 2: an IVF index
Now let's avoid scanning everything. We'll cluster vectors with a few rounds of k-means and search only the closest clusters.
export class TinyIVF {
private centroids: Float32Array[] = [];
private lists: Record_[][] = [];
constructor(private nlist: number, private nprobe: number) {}
build(records: Record_[], iterations = 10) {
// 1. start with random records as centroids
this.centroids = records
.slice()
.sort(() => Math.random() - 0.5)
.slice(0, this.nlist)
.map((r) => r.vector);
for (let it = 0; it < iterations; it++) {
// 2. assign every record to its nearest centroid
this.lists = this.centroids.map(() => []);
for (const r of records) this.lists[this.nearestCentroids(r.vector, 1)[0]].push(r);
// 3. move each centroid to the average of its records
this.centroids = this.lists.map((list, c) => {
if (list.length === 0) return this.centroids[c];
const mean = new Array(list[0].vector.length).fill(0);
for (const r of list) r.vector.forEach((x, i) => (mean[i] += x));
return normalise(mean);
});
}
}
private nearestCentroids(v: Float32Array, n: number): number[] {
return this.centroids
.map((c, i) => ({ i, score: dot(v, c) }))
.sort((a, b) => b.score - a.score)
.slice(0, n)
.map((x) => x.i);
}
search(query: number[], k = 5): Hit[] {
const q = normalise(query);
const hits: Hit[] = [];
for (const c of this.nearestCentroids(q, this.nprobe)) { // only nprobe clusters
for (const r of this.lists[c]) hits.push({ id: r.id, score: dot(q, r.vector), metadata: r.metadata });
}
return hits.sort((a, b) => b.score - a.score).slice(0, k);
}
}
Experiment
Load a few thousand real embeddings and compare the two:
- Run 100 queries through
TinyVectorDB.search— that is your ground truth. - Build
TinyIVFwithnlist = 50and trynprobe= 1, 3, 10. - For each setting, measure recall@10 against the ground truth and the time per query.
You'll see the trade-off from Approximate search with your own eyes: small nprobe is fast with lower recall; larger nprobe approaches exact results while still scanning only part of the data.
What a real database adds
| Our toy | A production vector database |
|---|---|
| Sort all hits | Keeps a small top-k heap |
| One thread, plain loops | SIMD instructions, multiple cores, sometimes GPUs |
| Simple IVF | HNSW, quantization, DiskANN, tuned implementations |
| Equality filters only | Range, set, geo and full-text filters with indexes |
| In memory only | Write-ahead logs, snapshots, replication, sharding |
| No security | Authentication, authorisation, encryption, audit logs |
Building this toy is excellent interview preparation: you can explain exactly what HNSW and IVF improve on, and why recall and latency trade against each other.