DriversRecommendedOutdated drivers can make a good PC feel brokenScan driver issues before chasing fixes manually.Scan NowOctober DealsAmazon USOctober deal check: compare before you payAmazon US: current deals, useful picks and tech finds.Check DealsClean PCRecommendedOne scan can reveal what keeps slowing WindowsLook for cleanup and repair opportunities.Run Scan×
Skip to content
World desk9 min

Build a Vector Database From Scratch in 10 Steps

A hands-on, ten-step guide to an educational in-memory vector database in Python, from validated records and exact cosine search to approximate indexing and evaluation.
Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

You can build a small vector database from scratch with ordinary Python: store records with fixed-length vectors, calculate distances, and return the nearest matches. This tutorial develops an educational, in-memory prototype and shows how to add a simple approximate search method. It is not a production database: durability, concurrency, crash recovery, and distributed operation are separate engineering work.

The example uses cosine distance and Python’s standard library, with no database engine or vector-search package. It assumes each vector has the same dimension. The code is intended to make the mechanics clear; it does not reproduce pgvector or implement HNSW or IVFFlat.

As an Amazon Associate I earn from qualifying purchases.

1. Set the scope before writing code

A vector database stores vectors alongside identifiers and often metadata, then finds records whose vectors are close to a query vector under a selected metric. For this project, the scope is deliberately small:

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
  • Language: Python with standard-library modules.
  • Storage: in memory, with a basic JSON save/load option in step 7.
  • Search: exact cosine-distance search first, followed by an illustrative approximate candidate index.
  • Out of scope: production-grade durability, concurrent writes, crash recovery, and distributed scaling.

These boundaries matter: an in-memory program that can find nearest neighbors is a useful learning project, but it is not equivalent to a database service that remains correct through failures and concurrent updates.

2. Define records and reject invalid vectors

Each record needs a stable ID, a vector, and optional metadata. Fix the vector dimension for the collection; accepting inconsistent dimensions can make comparisons fail or silently produce meaningless results.

from dataclasses import dataclass, field
from typing import Any

DIMENSION = 3

@dataclass
class Record:
    id: str
    vector: tuple[float, ...]
    metadata: dict[str, Any] = field(default_factory=dict)

def make_record(record_id, vector, metadata=None):
    values = tuple(float(x) for x in vector)
    if len(values) != DIMENSION:
        raise ValueError(f"expected {DIMENSION} values, got {len(values)}")
    if not all(__import__("math").isfinite(x) for x in values):
        raise ValueError("vector values must be finite")
    return Record(record_id, values, dict(metadata or {}))

The example dimension is three so vectors are easy to inspect. A real collection would use the dimension required by its embedding model, and should validate it at ingestion and query time. Metadata is application-defined; it might contain a document title or category.

3. Choose a metric and implement it clearly

“Nearest” has no meaning until the database defines a distance function. This example uses cosine distance: one minus cosine similarity. Similarity is higher for vectors pointing in similar directions; distance is lower. Cosine distance is not the same value as cosine similarity.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
import math

def cosine_distance(a, b):
    if len(a) != len(b):
        raise ValueError("vector dimensions do not match")
    dot = sum(x * y for x, y in zip(a, b))
    norm_a = math.sqrt(sum(x * x for x in a))
    norm_b = math.sqrt(sum(y * y for y in b))
    if norm_a == 0 or norm_b == 0:
        raise ValueError("cosine distance is undefined for a zero vector")
    return 1.0 - dot / (norm_a * norm_b)

assert abs(cosine_distance((1, 0, 0), (1, 0, 0))) < 1e-12
assert abs(cosine_distance((1, 0, 0), (0, 1, 0)) - 1.0) < 1e-12

The checks cover identical and orthogonal vectors. Other common choices include L2 (Euclidean) distance, inner product, and L1 distance. Binary vectors may use metrics such as Hamming or Jaccard distance. The metric affects both result ordering and which index structures are appropriate. pgvector documents these operators and distinguishes cosine distance from cosine similarity; its similarity expression is one minus cosine distance.

4. Make exact top-k search your correctness baseline

The simplest search computes the distance from the query to every record, sorts the results, and returns the first k. This scan has perfect recall for the stored data and chosen metric, because it considers every row. Its cost grows with the number of records and vector dimensions.

def exact_search(records, query, k, predicate=lambda record: True):
    query = tuple(float(x) for x in query)
    if len(query) != DIMENSION:
        raise ValueError("query dimension does not match collection")
    if k < 0:
        raise ValueError("k must be non-negative")
    if k == 0:
        return []
    matches = [
        (cosine_distance(record.vector, query), record.id, record)
        for record in records
        if predicate(record)
    ]
    matches.sort(key=lambda item: (item[0], item[1]))
    return matches[:k]

