Skip to content

Latest commit

 

History

11 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

VectKV

A vector database built from scratch in Python. This is not a wrapper around an existing vector DB — it implements the core storage, indexing, and durability layers directly.

Built as a learning project to understand how production vector databases like Chroma and Qdrant actually work under the hood.

Status: Actively in development. APIs may change.


What it does

VectKV stores vectors grouped into pages (namespaced collections). Each page maintains its own HNSW index for approximate nearest-neighbour search, with support for cosine, euclidean, and inner product distance metrics.


Why these choices

A few decisions worth explaining up front, since they shaped everything downstream:

Python, not a faster systems language. The point of this project isn't to build the fastest vector database — it's to actually understand how one works, end to end: storage layout, durability, concurrency, indexing. Python keeps the iteration loop short enough to work through those ideas without fighting a compiler, and its ecosystem (Pydantic, asyncio, FastAPI) already covers exactly the pieces a database engine needs — validation, structured concurrency, an HTTP layer — without having to build them from scratch too.

hnswlib for the index, not a hand-rolled HNSW implementation. HNSW (Hierarchical Navigable Small World) is the graph algorithm that makes approximate nearest-neighbour search fast, and it's genuinely hard to implement correctly and make fast. hnswlib is a mature, heavily-optimized C++ implementation of it — it's the same algorithm running underneath Chroma, Qdrant, and Weaviate. Using it isn't skipping the hard part; it's the same call production vector databases make. The differentiated part of a vector database isn't the ANN algorithm itself, it's everything built around it — storage, durability, concurrency, the query surface. That's where this project spends its effort.

FastAPI for the HTTP layer. Async-native and Pydantic-based for request/response validation, so it stays out of the way of the actual database logic instead of becoming its own thing to fight.


Architecture & Design Decisions

Pages

Vectors are organized into pages — isolated namespaces each with their own index, vector dimensionality, and HNSW configuration. This mirrors how production databases like Qdrant use "collections": you can tune each page independently rather than forcing all vectors into a single global index.

HNSW Indexing

Vector search is powered by hnswlib, giving sub-linear approximate nearest-neighbour search with tunable accuracy/speed trade-offs via HNSW_M, HNSW_EF_CONSTRUCTION, and HNSW_EF_SEARCH.

Ranked Pages

A page can optionally be created with is_ranked_page=True and a fixed target_vector. From then on, every vector added to that page has its cosine similarity to target_vector computed automatically, and the result is kept in a durable, continuously-sorted ranked_list — a live leaderboard of every vector in the page, ranked by similarity to the pinned target, always up to date with zero query-time computation to read it.

This is a different access pattern from search_vectors, not a replacement for it: search_vectors runs an ad-hoc, approximate HNSW query against whatever vector you hand it at query time — a fresh computation on every call, against any target. A ranked page is the opposite shape — one target, fixed at page creation, with an exact (not approximate) ranking maintained incrementally as writes happen, so reading it back is just "return the list," no computation required.

Maintaining that list correctly under updates is the interesting part. Inserts go in via bisect.insort, which keeps ranked_list sorted by score in O(log n) to find the position plus an O(n) shift. When an existing vector is updated (new value, same vector_id), its old ranked entry has to be found and removed before the new one is inserted — otherwise updates would leave stale duplicate scores behind forever. That lookup is O(1) via a hnsw_id_to_rank_list_item_map on the page, but locating the old entry's actual position in ranked_list still needs a search — and a plain score-based binary search isn't enough, since two different vectors can legitimately tie on cosine similarity. So the binary search finds the leftmost index with a matching score, then scans forward through the tied run matching on vector_hnsw_id (unique per vector in a page) to find the exact entry, not just any entry with the same score.

ranked_list requires no special-cased persistence code — it's a normal field on Page, so it rides along with the existing snapshot (model_dump/model_validate) and WAL replay machinery for free, the same way vectors and the HNSW index do.

Write-Ahead Log (WAL)

Every mutation (CREATE_PAGE, ADD_VECTOR, DELETE_PAGE) is written to a WAL segment before it's applied to in-memory state, so a crash mid-write never leaves in-memory state ahead of what's durably recorded on disk.

The WAL is segmented rather than kept as one ever-growing file. A single log that never rotates has two problems: it grows without bound, and startup replay time grows right along with it — the log you'd be replaying on day 400 would be every mutation the database has ever seen. Instead, the WAL is split into numbered segments (wal.00001.log.ndjson, wal.00002.log.ndjson, ...), and a segment gets closed and rotated the moment a snapshot has captured everything in it. Startup only ever needs to load the latest snapshot and replay the one active segment written since — not the database's entire history.

Snapshots

Snapshots exist to bound WAL replay time. Every SNAPSHOT_VECTOR_COUNT_THRESHOLD vectors written, VectKV takes a point-in-time snapshot: each page's state (metadata, vector map, HNSW id mappings) is serialized via Pydantic's model_dump into data.json, and each page's HNSW index is saved separately as a binary file via hnswlib's own save_index. The WAL then rotates to a fresh segment.

Checksum verification on load. Before a snapshot's data.json is ever deserialized back into Page objects, its raw bytes are SHA-256 hashed and compared against the checksum recorded at write time. If they don't match — disk corruption, an interrupted write, a tampered file — VectKV refuses to load it rather than silently reconstructing state from data it can't trust. The hash has to be computed over the raw bytes before anything touches them; hashing after deserialization would defeat the point, since corruption that survives parsing would never get caught.

Concurrency

