
LanceDB vs SQLite-vec vs Chroma: Embedded Vector Storage Architecture & Setup for AI Agents
[!NOTE] Scope & Methodology: This guide provides an architectural and specification comparison synthesized from official documentation, project source repositories, and memory model analysis for LanceDB, SQLite-vec, and Chroma. It evaluates disk I/O characteristics, vector index algorithms, and concurrency locking mechanics without asserting empirical laboratory benchmark measurements.
Spin up a standalone Milvus or Qdrant Docker cluster just so a local coding agent can remember fifty terminal commands across sessions, and you waste 2 gigabytes of system RAM before the LLM even loads. Local agents running inside Claude Code, Cursor, or custom FastMCP servers do not need a client-server database over TCP. They need an embedded storage engine that links directly into the Python process and persists state to disk.
The embedded vector ecosystem on Linux centers on three open-source architectures: LanceDB, SQLite-vec, and Chroma.
Each engine makes a radically different bet on how memory, disk I/O, and relational metadata interact. Choose the wrong one for your workload, and your agent will either run out of host RAM on large codebases or crash with sqlite3.OperationalError: database is locked during concurrent tool calls.
Architectural Breakdown: Disk-Native Columnar vs Pure C Flat Scan vs In-Memory HNSW
Instead of treating embedded vector stores as interchangeable black boxes, evaluate how their storage engines manage memory and disk persistence under load:
| Architectural Dimension | LanceDB (Rust / Arrow Core) | SQLite-vec (vec0 C Extension) |
Chroma (chromadb Persistent) |
|---|---|---|---|
| Underlying Storage Format | Disk-native Lance columnar format | Standard SQLite .db B-Tree pages |
Dual SQLite metadata + HNSW binary files |
| Vector Index Structure | Disk-backed IVF-PQ / IVF-HNSW | Exact brute-force SIMD flat scan | In-memory HNSW graph |
| Host RAM Scaling Profile | Near-constant (Zero-copy mmap from NVMe) |
Minimal (Streams chunks via SQLite page cache) | Scales linearly ($O(N)$) with vector count |
| Concurrency & Locking | Append-only manifest versioning | Full ACID (Requires WAL mode for readers) |
Single-process lock hazards across workers |
| Metadata Filtering | Pre-filtering via Apache Arrow pushdown | Standard SQL JOIN and WHERE clauses |
Custom dictionary filter DSL |
| External Dependencies | Python wheel with compiled Rust core | Zero dependencies (Single .so / .dylib C file) |
Heavy Python dependency tree |
| Optimal Agent Workload | Codebase indexing (50k to 5M+ chunks) | Unified relational + vector state (<50k rows) | Rapid single-process Python prototyping |
1. LanceDB: Disk-Native Scale Without the RAM Tax
Traditional vector databases load their entire HNSW graph into system RAM at startup. If you index a monorepo alongside local weights on a 32GB Linux workstation, that memory footprint competes directly with your vLLM KV cache.
LanceDB avoids this trap by building on the Lance columnar format. It keeps both the raw vectors and the IVF-PQ (Inverted File with Product Quantization) index on your NVMe drive, reading only the required partitions during a query via zero-copy memory mapping.
import lancedb
import numpy as np
# 1. Connect to a local directory on your Linux filesystem
db = lancedb.connect("./agent_lance_store")
# 2. Construct structured records with 768-d embeddings (via Apache Arrow)
records = [
{
"id": i,
"vector": np.random.randn(768).astype(np.float32),
"tool_name": "bash_exec",
"command": f"pytest tests/unit/test_worker_{i}.py",
"exit_code": 0 if i % 5 != 0 else 137
}
for i in range(2000)
]
table = db.create_table("execution_history", data=records, mode="overwrite")
# 3. Build a disk-backed IVF-PQ index once your table exceeds ~10,000 rows
table.create_index(metric="cosine", num_partitions=32, num_sub_vectors=48)
# 4. Execute a vector search with SQL predicate pushdown
query_vec = np.random.randn(768).astype(np.float32)
matches = (
table.search(query_vec)
.where("exit_code = 137", prefilter=True)
.limit(5)
.to_list()
)
for row in matches:
print(f"ID: {row['id']} | Cmd: {row['command']} | Dist: {row['_distance']:.4f}")
Operational Notes from Production
- When Not to Index: Do not call
table.create_index()on tiny tables with 500 rows. LanceDB performs exact flat scans over Apache Arrow buffers in single-digit milliseconds for small datasets. Building an IVF-PQ index on tiny data degrades recall without saving time. - Version Accumulation: Because LanceDB uses an append-only immutable manifest for concurrency safety, frequent single-row inserts from an agent loop will create hundreds of small fragment files. Run
table.compact_files()andtable.cleanup_old_versions()periodically in long-running daemons.
2. SQLite-vec: Unified ACID State in a Single File
Most local AI agents already use SQLite (or PostgreSQL) to store conversation threads, token budgets, and tool audit logs. Maintaining a separate vector directory alongside your relational .db file creates a synchronization headache: if the agent crashes mid-turn, your SQL table and vector index drift out of sync.
SQLite-vec eliminates that split brain. Written in pure C with zero third-party dependencies, it registers a vec0 virtual table inside your existing SQLite connection.
import sqlite3
import struct
import numpy as np
import sqlite_vec
def init_agent_db(db_path: str = "./agent_state.db") -> sqlite3.Connection:
db = sqlite3.connect(db_path, timeout=10.0)
db.enable_load_extension(True)
sqlite_vec.load(db)
db.enable_load_extension(False)
# CRITICAL: Enable WAL mode so concurrent MCP tool readers do not block writes
db.execute("PRAGMA journal_mode=WAL;")
db.execute("PRAGMA synchronous=NORMAL;")
db.execute("PRAGMA busy_timeout=5000;")
return db
db = init_agent_db()
# 1. Create relational metadata table and vec0 virtual table in one file
db.execute("""
CREATE TABLE IF NOT EXISTS tool_logs (
rowid INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
command TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
""")
db.execute("""
CREATE VIRTUAL TABLE IF NOT EXISTS vec_logs USING vec0(
rowid INTEGER PRIMARY KEY,
embedding float[768] distance_metric=cosine
);
""")
# 2. Atomic ACID transaction across both relational and vector tables
sample_vec = np.random.randn(768).astype(np.float32)
packed_bytes = struct.pack(f"{len(sample_vec)}f", *sample_vec)
with db:
cursor = db.execute(
"INSERT INTO tool_logs (session_id, command) VALUES (?, ?)",
("sess_alpha", "docker compose up -d")
)
last_id = cursor.lastrowid
db.execute(
"INSERT INTO vec_logs (rowid, embedding) VALUES (?, ?)",
(last_id, packed_bytes)
)
# 3. KNN search joined with relational metadata
query_vec = np.random.randn(768).astype(np.float32)
query_bytes = struct.pack(f"{len(query_vec)}f", *query_vec)
rows = db.execute("""
SELECT
t.session_id,
t.command,
v.distance
FROM vec_logs v
JOIN tool_logs t ON v.rowid = t.rowid
WHERE v.embedding MATCH ? AND k = 3
ORDER BY v.distance ASC;
""", (query_bytes,)).fetchall()
for session_id, cmd, dist in rows:
print(f"[{dist:.4f}] ({session_id}) {cmd}")
The Hidden Trap: SQLite Lock Contention and Flat Scans
When connecting multiple concurrent agent processes to a shared local sqlite-vec memory server, unconfigured SQLite instances quickly fail with sqlite3.OperationalError: database is locked. By default, SQLite operates with standard rollback journaling, which locks the database file during write transactions. To allow non-blocking concurrent reads alongside background writes, always execute PRAGMA journal_mode=WAL; and PRAGMA busy_timeout=5000; on connection initialization.
Understand the scaling boundary as well. SQLite-vec uses SIMD-accelerated brute-force scans rather than an approximate HNSW graph. Up to 30,000 or 50,000 vectors, hardware SIMD instructions scan the binary chunks in a few milliseconds with 100% exact recall. Once you push past 250,000 vectors, linear scan latency becomes noticeable, and you should migrate to LanceDB.
3. Chroma: Fast Python Prototyping and Its Memory Ceiling
Chroma popularized embedded vector storage for Python developers by bundling an HNSW graph (hnswlib), a SQLite metadata store, and an automatic ONNX embedding model inside a single pip install chromadb package.
import chromadb
# 1. Initialize persistent local client
client = chromadb.PersistentClient(path="./agent_chroma_store")
# 2. Create collection with cosine distance
collection = client.get_or_create_collection(
name="project_notes",
metadata={"hnsw:space": "cosine"}
)
# 3. Insert documents (Chroma can auto-embed or accept precomputed vectors)
collection.upsert(
ids=["note_101", "note_102"],
documents=[
"Increase Docker shm-size to 8g when running multi-GPU PyTorch DDP.",
"Pin vLLM --gpu-memory-utilization to 0.88 on 24GB desktop cards."
],
metadatas=[
{"subsystem": "docker", "verified": True},
{"subsystem": "vllm", "verified": True}
]
)
# 4. Query with metadata filter
results = collection.query(
query_texts=["How to prevent shared memory crashes in PyTorch containers?"],
n_results=1,
where={"subsystem": "docker"}
)
print(results["documents"][0])
Why Engineers Migrate Off Chroma in Production
Chroma shines during weekend hackathons because you do not even have to instantiate an embedding model; passing raw strings to query_texts works out of the box.
That convenience breaks down in heavier system architectures:
- In-Memory HNSW Overhead: Chroma loads its HNSW index segments into RAM. As your collection grows into hundreds of thousands of vectors, resident memory consumption balloons accordingly.
- Multi-Process File Corruption: Chroma’s
PersistentClientis not safe for multiple concurrent OS processes writing to the same directory. If two independent MCP stdio servers open./agent_chroma_storesimultaneously, HNSW segment files can corrupt silently. If you need multi-process access in Chroma, you must run it in client-server mode (chroma run --path ./data), which defeats the purpose of an embedded library.
Storage Engine Selection Matrix
Match the storage engine to your agent’s exact lifecycle and data volume:
- Choose SQLite-vec when:
You are building a persistent agent memory bank, chat history store, or MCP tool logger with fewer than 50,000 entries. Having relational tables and exact vector search inside a single ACID-compliant
.dbfile simplifies backups and eliminates state drift. - Choose LanceDB when: You are building a local codebase indexer, AST symbol retriever, or document RAG pipeline spanning 50,000 to several million chunks. Its disk-native Lance format and Apache Arrow integration keep host RAM free for your local inference engine.
- Choose Chroma when: You are writing a quick single-process Python script or prototype and want built-in text tokenization and embedding generation without wiring up external ONNX or SentenceTransformer pipelines.