A vector search engine built completely from scratch in Python — semantic search by meaning, not keywords. No FAISS, no Chroma, no shortcuts.
Search for "something warm and cozy for winter" and it finds "chunky knit sweater for cold days" — zero shared words. That's the whole point: this engine matches on meaning, not exact text.
This project was built to understand what vector databases actually do under the hood, one piece at a time: distance metrics, cosine similarity, HNSW graph indexing, real AI embeddings, persistence, and benchmarking.
The engine has two search implementations, built in this order:
1. Brute-force search (search.py)
Compares a query against every stored vector, one at a time, using cosine similarity. Always exact — guaranteed to find the true closest matches. Used as the "ground truth" baseline for everything else.
2. HNSW (Hierarchical Navigable Small World) search (hnsw.py)
An approximate nearest-neighbor index inspired by the algorithm used in FAISS, Pinecone, and other production vector databases. Instead of checking every vector, it builds a graph where each vector connects to a handful of nearby "neighbors," organized into layers — a sparse top layer for long-range jumps, denser lower layers for precision. Search hops toward the query instead of scanning everything.
text → embedding model (sentence-transformers) → 384-dim vector
|
query vector ──────────┤
|
brute-force search OR HNSW search
| |
exact top-k approximate top-k
- Cosine similarity — measures the angle between two vectors, ignoring their size. This was a deliberate choice after an earlier version (using raw L1/"gap-sum" distance) ranked results incorrectly on real embeddings — it was punishing vectors for being different lengths, not different meanings. Cosine fixed it by comparing direction only.
- Dot product & magnitude — the two building blocks cosine similarity is made of, both implemented from scratch.
- Graph construction — nodes are inserted one at a time via
insert(). Each new node searches the existing graph (using the engine's own brute-forcesearch()) to find its closest neighbors, then connects to them in both directions. - Layers — each node is randomly assigned a "height" via a weighted coin-flip (
assign_layer()), the same mechanism the original HNSW paper uses. Most nodes stay at the ground floor; a rare few climb higher and become long-range shortcuts. - Search — starts at the top layer, greedily hops toward the query, drops a floor when stuck, and repeats until it bottoms out at the full graph.
- Persistence — the graph and layer assignments can be saved to disk (
save_index) and reloaded (load_index) viapickle, so the index doesn't need to be rebuilt from scratch on every run.
Brute-force vs. HNSW, tested on 200 randomly generated 8-dimensional vectors:
| Metric | Brute-force | HNSW |
|---|---|---|
| Search time | ~0.0004s | ~0.0001s |
| Speedup | — | ~3–4x faster |
| Recall@5 (vs. brute-force ground truth) | 100% (exact) | ~25% |
Honest note on recall: this HNSW implementation trades a meaningful amount of accuracy for its speed gain at small scale. The likely cause is a simplified single-pass neighbor selection during insertion — production HNSW implementations connect new nodes to multiple candidate neighbors and prune weaker edges over time, which this from-scratch version doesn't yet do. That refinement is a natural next step (see below).
Full benchmark script: benchmark.py
Using sentence-transformers (all-MiniLM-L6-v2) to embed real product descriptions:
Query: "something warm and cozy for winter"
Brute-force top match: "chunky knit sweater for cold days" (0.545)
No word overlap between the query and the result — the match is purely semantic. Script: real_benchmark.py
vector-search-engine/
├── search.py # brute-force engine: gap_sum (L1), dot, magnitude, cosine, search()
├── hnsw.py # HNSW graph: assign_layer, insert, greedy_search, layered_search, save/load
├── real_benchmark.py # end-to-end demo on real text embeddings
├── benchmark.py # brute-force vs HNSW speed + recall comparison
├── embed_test.py # first exploration of the embedding model
├── real_search.py # early real-embedding search test
└── README.md
- Why raw distance metrics (L1/Euclidean) can fail on real embeddings, and why cosine similarity is the industry standard
- How HNSW's layered graph structure trades a small amount of accuracy for a large speed gain — and why that tradeoff is called "approximate nearest neighbor" search
- That an unreachable node in a graph is invisible to greedy search, no matter how good the search logic is — connectivity determines what's findable
- How to build, benchmark, and honestly evaluate an algorithm's real limitations rather than just claiming it works
- Multi-candidate neighbor selection during insertion (closer to the original HNSW paper's construction algorithm), to improve recall
- Benchmark at larger scale (10k–100k+ vectors) to show HNSW's speed advantage grow with dataset size
- Make the embedding model swappable as a parameter
- Batch insertion for faster index construction
Python, sentence-transformers (all-MiniLM-L6-v2), pickle for persistence. No FAISS, no Chroma, no vector database dependencies — every distance calculation, graph structure, and search algorithm is implemented from scratch.