The entire engine is async, and concurrency is managed at two levels:

  • global_lock — a single asyncio lock guards the structure of the page store itself (inserting or removing keys from the dict). Critical sections under this lock are kept deliberately tiny — a dict read or write, nothing else — so one page's operation is never blocked behind another page's slower work.
  • page.lock — each page has its own lock guarding vector-level writes, searches, and deletes on that page, so concurrent operations on different pages never block each other.

The subtle part isn't holding two locks, it's the gap between them: a page reference fetched under global_lock can still get deleted out from under an in-flight operation before that operation acquires page.lock, since the two locks are never held at the same time. VectKV closes that gap with an is_deleted_before_lock_release flag on Pagedelete_page sets it the instant it acquires page.lock, and any other operation racing for that same page checks the flag right after acquiring page.lock itself, before touching anything. Whichever side wins the race for the lock determines the real ordering of events; the flag just lets the loser discover that cleanly instead of silently mutating a page that's already gone.

(This guard currently covers add_vector and delete_page; extending it to search_vectors and set_ef_search is still open — see Roadmap.)

Configuration

Runtime config (HOST, PORT, SNAPSHOT_VECTOR_COUNT_THRESHOLD, ...) is centralized in a pydantic-settings-based GlobalConfig, read from real environment variables. Locally, an ENVIRONMENT variable (set via direnv, scoped to this project directory) gates whether a .env file gets loaded at all. Production never attempts to read one — so python-dotenv stays a dev-only dependency, and production config always comes from real env vars injected by the deploy environment (Docker, etc.), never a file that could accidentally end up baked into an image.

HTTP Layer

VectKV exposes a FastAPI HTTP server. Pydantic models are used for all request validation and response serialization. Errors from the core engine are mapped to appropriate HTTP status codes via FastAPI exception handlers.


Project Structure

vectkv/
├── main.py          # VectKv engine — core logic, WAL replay, snapshot lifecycle
├── config.py        # Environment-based settings (pydantic-settings)
├── schema.py        # Pydantic models: Page, VectorStore, VectKvSearchResult, snapshot state
├── wal.py           # WAL operation models
├── types.py         # Shared type aliases (VectKvId, Metric, etc.)
├── exceptions.py    # Exception hierarchy + error response models
├── handler.py       # FastAPI exception handlers
├── server.py        # App factory + lifespan
└── api/
    ├── pages/       # Page routes
    └── vectors/     # Vector routes

Getting Started

Requirements: Python 3.12+, Poetry

git clone https://github.com/dakohhh/VectKV-Python.git
cd VectKV-Python
poetry install
poetry run python -m vectkv.server

For local development with a .env file: install direnv, add export ENVIRONMENT=development to a .envrc in the project root, then run direnv allow so it loads automatically whenever you're in the project directory.

The server starts on port 5955 by default (configurable via the PORT env var).


API

Create a page

POST /v1/pages/
Content-Type: application/json

{
  "page_name": "my-embeddings",
  "vector_dim": 1536,
  "metric": "cosine"
}
Field Type Default Description
page_name string required Unique name for the page
vector_dim int required Dimensionality of vectors stored in this page
metric string "cosine" Distance metric: cosine, euclidean, or ip
allow_updates bool true Whether existing vector IDs can be overwritten
HNSW_M int 16 Number of bi-directional links per HNSW node
HNSW_EF_CONSTRUCTION int 200 Candidate list size during index construction
HNSW_MAX_ELEMENT int 10000 Maximum vectors the index can hold
HNSW_RESIZE_COUNT int 10000 Growth increment when resizing
HNSW_EF_SEARCH int null Candidate list size during search (higher = more accurate, slower)
is_ranked_page bool false If true, maintain a durable similarity ranking against target_vector (see Ranked Pages)
target_vector float[] null Required when is_ranked_page is true; must match vector_dim

Get ranked items

GET /v1/pages/{page_name}/ranked-items/

Returns every vector in the page ranked by similarity to its target_vector, most similar first. Raises an error if the page doesn't exist or wasn't created with is_ranked_page=true.

{
  "message": "Ranked items retrieved",
  "data": [
    { "score": 1.0, "vector_vectkv_id": "doc-42", "vector_hnsw_id": 3 },
    { "score": 0.87, "vector_vectkv_id": "doc-7", "vector_hnsw_id": 0 }
  ],
  "status_code": 200
}

Roadmap

  • In-memory page storage with HNSW indexing
  • Write-ahead log with crash recovery
  • WAL segmentation with snapshot-triggered rotation
  • Snapshots with SHA-256 checksum verification
  • Async concurrency with per-page locking
  • FastAPI HTTP layer
  • Environment-based configuration
  • Ranked pages: durable, similarity-ranked lists pinned to a target vector
  • Extend the delete-race guard (is_deleted_before_lock_release) to search_vectors and set_ef_search
  • WAL compaction after snapshot
  • Delete vector support
  • API key authentication
  • Rate limiting
  • Tests covering concurrent add/delete races

Contributing

This project is open to contributions. If you have ideas, find a bug, or want to improve something:

  • Issues — open one to discuss a bug, ask a question, or propose a feature before building it
  • Pull requests — welcome for anything on the roadmap or improvements you think are worthwhile. For larger changes, open an issue first so we can align before you invest the time

There's no formal contribution guide yet — keep code async-first, add Pydantic models for any new data shapes, and follow the existing exception hierarchy for errors.


License

MIT

About

VectKV is a lightweight in-memory vector database with HNSW approximate nearest neighbor search, write-ahead logging for durability, and a FastAPI HTTP interface. Built to understand how vector stores actually work

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages