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.
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.
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.
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.
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.
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.
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 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.
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 Page — delete_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.)
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.
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.
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
Requirements: Python 3.12+, Poetry
git clone https://github.com/dakohhh/VectKV-Python.git
cd VectKV-Python
poetry install
poetry run python -m vectkv.serverFor 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).
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 /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
}- 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) tosearch_vectorsandset_ef_search - WAL compaction after snapshot
- Delete vector support
- API key authentication
- Rate limiting
- Tests covering concurrent add/delete races
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.
MIT