The ID is a deterministic tie-breaker, so equal distances produce stable ordering. The returned distance is lower for a closer result. A caller can convert it to cosine similarity with 1 - distance. Keep this exact implementation: later, it will tell you whether an approximate index is missing relevant neighbors.

5. Add a simple index for metadata filters

An index does not have to be a nearest-neighbor structure. A dictionary from metadata values to record IDs can narrow a category filter before the distance calculation. This example indexes one field and assumes records are inserted through the same collection object.

Free tools Windows power users keep installed

One-click scans. No signup required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
from collections import defaultdict

class Collection:
    def __init__(self):
        self.records = {}
        self.by_category = defaultdict(set)

    def insert(self, record):
        if record.id in self.records:
            raise ValueError("record ID already exists")
        self.records[record.id] = record
        category = record.metadata.get("category")
        if category is not None:
            self.by_category[category].add(record.id)

    def category_records(self, category):
        return [self.records[i] for i in self.by_category.get(category, ())]

Use exact_search(collection.category_records("science"), query, 5) to search only that category. This index accelerates that specific lookup; it does not accelerate vector distance calculations in general. If metadata can change, deletion and update code must also remove stale entries from the index.

6. Try an approximate candidate index

Approximate nearest-neighbor (ANN) search avoids comparing the query with every record by searching a candidate subset. One educational option is random-hyperplane locality-sensitive hashing (LSH): project a vector onto several random directions and record the signs as a bit signature. Similar directions are more likely to share signatures, but collisions are not guaranteed. The code below illustrates the idea, not a generally tuned or production-ready index.

import random

class RandomHyperplaneIndex:
    def __init__(self, dimension, tables=8, bits=10, seed=7):
        self.dimension = dimension
        self.tables = tables
        self.bits = bits
        rng = random.Random(seed)
        self.planes = [
            [[rng.gauss(0, 1) for _ in range(dimension)] for _ in range(bits)]
            for _ in range(tables)
        ]
        self.buckets = [defaultdict(set) for _ in range(tables)]
        self.records = {}

    def signature(self, vector, planes):
        return tuple(sum(x * w for x, w in zip(vector, plane)) >= 0
                     for plane in planes)

    def add(self, record):
        if len(record.vector) != self.dimension:
            raise ValueError("vector dimension does not match index")
        self.records[record.id] = record
        for table, planes in zip(self.buckets, self.planes):
            table[self.signature(record.vector, planes)].add(record.id)

    def search(self, query, k):
        if len(query) != self.dimension:
            raise ValueError("query dimension does not match index")
        candidate_ids = set()
        for table, planes in zip(self.buckets, self.planes):
            candidate_ids.update(table.get(self.signature(query, planes), ()))
        candidates = [self.records[i] for i in candidate_ids]
        return exact_search(candidates, query, k)

Insert the same records into the collection and this index, then compare index.search(query, k) with exact_search(all_records, query, k). This particular implementation searches only exact-signature buckets. If a query’s buckets are empty or omit good matches, it can return fewer than k records or miss close neighbors. Increasing the number of tables can produce more candidates, while changing the number of bits changes bucket granularity. Those parameters require evaluation on the workload; this toy index is not a substitute for a mature ANN implementation.

7. Persist records and define mutation behavior

For a learning prototype, JSON is enough to demonstrate persistence. It does not provide transactions or crash-safe database semantics. Save records, not derived index state; rebuild indexes after loading so they reflect the stored records.

What’s actually slowing this PC down?

Pick the symptom - the matching free tool is one click away.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
import json

def save_collection(collection, path):
    payload = [
        {"id": r.id, "vector": list(r.vector), "metadata": r.metadata}
        for r in collection.records.values()
    ]
    with open(path, "w", encoding="utf-8") as f:
        json.dump(payload, f)

def load_collection(path):
    collection = Collection()
    with open(path, encoding="utf-8") as f:
        payload = json.load(f)
    for item in payload:
        collection.insert(make_record(item["id"], item["vector"], item["metadata"]))
    return collection

Before extending this, define what insert, update, and delete mean. An update must replace the record and refresh every metadata and vector index; a delete must remove it from both storage and indexes. Rebuilding an index after mutations is simpler than maintaining it incrementally, but may be costly as the collection grows. The JSON example overwrites a file directly and does not promise recovery if a write is interrupted.

8. Add query validation, filtering, and an interface

A useful query path validates the dimension and requested result count, applies any metadata filter, then ranks candidates. The exact scan can apply the predicate before sorting. An ANN index may instead gather candidates first; if filtering happens after that approximate scan, too few candidates may survive to fill the requested limit.

That issue is documented for pgvector approximate indexes with selective filters. Supabase’s HNSW guidance describes iterative scans, available with pgvector 0.8.0 and later, as a way to continue searching for enough qualifying results; actual behavior depends on settings and limits. The general lesson is to test filtered queries separately from unfiltered nearest-neighbor queries.

