# Build And Understand a Vector Database From Scratch in 10 Easy Steps

> Source: <https://machinelearningmastery.com/build-and-understand-a-vector-database-from-scratch-in-10-easy-steps/>
> Published: 2026-09-18 12:00:03+00:00

In this article, you will learn how a vector database works under the hood by building one from scratch in ten incremental steps using Python and NumPy.

Topics we will cover include:

- How documents are encoded into fixed-size vectors and searched by meaning rather than by keyword.
- How to add metadata filtering, input validation, and persistence to a minimal vector database.
- How brute-force cosine similarity scales with corpus size, and when to consider approximate indexing.

## Introducing Vector Databases

A vector database answers questions by meaning rather than by keyword. It operates by turning every document into a vector of numbers and then finding the numbers that point in a similar direction to your query (which has also been turned into a vector of numbers). This tutorial will demonstrate how to build a working vector database of your very own, through ten steps that each demonstrate one atomic idea. To follow along, create an empty script and name it something clever like `tutorial.py`. Append each step’s code to the script as you go and re-run it after you make sense of the commentary. The resulting output should make sense at that point. Nothing here needs a GPU or an API key; one small model downloads on the first run, and everything after that is plain NumPy.

## Step 1: Setup

You need three files [from this repository](https://github.com/mmmayo13/vector_db) in your working directory: [vector_db.py](https://github.com/mmmayo13/vector_db/blob/main/vector_db.py)[corpus.py](https://github.com/mmmayo13/vector_db/blob/main/corpus.py)[test.py](https://github.com/mmmayo13/vector_db/blob/main/test.py)`python test.py`.

Install the two dependencies:

```
pip install numpy sentence-transformers

1

pip install numpy sentence-transformers
```

Now start your `tutorial.py` file with the imports and two small display helpers. `show()` prints a list of search results as score, topic, document (relied upon later). `header()` just labels each section so the growing script’s output stays readable.

``` python
import time
from pathlib import Path

import numpy as np

from corpus import DOCS, META
from vector_db import VectorDB

WIDTH = 64

def header(title):
    print(f"\n{title}\n{'─' * len(title)}")

def show(results):
    if not results:
        print("  (no matches)")
    for hit in results:
        text = hit.text if len(hit.text) <= WIDTH else hit.text[: WIDTH - 1] + "..."
        print(f"  {hit.score:+.3f}  [{hit.meta['topic']:<7}]  {text}")
    print()

1234567891011121314151617181920

import timefrom pathlib import Path import numpy as np from corpus import DOCS, METAfrom vector_db import VectorDB WIDTH = 64 def header(title):    print(f"\n{title}\n{'─' * len(title)}") def show(results):    if not results:        print("  (no matches)")    for hit in results:        text = hit.text if len(hit.text) <= WIDTH else hit.text[: WIDTH - 1] + "..."        print(f"  {hit.score:+.3f}  [{hit.meta['topic']:<7}]  {text}")    print()
```

Running the script now produces no output. This is what we want; nothing has been called yet.

## Step 2: Building the Index

Creating a `VectorDB` loads the embedding model, and `add()` encodes every document into a vector and stores it.

```
header("2. Building the index")

t0 = time.perf_counter()
db = VectorDB()
load_seconds = time.perf_counter() - t0

t0 = time.perf_counter()
db.add(DOCS, META)
encode_seconds = time.perf_counter() - t0

print(f"  {db!r}")
print(f"  model load:  {load_seconds:5.2f}s")
print(f"  encoding:    {encode_seconds:5.2f}s   for {len(db)} documents "
      f"({encode_seconds / len(db) * 1000:.0f} ms each)")
print(f"  index size:  {db.vectors.nbytes / 1024:5.1f} KiB  "
      f"{db.vectors.shape} of {db.vectors.dtype}")

12345678910111213141516

header("2. Building the index") t0 = time.perf_counter()db = VectorDB()load_seconds = time.perf_counter() - t0 t0 = time.perf_counter()db.add(DOCS, META)encode_seconds = time.perf_counter() - t0 print(f"  {db!r}")print(f"  model load:  {load_seconds:5.2f}s")print(f"  encoding:    {encode_seconds:5.2f}s   for {len(db)} documents "      f"({encode_seconds / len(db) * 1000:.0f} ms each)")print(f"  index size:  {db.vectors.nbytes / 1024:5.1f} KiB  "      f"{db.vectors.shape} of {db.vectors.dtype}")
```

Output:

```
2. Building the index
─────────────────────
  VectorDB(25 docs, dim=384, model='sentence-transformers/all-MiniLM-L6-v2')
  model load:   1.64s
  encoding:     0.14s   for 25 documents (6 ms each)
  index size:   37.5 KiB  (25, 384) of float32

123456

2. Building the index─────────────────────  VectorDB(25 docs, dim=384, model='sentence-transformers/all-MiniLM-L6-v2')  model load:   1.64s  encoding:     0.14s   for 25 documents (6 ms each)  index size:   37.5 KiB  (25, 384) of float32
```

Note that the index size does not depend on how long the documents are. Every document, whether a six-word sentence or a six-page essay, becomes the same 384 numbers at 4 bytes each: 1,536 bytes, flat. That is fixed, and is what makes a vector index predictable to size and cheap to scan.

## Step 3: A First Search

```
header("3. A first search")

query = "what keeps a cell supplied with energy?"
print(f'  query: "{query}"\n')
show(db.search(query, k=3))

12345

header("3. A first search") query = "what keeps a cell supplied with energy?"print(f'  query: "{query}"\n')show(db.search(query, k=3))
```

Output:

```
3. A first search
─────────────────
  query: "what keeps a cell supplied with energy?"

  +0.589  [bio    ]  The mitochondria is the powerhouse of the cell.
  +0.440  [bio    ]  During aerobic respiration, mitochondria produce ATP through th...
  +0.329  [comics ]  Thor's mitochondria-rich muscle fibres make him a biological po...

1234567

3. A first search─────────────────  query: "what keeps a cell supplied with energy?"   +0.589  [bio    ]  The mitochondria is the powerhouse of the cell.  +0.440  [bio    ]  During aerobic respiration, mitochondria produce ATP through th...  +0.329  [comics ]  Thor's mitochondria-rich muscle fibres make him a biological po...
```

The top hit shares exactly one word with the query (“cell”) and the runner-up shares none at all. A keyword index would have ranked these very differently, if it found them at all.

## Step 4: Searching Without Sharing a Single Word

```
header("4. Searching without sharing a single word")

for query in ("why does my loaf taste sour", "superheroes"):
    print(f'  query: "{query}"\n')
    show(db.search(query, k=3))

12345

header("4. Searching without sharing a single word") for query in ("why does my loaf taste sour", "superheroes"):    print(f'  query: "{query}"\n')    show(db.search(query, k=3))
```

Output:

```
4. Searching without sharing a single word
──────────────────────────────────────────
  query: "why does my loaf taste sour"

  +0.630  [food   ]  The tangy flavour of sourdough bread comes from acetic and lact...
  +0.497  [food   ]  Sourdough fermentation relies on wild yeast and lactic acid bac...
  +0.386  [food   ]  The Maillard reaction between amino acids and reducing sugars i...

  query: "superheroes"

  +0.369  [comics ]  Tony Stark's alter ego Iron Man wields a powered exoskeleton ar...
  +0.357  [comics ]  Peter Parker gained super-strength, wall-crawling, and a precog...
  +0.353  [comics ]  Bruce Banner involuntarily transforms into the Hulk when his ad...

12345678910111213

4. Searching without sharing a single word──────────────────────────────────────────  query: "why does my loaf taste sour"   +0.630  [food   ]  The tangy flavour of sourdough bread comes from acetic and lact...  +0.497  [food   ]  Sourdough fermentation relies on wild yeast and lactic acid bac...  +0.386  [food   ]  The Maillard reaction between amino acids and reducing sugars i...   query: "superheroes"   +0.369  [comics ]  Tony Stark's alter ego Iron Man wields a powered exoskeleton ar...  +0.357  [comics ]  Peter Parker gained super-strength, wall-crawling, and a precog...  +0.353  [comics ]  Bruce Banner involuntarily transforms into the Hulk when his ad...
```

This is the whole point of the exercise. Neither query shares any word with the documents it retrieves; no instances of “loaf”, “sour”, nor “superhero” appear anywhere in the corpus. The match is on *meaning*.

## Step 5: Reading The Scores

```
header("5. Reading the scores")

query = "the best way to change a tyre"
print(f'  query: "{query}"\n')
show(db.search(query, k=3))

12345

header("5. Reading the scores") query = "the best way to change a tyre"print(f'  query: "{query}"\n')show(db.search(query, k=3))
```

Output:

```
5. Reading the scores
─────────────────────
  query: "the best way to change a tyre"

  +0.111  [ml     ]  Transformers replaced recurrent networks for most sequence tasks.
  +0.096  [comics ]  Like Peter Parker's cells constantly regenerating thanks to his...
  +0.068  [ml     ]  The self-attention mechanism in transformers allows each token ...

1234567

5. Reading the scores─────────────────────  query: "the best way to change a tyre"   +0.111  [ml     ]  Transformers replaced recurrent networks for most sequence tasks.  +0.096  [comics ]  Like Peter Parker's cells constantly regenerating thanks to his...  +0.068  [ml     ]  The self-attention mechanism in transformers allows each token ...
```

A vector search always returns `k` results, even when the corpus holds nothing relevant; it simply ranks *what it has*. The score is the only signal of whether an answer is any good: compare the `+0.111` here against the `+0.630` in step 4. In production you would set a floor and return nothing below it.

## Step 6: Narrowing Results with Metadata

Every document was added with a `{"topic": ...}` dict. The `where` argument keeps only the documents whose metadata matches on every key given.

```
header("6. Narrowing results with metadata")

query = "what keeps a cell supplied with energy?"
print(f'  query: "{query}"  (no filter)\n')
show(db.search(query, k=4))

print(f'  query: "{query}"  where={{"topic": "bio"}}\n')
show(db.search(query, k=4, where={"topic": "bio"}))

12345678

header("6. Narrowing results with metadata") query = "what keeps a cell supplied with energy?"print(f'  query: "{query}"  (no filter)\n')show(db.search(query, k=4)) print(f'  query: "{query}"  where={{"topic": "bio"}}\n')show(db.search(query, k=4, where={"topic": "bio"}))
```

Output:

```
6. Narrowing results with metadata
──────────────────────────────────
  query: "what keeps a cell supplied with energy?"  (no filter)

  +0.589  [bio    ]  The mitochondria is the powerhouse of the cell.
  +0.440  [bio    ]  During aerobic respiration, mitochondria produce ATP through th...
  +0.329  [comics ]  Thor's mitochondria-rich muscle fibres make him a biological po...
  +0.308  [bio    ]  Mitochondria contain their own DNA, a remnant of their ancient ...

  query: "what keeps a cell supplied with energy?"  where={"topic": "bio"}

  +0.589  [bio    ]  The mitochondria is the powerhouse of the cell.
  +0.440  [bio    ]  During aerobic respiration, mitochondria produce ATP through th...
  +0.308  [bio    ]  Mitochondria contain their own DNA, a remnant of their ancient ...
  +0.191  [bio    ]  Mitochondrial dysfunction has been linked to neurodegenerative ...

123456789101112131415

6. Narrowing results with metadata──────────────────────────────────  query: "what keeps a cell supplied with energy?"  (no filter)   +0.589  [bio    ]  The mitochondria is the powerhouse of the cell.  +0.440  [bio    ]  During aerobic respiration, mitochondria produce ATP through th...  +0.329  [comics ]  Thor's mitochondria-rich muscle fibres make him a biological po...  +0.308  [bio    ]  Mitochondria contain their own DNA, a remnant of their ancient ...   query: "what keeps a cell supplied with energy?"  where={"topic": "bio"}   +0.589  [bio    ]  The mitochondria is the powerhouse of the cell.  +0.440  [bio    ]  During aerobic respiration, mitochondria produce ATP through th...  +0.308  [bio    ]  Mitochondria contain their own DNA, a remnant of their ancient ...  +0.191  [bio    ]  Mitochondrial dysfunction has been linked to neurodegenerative ...
```

The corpus contains a deliberate trap: a comics document about Thor’s “mitochondria-rich muscle fibres” that is a genuinely good vector match for a biology question. Filtering is how you rule it the match — similarity alone cannot, because by meaning it really is similar.

## Step 7: A Filter Narrower Than *k*

```
header("7. A filter narrower than k")

print('  query: "bread"  where={"topic": "music"}, k=5\n')
results = db.search("bread", k=5, where={"topic": "music"})
show(results)
print(f"  asked for 5, got {len(results)}\n")

print('  query: "bread"  where={"topic": "astrology"}\n')
show(db.search("bread", k=5, where={"topic": "astrology"}))

123456789

header("7. A filter narrower than k") print('  query: "bread"  where={"topic": "music"}, k=5\n')results = db.search("bread", k=5, where={"topic": "music"})show(results)print(f"  asked for 5, got {len(results)}\n") print('  query: "bread"  where={"topic": "astrology"}\n')show(db.search("bread", k=5, where={"topic": "astrology"}))
```

Output:

```
7. A filter narrower than k
───────────────────────────
  query: "bread"  where={"topic": "music"}, k=5
  +0.082  [music  ]  In classical music, a fugue is a contrapuntal composition in wh...
  asked for 5, got 1

  query: "bread"  where={"topic": "astrology"}
  (no matches)

12345678

7. A filter narrower than k───────────────────────────  query: "bread"  where={"topic": "music"}, k=5  +0.082  [music  ]  In classical music, a fugue is a contrapuntal composition in wh...  asked for 5, got 1   query: "bread"  where={"topic": "astrology"}  (no matches)
```

Only one document is tagged `music`, so asking for 5 returns 1. Results are filtered before they are ranked, meaning that a non-matching document can never be padded into the list just to reach `k`.

## Step 8: Guard Rails

```
header("8. Guard rails")

for label, texts, metadata in [
    ("a single string instead of a list", "one document", None),
    ("metadata that does not line up", ["a", "b", "c"], [{"topic": "x"}]),
]:
    try:
        db.add(texts, metadata)
    except (TypeError, ValueError) as err:
        print(f"  {label}:\n    {type(err).__name__}: {err}\n")

12345678910

header("8. Guard rails") for label, texts, metadata in [    ("a single string instead of a list", "one document", None),    ("metadata that does not line up", ["a", "b", "c"], [{"topic": "x"}]),]:    try:        db.add(texts, metadata)    except (TypeError, ValueError) as err:        print(f"  {label}:\n    {type(err).__name__}: {err}\n")
```

Output:

```
8. Guard rails
──────────────
  a single string instead of a list:
    TypeError: add() takes a list of strings, not a single string

  metadata that does not line up:
    ValueError: got 3 texts but 1 metadata entries; they must line up one-to-one

1234567

8. Guard rails──────────────  a single string instead of a list:    TypeError: add() takes a list of strings, not a single string   metadata that does not line up:    ValueError: got 3 texts but 1 metadata entries; they must line up one-to-one
```

`add()` keeps documents, metadata and vectors in lockstep. Both of the above mistakes are easy to make and would silently corrupt an index if not caught. A bare string is iterable, so `docs.extend("hi")` would append “h” and “i” as two separate documents, and the model returned a single vector.

## Step 9: Saving and Loading

```
header("9. Saving and loading")

db.save("index")
for path in sorted(Path("index").iterdir()):
    print(f"  {path}  {path.stat().st_size / 1024:6.1f} KiB")

reopened = VectorDB()
reopened.load("index")
print(f"\n  reopened: {reopened!r}")
print(f"  vectors identical:  {np.array_equal(db.vectors, reopened.vectors)}")
print(f"  same top hit:       {reopened.search('superheroes', k=1)[0].text[:44]}...")

1234567891011

header("9. Saving and loading") db.save("index")for path in sorted(Path("index").iterdir()):    print(f"  {path}  {path.stat().st_size / 1024:6.1f} KiB") reopened = VectorDB()reopened.load("index")print(f"\n  reopened: {reopened!r}")print(f"  vectors identical:  {np.array_equal(db.vectors, reopened.vectors)}")print(f"  same top hit:       {reopened.search('superheroes', k=1)[0].text[:44]}...")
```

Output:

```
9. Saving and loading
─────────────────────
  index/store.json     3.0 KiB
  index/vectors.npy    37.6 KiB

  reopened: VectorDB(25 docs, dim=384, model='sentence-transformers/all-MiniLM-L6-v2')
  vectors identical:  True
  same top hit:       Tony Stark's alter ego Iron Man wields a pow...

12345678

9. Saving and loading─────────────────────  index/store.json     3.0 KiB  index/vectors.npy    37.6 KiB   reopened: VectorDB(25 docs, dim=384, model='sentence-transformers/all-MiniLM-L6-v2')  vectors identical:  True  same top hit:       Tony Stark's alter ego Iron Man wields a pow...
```

The vectors go to `.npy` because it is compact and loads without parsing. The text and metadata go to `.json` so you can open the file and read it. `load()` refuses an index built by a different model. This is important because embeddings only mean something relative to the model that produced them; mixing them would not be a little bit “off,” it would be confident nonsense.

## Step 10: How This Scales

Twenty-five documents are too few to measure, so this step also times a synthetic corpus of random vectors. They score meaningless results, but the computational cost matches a real world scenario.

```
header("10. How this scales")

runs = 50
t0 = time.perf_counter()
for _ in range(runs):
    db.search("memory safety without a garbage collector", k=5)
print(f"  {(time.perf_counter() - t0) / runs * 1000:.1f} ms per query "
      f"over {len(db)} documents\n")

rng = np.random.default_rng(0)
big = rng.random((100_000, db.dim), dtype=np.float32)
big /= np.linalg.norm(big, axis=1, keepdims=True)
query_vector = big[0]

def milliseconds(work, repeats=20):
    work()
    t0 = time.perf_counter()
    for _ in range(repeats):
        work()
    return (time.perf_counter() - t0) / repeats * 1000

print(f"  {'documents':>12}  {'memory':>9}  {'scan':>9}  {'rank':>9}")
for n in (1_000, 10_000, 100_000):
    rows = big[:n]
    scores = rows @ query_vector
    scan_ms = milliseconds(lambda: rows @ query_vector)
    rank_ms = milliseconds(lambda: np.argsort(scores)[::-1][:5])
    print(f"  {n:>12,}  {rows.nbytes / 1024**2:>7.1f} MB  "
          f"{scan_ms:>6.2f} ms  {rank_ms:>6.2f} ms")

12345678910111213141516171819202122232425262728293031

header("10. How this scales") runs = 50t0 = time.perf_counter()for _ in range(runs):    db.search("memory safety without a garbage collector", k=5)print(f"  {(time.perf_counter() - t0) / runs * 1000:.1f} ms per query "      f"over {len(db)} documents\n") rng = np.random.default_rng(0)big = rng.random((100_000, db.dim), dtype=np.float32)big /= np.linalg.norm(big, axis=1, keepdims=True)query_vector = big[0]  def milliseconds(work, repeats=20):    work()    t0 = time.perf_counter()    for _ in range(repeats):        work()    return (time.perf_counter() - t0) / repeats * 1000  print(f"  {'documents':>12}  {'memory':>9}  {'scan':>9}  {'rank':>9}")for n in (1_000, 10_000, 100_000):    rows = big[:n]    scores = rows @ query_vector    scan_ms = milliseconds(lambda: rows @ query_vector)    rank_ms = milliseconds(lambda: np.argsort(scores)[::-1][:5])    print(f"  {n:>12,}  {rows.nbytes / 1024**2:>7.1f} MB  "          f"{scan_ms:>6.2f} ms  {rank_ms:>6.2f} ms")
```

Output:

```
10. How this scales
──────────────────
  14.9 ms per query over 25 documents

     documents     memory       scan       rank
         1,000      1.5 MB    0.01 ms    0.04 ms
        10,000     14.6 MB    0.36 ms    0.55 ms
       100,000    146.5 MB    3.73 ms    8.90 ms

12345678

10. How this scales──────────────────  14.9 ms per query over 25 documents      documents     memory       scan       rank         1,000      1.5 MB    0.01 ms    0.04 ms        10,000     14.6 MB    0.36 ms    0.55 ms       100,000    146.5 MB    3.73 ms    8.90 ms
```

At 25 documents, embedding the query is essentially the entire computation, since the search itself is too fast to measure. Note that `milliseconds()` discards one warm-up run; the first call to a NumPy matrix routine spins up its internal thread pool, which can take more time than the actual work itself, with a result of making a small corpus look slower than a large one.

Two things are worth pointing out in the results table above:

1. Both columns grow linearly; nothing here is clever, it simply touches every row.
2. Past ~100,000 rows the sort starts to outgrow the scan. At a million documents the scan takes about 25 ms and the full sort about 90 ms. That is the point where it pays to stop sorting everything (`np.argpartition` finds the top`k` in about 10 ms). Not far beyond this you will find the point where you reach for a real approximate index (HNSW, IVF) and trade a little accuracy for speed.

## Wrapping Up

Every step here rests on a single idea: scale each embedding to length 1, and a plain dot product becomes cosine similarity. Ranking an entire corpus is then one matrix multiply. Everything else you added along the way — from metadata filters, saving and loading, the guard rails on `add()` — is bookkeeping that keeps documents, metadata and vectors in lockstep, so that the multiplication remains meaningful.

The big takeaway — beyond the simplicity and elegance behind the implementation of a vector database’s core functionality — is that the design does not change between 25 documents and 25 million; only the index structure underneath it does. This is, not surprisingly, precisely what the managed vector databases are selling.

For more information on vector databases from different points of view, check out these Machine Learning Mastery resources:

- [Understanding RAG Part VII: Vector Databases & Indexing Strategies](https://machinelearningmastery.com/understanding-rag-part-vii-vector-databases-indexing-strategies/) by Iván Palomares Carrascosa
- [Vector Databases Explained in 3 Levels of Difficulty](https://machinelearningmastery.com/vector-databases-explained-in-3-levels-of-difficulty/) by Bala Priya C
- [The Complete Guide to Vector Databases for Machine Learning](https://machinelearningmastery.com/the-complete-guide-to-vector-databases-for-machine-learning/) by Bala Priya C
