Build And Understand a Vector Database From Scratch in 10 Easy Steps A tutorial published with a companion GitHub repository (mmmayo13/vector_db) walks readers through building a working vector database from scratch in 10 incremental steps using Python and NumPy, requiring only the numpy and sentence-transformers packages and no GPU or API key. The guide covers encoding documents into fixed-size vectors for meaning-based search, adding metadata filtering, input validation and persistence, and explains how brute-force cosine similarity scales with corpus size and when to consider approximate indexing. The tutorial's first steps load an embedding model, encode a document corpus, and report model load time, per-document encoding time and index size in KiB. 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