A small application interface should make the metric, dimension, top-k, and filtering behavior explicit. For example, an API can accept a query vector, a positive integer k, and an optional category, then call either exact search or ANN search. It should reject malformed input rather than silently truncating vectors or changing the metric.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.
Independent reader supportYour contribution helps us test, update, and keep practical guides available for everyone.Support on Ko-Fi

9. Measure accuracy and cost against the exact scan

Do not call an ANN index faster or better based on a few successful queries. Use a fixed dataset and query set, disclose the hardware and index settings, and compare approximate results with exact top-k results. For one query, recall@k is the size of the intersection between the approximate and exact result IDs divided by the exact result count, when that count is nonzero.

  • Recall: how many exact nearest neighbors appear in the approximate result.
  • Query latency: measure a representative distribution of queries, not just one run.
  • Build time: include the time and resources needed to create or rebuild the index.
  • Memory and disk footprint: account for both records and index structures.
  • Mutation behavior: check whether inserts, updates, and deletes leave results correct and how much maintenance they require.
  • Filtered recall and result count: test selective metadata filters and whether the requested number of matches is returned.

Keep the exact scan as the baseline when changing index parameters. There is no universal speedup or best recall setting: outcomes depend on the dataset, metric, implementation, hardware, and query workload. For PostgreSQL deployments, pgvector recommends inspecting plans with EXPLAIN (ANALYZE, BUFFERS); that is a PostgreSQL diagnostic, not a measurement built into this Python prototype.

10. Know when the prototype stops being a database

Two ANN index families documented by pgvector illustrate important design trade-offs. These are pgvector characteristics, not guarantees for every implementation or workload:

Approach Search accuracy and latency Build and memory Data and filtering considerations
Exact scan Perfect recall for the selected metric; scans every row, so query work grows with the collection. No ANN index build or index-memory cost. Filtering can be applied before ranking; the data still needs storage and validation.
HNSW Approximate search; pgvector describes better speed/recall behavior than IVFFlat in general, not a universal measured result. Multilayer graph; slower builds and higher memory use than IVFFlat are documented characteristics. HNSW has no training step and can be created on an empty table. Its m parameter controls maximum connections per layer; ef_construction controls the candidate-list size during graph construction. Greater construction effort can improve recall while increasing build time and insert cost. Selective post-scan filters can leave fewer than the requested results. Iterative scan behavior depends on pgvector version and configuration.
IVFFlat Approximate search over inverted lists; no universal latency or recall figure is established. Uses lists; pgvector recommends creating the index after loading data. As with other approximate scans, evaluate filtered result counts and recall on the intended workload.

Beyond these indexes, a service needs a plan for durability, concurrent reads and writes, recovery, and scaling. PostgreSQL and pgvector document practices such as bulk loading with COPY, creating indexes after initial load where appropriate, and creating production indexes concurrently to avoid blocking writes. These are PostgreSQL-specific operational practices, not requirements for an in-memory implementation. Google Cloud SQL is one managed-service example of storing and querying embeddings with pgvector; using that provider is not required.

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

When memory becomes a constraint, pgvector documents half-precision vectors and binary quantization with reranking. For text retrieval, it also documents combining full-text and vector search. Replication and sharding introduce additional operational concerns rather than automatically improving a small prototype. A 2026 arXiv paper on PostgreSQL-V 2.0 treats concurrency, crash recovery, and physical replication as substantial system-design topics; its results are specific to that research system and its experiments.

Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon at the time of purchase will apply.

Leave a Reply

Your email address will not be published. Required fields are marked *

Special offer. See more information about Outbyte and uninstall instructions. Please review EULA and Privacy policy.

More from the Wire

  1. World desk4 min
    How to Spot an AI Voice Scam Before Sending MoneyDon’t rely on how a caller sounds. Pause, call back through a known number, and verify the emergency with another trusted person before sending money.
  2. Mountain View desk4 min
    Google’s SynthID Detector: How to Check AI-Generated Images, Video and AudioGoogle’s SynthID Detector looks for an embedded watermark in supported images, video and audio. Here is what its results do—and do not—show.
  3. Redmond desk20 min
    How to create a link to File or Folder in Windows 11Windows 11 gives you several ways to point to a file or folder without moving or duplicating it. You can create a desktop shortcut,…
Recommended PC Tool
Recommended PC Tool
Crashes, No Sound, or Screen Glitches?Free driver scan
Windows Errors? Fix Them Before They SpreadFree repair scan

Two free Windows tools

One Free Minute Could Fix That PC

Before you go - each of these free tools takes about a minute and tackles what quietly slows a Windows PC down.

Special offer. View Outbyte info, uninstall instructions, EULA, and Privacy Policy.