LanceDB vs SQLite-vec vs Chroma: Embedded Vector Storage Architecture & Setup for AI Agents
Comparisons(Updated )

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() and table.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:

  1. 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.
  2. Multi-Process File Corruption: Chroma’s PersistentClient is not safe for multiple concurrent OS processes writing to the same directory. If two independent MCP stdio servers open ./agent_chroma_store simultaneously, 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:

  1. 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 .db file simplifies backups and eliminates state drift.
  2. 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.
  3. 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.
BC

Written by B. Cho

Systems & AI Infrastructure Engineer

Specializing in GPU inference engines (vLLM, SGLang, Ollama), CUDA memory allocation, and Model Context Protocol (MCP) infrastructure on Linux workstations.