Machine Learning Mastery

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

8.5内容质量

TL;DR · AI 摘要

本文通过10个步骤用Python和NumPy从零构建向量数据库,详解向量编码、相似性搜索和近似索引原理。

核心要点

  • 使用sentence-transformers库将文档编码为固定长度向量
  • 近似索引在百万级数据时可提升90%查询速度
  • VectorDB支持元数据过滤和持久化存储

结构提纲

按章节快速跳转。

  1. 向量数据库通过语义相似性而非关键词匹配实现搜索。

  2. 需安装numpy和sentence-transformers库并导入基础模块。

  3. 使用预训练模型将文档转换为固定维度的数值向量。

  4. 包含元数据过滤、持久化存储和相似性搜索三个关键模块。

  5. 当数据量超过10万时需采用HNSW等近似算法优化查询性能。

思维导图

用一张图看清主题之间的关系。

查看大纲文本(无障碍 / 无 JS 友好)
  • 构建向量数据库
    • 实现步骤
      • 环境配置
      • 向量编码
      • 相似性搜索
      • 近似索引
    • 关键技术
      • NumPy
      • sentence-transformers

金句 / Highlights

值得收藏与分享的关键句。

#向量数据库#Python#NumPy#机器学习
打开原文

Build And Understand a Vector Database From Scratch in 10 Easy Steps - MachineLearningMastery.com

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

By

Matthew Mayo

on

September 17, 2026

in

Language Models

0

Share

Post

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 in your working directory: vector_db.py is the actual database which, yes, is already built for you… but the real magic is the understanding of the code and the interaction with it using the code herein. The good news is, once you go through this tutorial and understand the code, recreating the vector database on your own is nearly trivial. corpus.py contains 25 simulated sample documents and their topic tags. test.py is the test suite, only here to make you feel safe and secure that the vector database works properly as implemented, which you can verify by running at any point with 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.

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()

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

import

time

from

pathlib

Path

as

np

corpus

DOCS

,

META

vector_db

VectorDB

WIDTH

=

64

def

header

(

title

)

:

print

f

"\n{title}\n{'─' * len(title)}"

show

results

if

not

" (no matches)"

for

hit

text

.

len

<=

else

[

]

+

"..."

" {hit.score:+.3f} [{hit.meta['topic']:<7}] {text}"

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}")

"2. Building the index"

t0

perf_counter

db

load_seconds

add

encode_seconds

" {db!r}"

" model load: {load_seconds:5.2f}s"

" encoding: {encode_seconds:5.2f}s for {len(db)} documents "

"({encode_seconds / len(db) * 1000:.0f} ms each)"

" index size: {db.vectors.nbytes / 1024:5.1f} KiB "

"{db.vectors.shape} of {db.vectors.dtype}"

Output:

  1. 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

2.

Building

the

index

─────────────────────

25

dim

384

model

'sentence-transformers/all-MiniLM-L6-v2'

load

1.64s

encoding

0.14s

documents

ms

each

size

37.5

KiB

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))

"3. A first search"

query

"what keeps a cell supplied with energy?"

' query: "{query}"\n'

search

k

  1. 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...

3.

A

first

─────────────────

0.589

bio

mitochondria

is

powerhouse

cell

0.440

During

aerobic

respiration

produce

ATP

through

th

0.329

comics

Thor

'

s

rich

muscle

fibres

make

him

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))

"4. Searching without sharing a single word"

"why does my loaf taste sour"

"superheroes"

  1. 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...

4.

Searching

without

sharing

single

word

──────────────────────────────────────────

0.630

food

tangy

flavour

sourdough

bread

comes

acetic

and

lact

0.497

fermentation

relies

wild

yeast

lactic

acid

bac

0.386

Maillard

reaction

between

amino

acids

reducing

sugars

i

0.369

Tony

Stark

alter

ego

Iron

Man

wields

powered

exoskeleton

ar

0.357

Peter

Parker

gained

super

strength

wall

crawling

precog

0.353

Bruce

Banner

involuntarily

transforms

into

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)) 1 2 3 4 5 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 ... 1 2 3 4 5 6 7 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"})) 1 2 3 4 5 6 7 8 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 ... 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 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"})) 1 2 3 4 5 6 7 8 9 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) 1 2 3 4 5 6 7 8 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") 1 2 3 4 5 6 7 8 9 10 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 1 2 3 4 5 6 7 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]}...") 1 2 3 4 5 6 7 8 9 10 11 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... 1 2 3 4 5 6 7 8 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") 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 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" ) 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 1 2 3 4 5 6 7 8 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: Both columns grow linearly; nothing here is clever, it simply touches every row. 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 by Iván Palomares Carrascosa Vector Databases Explained in 3 Levels of Difficulty by Bala Priya C The Complete Guide to Vector Databases for Machine Learning by Bala Priya C Share Post Share More On This Topic Understand Machine Learning Algorithms By… 7 Steps to Mastering Memory in Agentic AI Systems 5 Steps to Thinking Like a Designer in Machine Learning 4-Steps to Get Started in Applied Machine Learning 3 Easy Ways to Fine-Tune Language Models LLM Evaluation Metrics Made Easy

/.entry