Tutorial: Vector Search with SochDB
๐ง Skill Level: Intermediate
โฑ๏ธ Time Required: 20 minutes
๐ฆ Requirements: Python 3.9+, numpy, sentence-transformers
Versions: Core engine 2.0.3 ยท Python SDK 0.5.9
Learn how to build a semantic search system using SochDB's HNSW vector index.
sochdbThere are two importable packages, both named sochdb:
- The pure-Python ctypes SDK (v0.5.9) โ the broad embedded + server SDK
(
Database,Namespace,Collection,VectorIndex,AgentMemory, โฆ). This tutorial uses this package for general usage. - The native PyO3 engine (v2.0.3) โ a focused HNSW/BM25/RRF engine exposing
HnswIndex,build_index_from_numpy,recommended_hnsw_params,MultiShardHnswIndex, etc.
Both publish under pip install sochdb. Examples below note which class comes
from which package.
๐ฏ What You'll Buildโ
A document search system that:
- โ Stores documents with vector embeddings
- โ Performs semantic similarity search
- โ Returns relevant results based on meaning, not keywords
Step 1: Setupโ
# Create project
mkdir semantic-search && cd semantic-search
python -m venv venv
source venv/bin/activate
# Install dependencies
pip install sochdb numpy sentence-transformers
Note:
sentence-transformersprovides real embedding models. For production, you might also use OpenAI, Cohere, or other embedding APIs.
Step 2: Understand Vector Searchโ
What are Embeddings?โ
Embeddings convert text into numerical vectors that capture semantic meaning:
"The cat sat on the mat" -> [0.12, -0.34, 0.56, ...] (384 dimensions)
"A feline rested on a rug" -> [0.11, -0.32, 0.55, ...] (similar vector!)
"Python programming" -> [-0.45, 0.78, -0.23, ...] (different vector)
Similar meanings = similar vectors = small distance
How HNSW Worksโ
HNSW (Hierarchical Navigable Small World) is a graph-based index:
Layer 2: โโโโโโโโโโโโโโโโโโโโโโโโโ
โ โ
Layer 1: โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ โ โ โ โ
Layer 0: โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
- Search: Start at top layer, greedily descend
- Complexity: O(log N) average
- Recall: 95%+ at the engine's default settings (deep-1M recall@10 = 0.988 at M=32)
Step 3: Create the Embedding Serviceโ
Create embeddings.py:
"""Embedding service using sentence-transformers."""
from sentence_transformers import SentenceTransformer
import numpy as np
class EmbeddingService:
def __init__(self, model_name: str = "all-MiniLM-L6-v2"):
"""Initialize with a sentence-transformer model.
Models:
- all-MiniLM-L6-v2: Fast, good quality, 384 dims
- all-mpnet-base-v2: Better quality, 768 dims, slower
- all-distilroberta-v1: Balanced, 768 dims
"""
print(f"Loading model: {model_name}...")
self.model = SentenceTransformer(model_name)
self.dimension = self.model.get_sentence_embedding_dimension()
print(f"Model loaded. Embedding dimension: {self.dimension}")
def embed(self, texts: list[str]) -> np.ndarray:
"""Convert texts to embeddings."""
embeddings = self.model.encode(
texts,
convert_to_numpy=True,
normalize_embeddings=True # L2 normalize for cosine similarity
)
return embeddings.astype(np.float32)
def embed_single(self, text: str) -> np.ndarray:
"""Convert a single text to embedding."""
return self.embed([text])[0]
# Test it
if __name__ == "__main__":
service = EmbeddingService()
texts = [
"The quick brown fox jumps over the lazy dog",
"A fast auburn canine leaps above a sleepy hound",
"Python programming is fun",
]
embeddings = service.embed(texts)
# Check similarity (dot product of normalized vectors = cosine similarity)
sim_01 = np.dot(embeddings[0], embeddings[1])
sim_02 = np.dot(embeddings[0], embeddings[2])
print(f"Similarity (fox/canine): {sim_01:.3f}") # Should be high (~0.7+)
print(f"Similarity (fox/python): {sim_02:.3f}") # Should be low (~0.2)
Run it:
python embeddings.py
# Loading model: all-MiniLM-L6-v2...
# Model loaded. Embedding dimension: 384
# Similarity (fox/canine): 0.763
# Similarity (fox/python): 0.186
Step 4: Build the Search Systemโ
We use VectorIndex from the 0.5.9 ctypes SDK โ an embedded HNSW index whose
defaults (max_connections=32, ef_construction=256) mirror the engine's
HnswConfig::default().
Create search.py:
"""Semantic search system with SochDB."""
from sochdb import Database, VectorIndex
from embeddings import EmbeddingService
import numpy as np
import json
import os
class SemanticSearch:
def __init__(self, db_path: str = "./search_db"):
self.db = Database.open(db_path) # embedded FFI database
self.embeddings = EmbeddingService()
self.index = VectorIndex(dimension=self.embeddings.dimension)
# ef_search controls recall vs. latency. Default is engine-side; raise
# it for higher recall. See "Search-time tuning" below.
self.index.ef_search = 100
self._documents = []
self._load_documents()
def _load_documents(self):
"""Load existing documents from the database and rebuild the index."""
for key, value in self.db.scan(b"docs/"):
doc = json.loads(value.decode())
self._documents.append(doc)
if self._documents:
self._rebuild_index()
print(f"Loaded {len(self._documents)} existing documents")
def add_documents(self, documents: list[dict]):
"""Add documents with automatic embedding generation.
Each document should have at least a 'content' field.
Additional fields (title, metadata) are preserved.
"""
print(f"Adding {len(documents)} documents...")
start_id = len(self._documents)
with self.db.transaction() as txn:
for i, doc in enumerate(documents):
doc_id = start_id + i
doc["id"] = doc_id
txn.put(f"docs/{doc_id}".encode(), json.dumps(doc).encode())
self._documents.append(doc)
# Rebuild the index with all embeddings
self._rebuild_index()
print(f"โ
Added {len(documents)} documents (total: {len(self._documents)})")
def _rebuild_index(self):
"""(Re)build the HNSW index from all documents."""
if not self._documents:
return
contents = [doc["content"] for doc in self._documents]
embeddings = self.embeddings.embed(contents)
# Fresh index, then a single zero-copy batch insert
self.index = VectorIndex(dimension=self.embeddings.dimension)
self.index.ef_search = 100
ids = np.arange(len(self._documents), dtype=np.uint64)
count = self.index.insert_batch(ids, embeddings)
print(f"Index built: {count} vectors")
def search(self, query: str, k: int = 5) -> list[dict]:
"""Search for documents similar to the query."""
if not self._documents:
return []
query_embedding = self.embeddings.embed_single(query)
# search() returns list[(id, distance)]
results = self.index.search(query_embedding, k=k)
search_results = []
for doc_id, distance in results:
if doc_id < len(self._documents):
doc = self._documents[doc_id].copy()
doc["score"] = 1.0 - distance # cosine distance -> similarity
doc["distance"] = distance
search_results.append(doc)
return search_results
def main():
search = SemanticSearch()
documents = [
{
"title": "SochDB Overview",
"content": "SochDB is an LLM-native database designed for AI applications. It provides token-efficient storage and vector search capabilities."
},
{
"title": "Vector Search Basics",
"content": "Vector search uses embeddings to find semantically similar documents. HNSW is a popular algorithm for approximate nearest neighbor search."
},
{
"title": "Python Development",
"content": "Python is a versatile programming language popular for data science, web development, and AI applications."
},
{
"title": "Database Transactions",
"content": "ACID transactions ensure data integrity. SochDB supports MVCC with serializable snapshot isolation for concurrent access."
},
{
"title": "Machine Learning Models",
"content": "Embedding models convert text to vectors. Popular models include sentence-transformers, OpenAI embeddings, and Cohere embeddings."
},
]
search.add_documents(documents)
print("\n" + "=" * 60)
queries = [
"How does SochDB handle AI workloads?",
"What is HNSW algorithm?",
"How to ensure data consistency?",
]
for query in queries:
print(f"\n๐ Query: {query}")
print("-" * 40)
for i, result in enumerate(search.search(query, k=3), 1):
print(f"{i}. [{result['score']:.3f}] {result['title']}")
print(f" {result['content'][:80]}...")
if __name__ == "__main__":
main()
Run it:
python search.py
Expected output:
Loading model: all-MiniLM-L6-v2...
Model loaded. Embedding dimension: 384
Loaded 0 existing documents
Adding 5 documents...
Index built: 5 vectors
โ
Added 5 documents (total: 5)
============================================================
๐ Query: How does SochDB handle AI workloads?
----------------------------------------
1. [0.842] SochDB Overview
SochDB is an LLM-native database designed for AI applications...
2. [0.534] Machine Learning Models
Embedding models convert text to vectors...
3. [0.423] Vector Search Basics
Vector search uses embeddings to find semantically similar documents...
๐ Query: What is HNSW algorithm?
----------------------------------------
1. [0.756] Vector Search Basics
Vector search uses embeddings to find semantically similar documents...
2. [0.412] SochDB Overview
SochDB is an LLM-native database designed for AI applications...
3. [0.389] Machine Learning Models
Embedding models convert text to vectors...
๐ Query: How to ensure data consistency?
----------------------------------------
1. [0.698] Database Transactions
ACID transactions ensure data integrity...
2. [0.312] SochDB Overview
SochDB is an LLM-native database designed for AI applications...
3. [0.287] Vector Search Basics
Vector search uses embeddings to find semantically similar documents...
With only 5 vectors, the engine does not build an HNSW graph at query time โ it uses an exact parallel SIMD flat scan (see Flat-scan below). HNSW only matters once you cross the per-dimension flat-scan threshold.
Step 5: HNSW Configuration and Defaultsโ
The core engine's HnswConfig (Rust, sochdb-index/src/hnsw.rs) ships these
defaults โ they are tuned to clear 95% recall@10 out of the box:
| Parameter | Default | Meaning |
|---|---|---|
max_connections (M) | 32 | Neighbors per node above layer 0 |
max_connections_layer0 (M0) | 64 | Neighbors at layer 0 (= 2ยทM) |
level_multiplier (mL) | 1 / ln(32) | Layer-assignment probability |
ef_construction | 256 | Build-time search breadth (richer graph) |
ef_search | 500 | Query-time search breadth (recall vs. latency) |
metric | Cosine | Distance metric |
quantization_precision | F32 | Vector storage precision |
enable_product_quantization | false | Optional PQ for very large/high-dim sets |
Reported recall@10 on deep-1M: 0.967 at M=16/M0=32, 0.988 at M=32 (the
default), 0.990 at M=48. ef_construction=256 particularly helps hard real
embeddings (e.g. Cohere) where M alone left recall near 0.90.
ef_search split in the coreef_search defaults to a single fixed value of 500; there is no
500/1500-by-dimension branching in the core engine. The dimension-aware logic
that does exist is the flat-scan threshold.
The Python recommended_hnsw_params() helper (native package) does derive an
ef_search from dimension + target recall โ that is an SDK convenience, not a
core default.
Distance metricsโ
The DistanceMetric enum offers three options:
| Metric | Use when | Notes |
|---|---|---|
| Cosine (default) | Normalized text/embedding similarity | Vectors are unit-normalized at ingest when enabled |
| Euclidean (L2) | Geometric / magnitude-aware distance | Straight-line distance |
| DotProduct | Inner-product / un-normalized models | Larger = more similar |
Precision (quantization)โ
Vectors can be stored at reduced precision to cut memory (via the half crate):
| Precision | Bytes/element | Use when |
|---|---|---|
| F32 (default) | 4 | Maximum recall, baseline |
| F16 | 2 | ~2ร memory savings, slight recall loss |
| BF16 | 2 | Wider dynamic range than F16 |
Product Quantization (enable_product_quantization) is an additional,
optional codec that is off by default, intended for very large
(> 100k vectors) and high-dimensional (> 128D) collections.
Step 6: Flat-Scan, search_smart, and search_exactโ
Flat-scan (exact) for small datasetsโ
For small indexes, an exact parallel SIMD flat scan is both correct and faster than walking an HNSW graph. The engine chooses flat scan when the vector count is at or below a dimension-aware threshold:
| Dimension | Flat-scan threshold (vectors) |
|---|---|
| โค 128 | 10,000 |
| โค 384 | 4,000 |
| 768+ (else) | 1,000 |
At or below the threshold, search runs a rayon-parallel SIMD brute-force scan and returns exact results. Above it, the approximate HNSW path takes over.
search_exact vs. search_smartโ
The engine exposes explicit exact search and an automatic chooser:
search_exact(query, k)โ always brute-force exact k-NN (alsosearch_exact_f64for f64 inputs). Use this for ground-truth recall checks.search_smart(query, k, exact_threshold)โ ifdataset_size <= exact_threshold(default 1000) it callssearch_exact; otherwise it runs the adaptive HNSW search. A convenient default for mixed-size workloads.
In the 0.5.9 ctypes SDK these surface on VectorIndex as
search_exact(...) / search_exact_f64(...) alongside the regular search(...),
plus build_flat_cache() to warm the flat-scan path.
Step 7: Graph Maintenance and Diagnosticsโ
After heavy inserts (especially incremental ones), you can repair and tighten the
graph for better recall. These operations exist in the native 2.0.3 package
on HnswIndex:
| Method | What it does |
|---|---|
refine_graph_additive() -> int | Parallel additive neighbor refinement (fills empty slots, never removes edges); returns count improved |
refine_graph() -> int | General neighbor refinement pass |
optimize() -> int | Exact brute-force layer-0 rebuild (recall booster for small indexes; ~0.3s per 10K) |
repair() -> int | Reconnect orphaned nodes via BFS |
diagnose() -> dict | Connectivity/degree report: reachable, total, orphan_count, avg_degree, zero_degree_nodes, target_degree |
optimize() is a small-index booster, not a large-index stepThe exact layer-0 rebuild is O(NยฒยทD). At ~1M vectors it can OOM a large box and
run for hours, so the core guards it as a no-op above a size cap โ the same
1M build skipping it succeeded in ~195s at recall@10 = 0.968. Treat
optimize() as a recall booster for small-to-medium indexes only.
# Native package (sochdb 2.0.3): build, refine, inspect
import numpy as np
from sochdb import HnswIndex
index = HnswIndex(dimension=768, m=32, ef_construction=256, metric="cosine")
index.insert_batch(np.random.randn(50_000, 768).astype(np.float32))
improved = index.refine_graph_additive() # tighten the graph
report = index.diagnose() # connectivity / degree stats
print(f"refined={improved} orphans={report['orphan_count']} "
f"avg_degree={report['avg_degree']:.1f}")
Step 8: Tune Performanceโ
Build-time parametersโ
| Parameter | Effect | Trade-off |
|---|---|---|
m=16 | Faster build, less memory | Lower recall on high-dim data |
m=32 | Engine default | 95+ recall@10 out of the box |
m=48 | Best recall (0.990 @ deep-1M) | More memory, slower build |
ef_construction=200 | Faster build | Slightly lower quality |
ef_construction=256 | Engine default | Strong quality on hard embeddings |
ef_construction=400 | Highest quality | Slow build |
Search-time tuningโ
ef_search (the query-time beam width) trades recall for latency. The 0.5.9
VectorIndex exposes it as a settable property:
index.ef_search = 50 # fast, ~0.9 recall
index.ef_search = 100 # balanced, ~0.95 recall
index.ef_search = 200 # high recall, ~0.99
The native recommended_hnsw_params(dimension, target_recall=0.95) helper returns
dimension-aware m, ef_construction, and ef_search suggestions:
- M: dim โค 128 โ 16; 129โ512 โ 24; 513+ โ 32
- ef_construction:
max(200, M*8) - ef_search: recall โฅ 0.99 โ 40ยทM; โฅ 0.95 โ 20ยทM; โฅ 0.90 โ 10ยทM; else 6ยทM
The helper's note warns that synthetic uniform-random vectors need roughly 2โ3ร
higher ef_search than real embedding data to hit the same recall.
Memory estimationโ
Memory โ vectors ร dimensions ร bytes/element ร ~1.5 overhead
F32 example: 100,000 ร 384 ร 4 ร 1.5 โ 230 MB
F16 example: 100,000 ร 384 ร 2 ร 1.5 โ 115 MB
Per-SDK Quick Referenceโ
The same HNSW engine is reachable from every SDK. Constructor parameter names and defaults differ per language; the table-defining values above are the core engine's.
- Python (0.5.9 SDK)
- Python (2.0.3 native)
- Rust
- Node.js
- Go
# Embedded VectorIndex (defaults mirror the engine: M=32, efc=256)
import numpy as np
from sochdb import VectorIndex
index = VectorIndex(dimension=768, max_connections=32, ef_construction=256)
index.ef_search = 100
ids = np.arange(1000, dtype=np.uint64)
index.insert_batch(ids, np.random.randn(1000, 768).astype(np.float32))
query = np.random.randn(768).astype(np.float32)
for doc_id, distance in index.search(query, k=10):
print(doc_id, distance)
# Exact ground-truth search for recall checks
exact = index.search_exact(query, k=10)
# Native HnswIndex + dimension-aware builders
import numpy as np
from sochdb import HnswIndex, build_index_from_numpy, recommended_hnsw_params
# Constructor defaults: m=32, ef_construction=200, metric="cosine", precision="f32"
idx = HnswIndex(dimension=768, m=32, ef_construction=256,
metric="cosine", precision="f32")
idx.insert_batch(np.random.randn(1000, 768).astype(np.float32))
ids, dists = idx.search(query=np.random.randn(768).astype(np.float32), k=10)
# Auto-tuned build from a NumPy matrix
emb = np.random.randn(10_000, 768).astype(np.float32)
auto = build_index_from_numpy(emb) # picks M for 768D
params = recommended_hnsw_params(768) # {m, ef_construction, ef_search, note}
MultiShardHnswIndex is Python-onlyMultiShardHnswIndex (a threaded scatter-gather wrapper for 100Mโ1B vectors that
routes by id % n_shards) exists only in this native Python package. It is
not a core-engine type and is not exposed by the server or other SDKs.
// cargo add sochdb (crate `sochdb`, currently 2.0.3)
use sochdb::SochConnection;
use std::sync::Arc;
let conn = Arc::new(SochConnection::open("./vector_db")?);
// Create a collection and add vectors
let mut col = sochdb::vectors::VectorCollection::create(&conn, "docs", 768)?;
col.add(&["a", "b"], &[vec![0.1; 768], vec![0.2; 768]])?;
// search(query, k) -> Vec<SearchResult { id, distance, metadata }>
let hits = col.search(&vec![0.1; 768], 10)?;
for h in hits {
println!("{} {}", h.id, h.distance);
}
The sochdb crate's VectorCollection has no HnswConfig/HnswIndex and no
m/ef_construction/ef_search parameters. It uses a scale-aware backend
(brute-force in memory below 100K vectors, then Vamana + product quantization).
The configurable HnswConfig (defaults M=32, M0=64, ef_construction=256,
ef_search=500, Cosine, F32) lives in the internal sochdb-index crate that powers
the engine and the Python native package โ it is not part of the public Rust SDK.
// npm install @sochdb/sochdb (SDK 0.5.3)
import { EmbeddedDatabase, HnswIndex } from "@sochdb/sochdb";
// EmbeddedDatabase.open() is SYNCHRONOUS (returns the db, not a Promise)
const db = EmbeddedDatabase.open("./search_db");
// Embedded HNSW lives on the standalone HnswIndex class.
// HnswConfig defaults mirror the engine: maxConnections=32, efConstruction=256.
const index = new HnswIndex({ dimension: 768, efSearch: 100 });
index.insertBatch(ids /* string[] */, vectors /* number[][] */);
const results = index.search(query, 10); // -> { id, distance }[]
// Transactions: commit() returns Promise<void>
const txn = db.transaction();
await txn.put(Buffer.from("k"), Buffer.from("v"));
await txn.commit();
// go get github.com/sochdb/sochdb-go (SDK 0.4.5)
// Remote-first by default: a plain build compiles only the gRPC/remote path.
// The embedded FFI engine is behind the `sochdb_embedded` build tag.
import sochdb "github.com/sochdb/sochdb-go"
// GrpcConnect dials a remote server (Connect() is the Unix-socket IPC path).
client, _ := sochdb.GrpcConnect("localhost:50051")
defer client.Close()
// CreateIndex(name, dimension, metric); HNSW params are fixed server-side.
client.CreateIndex("docs", 768, "cosine")
client.InsertVectors("docs", ids /* []uint64 */, vectors /* [][]float32 */)
results, _ := client.GrpcSearch("docs", query, 10) // -> []GrpcSearchResult
Step 9: Production Considerationsโ
Use persistent embeddingsโ
Don't regenerate embeddings on every load โ store them alongside the document and reload them on startup:
def add_documents_with_cache(self, documents):
"""Store embeddings alongside documents."""
contents = [doc["content"] for doc in documents]
embeddings = self.embeddings.embed(contents)
with self.db.transaction() as txn:
for i, (doc, emb) in enumerate(zip(documents, embeddings)):
doc_id = len(self._documents) + i
txn.put(f"docs/{doc_id}".encode(), json.dumps(doc).encode())
txn.put(f"embeddings/{doc_id}".encode(), emb.tobytes()) # binary
Batch processingโ
For large datasets, insert in batches to manage memory:
def add_documents_batched(self, documents, batch_size=1000):
"""Add documents in batches to manage memory."""
for i in range(0, len(documents), batch_size):
batch = documents[i:i + batch_size]
self.add_documents(batch)
print(f"Processed {min(i + batch_size, len(documents))}/{len(documents)}")
For the fastest bulk path that bypasses Python FFI overhead, see Bulk Operations.
What You Learnedโ
| Concept | What You Did |
|---|---|
| Embeddings | Converted text to vectors using sentence-transformers |
| HNSW indexing | Built an approximate nearest neighbor index with engine defaults (M=32, ef_construction=256) |
| Flat-scan & exact search | Understood when SochDB searches exactly vs. approximately |
| Tuning | Used ef_search, metrics, precision, and graph maintenance |
| SochDB integration | Stored documents and vectors together |
Next Stepsโ
| Goal | Resource |
|---|---|
| Filtered / session-scoped search | HNSW Vector Search with Filtering |
| Fastest bulk index builds | Bulk Operations |
| Build a RAG system | MCP Integration |
| Optimize performance | Performance Guide |
| Production deployment | Deployment Guide |
Tutorial completed! You've built a working semantic search system with SochDB. ๐