Vector Database Handbook from zero to production Bipin Singh
Hands-on

Build a tiny vector database

2 min readChapter 16 of 25By Bipin Singh

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.

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.

Key idea

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:

  1. Run 100 queries through TinyVectorDB.search — that is your ground truth.
  2. Build TinyIVF with nlist = 50 and try nprobe = 1, 3, 10.
  3. 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
Tip

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.

Bipin Singh
Written by Bipin Singh

Senior Full-Stack Engineer · AI & AWS. I build production search, RAG and AI systems on AWS and Postgres.

Work with me