Articles / Local Vector Database: sqlite-vec, LanceDB, Chroma or Qdrant

vector database

Local Vector Database: sqlite-vec, LanceDB, Chroma or Qdrant

Finn ·

A local vector database stores embeddings in files on your own machine and searches them inside your program, with no database server to run. On the same 945 paragraphs, sqlite-vec, LanceDB, Chroma and Qdrant's local mode returned identical top-three matches. So pick by access: which language opens the file, whether two processes share it, and how far it must grow.

I ran the test on September 25, 2026, on a Mac, with chromadb 1.5.9, lancedb 0.39.0, sqlite-vec 0.1.9 and qdrant-client 1.19.1 under Python 3.13. Local here means embedded: the database runs inside your process. Running a server yourself is covered at the end.

Which local vector database to pick

  • sqlite-vec if you want one file that most languages can open and that joins your other tables in plain SQL. Search is an exact scan, about 40 ms per query at 100,000 vectors in my test.
  • LanceDB if you work in Python or TypeScript and expect the data to grow: it ran the fastest exact scan here and can build an approximate index later. By default, a process that already has a table open does not see another process's writes.
  • Chroma if you want the shortest path from raw text to search in Python: it ships a default embedding model, so you can add text without choosing one. Its JavaScript client needs a Chroma server.
  • Qdrant's local mode if you are prototyping in Python and plan to move to a Qdrant server. It locks its folder to one process.

If the app already runs on Postgres, pgvector is a fifth option, which I did not test: it searches exactly by default and adds HNSW or IVFFlat indexes when you need speed.

The test, and what came out identical

The data is every prose paragraph of 80 characters or more from the 45 English articles on this site: 945 paragraphs, embedded once with all-MiniLM-L6-v2, a small open model that returns 384 numbers per text. The same vectors went into each store with cosine distance. One process wrote each store and exited; a fresh process reopened it to query.

Take the query "how do I check that my model really runs locally". All four stores returned the same three paragraphs in the same order, all from the article on what self-hosted AI agents keep on your machine. The top match, the paragraph that starts "Where does the model run?", came back at a cosine distance of 0.396. An exact scan in numpy, the query multiplied by every stored vector and sorted, agreed with the four stores on all three test queries.

At this size, the database does not change what you find; the way you cut the text and the model that embeds it do.

Four local vector databases returned identical matches on 945 paragraphs; pick by who opens the file and how far it must grow.

Four differences that decide

What you install and which language opens it. Once installed, sqlite-vec took 168 KB, Qdrant's client 76 MB with its dependencies, Chroma 296 MB (a 76 MB ONNX runtime runs its built-in embeddings) and LanceDB 298 MB, mostly its engine and PyArrow. sqlite-vec has bindings for Python, Node.js, Ruby, Go and Rust; Node's built-in node:sqlite module read the file Python had written and returned the same three results. LanceDB's TypeScript package did the same with its table. Chroma's client docs say that "to connect with the JS/TS client, you must connect to a Chroma server", and Qdrant's README presents local mode as a feature of its Python client.

What a second process sees. One process held each store open while a second one queried, then wrote. Qdrant refused the second process: "If you require concurrent access, use Qdrant server instead." Chroma and sqlite-vec accepted it, and the first process saw the new row on its next read. That was one reader and one writer, not a load test. LanceDB accepted the write, but the first process kept counting the old rows until it called checkout_latest(). Its consistency docs confirm the default: "no automatic cross-process refresh checks". Connect with read_consistency_interval=timedelta(0) if a web process must see what an ingestion script just added; with that setting, the new row showed up at once.

How search behaves at 100,000 vectors. I loaded 100,000 random 384-number vectors and ran 20 queries, each a stored vector plus noise, so the right answer is known. sqlite-vec's stable release and LanceDB without an index compare the query with every vector: medians of 39 ms and 12 ms, with the planted vector ranked first 20 times out of 20. Qdrant's local mode was also exact, at 24 ms. Chroma answers from an HNSW graph, an approximate index. Its median was 2.3 ms, but the planted vector came first in only 15 queries out of 20 with the default ef_search of 100. Raised to 400, it came first in all 20, at 4.6 ms. Random vectors are not text embeddings, so this does not predict Chroma's recall on your data. It shows why an approximate index needs a recall check.

How finished it is. sqlite-vec's README warns: "expect breaking changes"; its approximate indexes exist only in alpha releases. Chroma's architecture page presents embedded mode as for "prototyping and experimentation", and Qdrant's client source warns that local mode "is not recommended for collections with more than 20,000 points". None of this rules out a personal tool; it tells you what to replace first as it grows.

One trap on macOS: the python.org build of Python 3.13 used here lacks SQLite extension support and failed on enable_load_extension. The sqlite-vec Python guide documents that error and suggests Homebrew's Python; a uv-managed Python 3.13 worked.

The smallest working setup, with its check

This is the sqlite-vec code from the test, cut to what you need. It assumes texts is your list of chunks and vecs a float32 numpy array of their embeddings, one row each, normalized to length 1: np.linalg.norm(vecs, axis=1) should print ones.

import sqlite3, sqlite_vec, numpy as np

db = sqlite3.connect("notes.db")
db.enable_load_extension(True)
sqlite_vec.load(db)
db.enable_load_extension(False)

db.execute("create table if not exists chunks(id integer primary key, body text)")
db.execute("create virtual table if not exists vec_chunks "
           "using vec0(embedding float[384] distance_metric=cosine)")

with db:
    for i, (text, vec) in enumerate(zip(texts, vecs), start=1):
        db.execute("insert into chunks values (?, ?)", (i, text))
        db.execute("insert into vec_chunks(rowid, embedding) values (?, ?)",
                   (i, vec.astype(np.float32).tobytes()))

def search(q, k=3):
    return db.execute(
        "select c.id, round(v.distance, 3), substr(c.body, 1, 60) "
        "from vec_chunks v join chunks c on c.id = v.rowid "
        "where v.embedding match ? and k = ? order by v.distance",
        (q.astype(np.float32).tobytes(), k)).fetchall()

def agreement(q, k=3):
    exact = {int(i) + 1 for i in np.argsort(-(vecs @ q))[:k]}  # rowids start at 1
    return len(exact & {row[0] for row in search(q, k)}) / k

Change float[384] to your model's dimension, and embed each question with the same model as the chunks. On the test data, search(q) returned the three paragraphs above at distances 0.396, 0.557 and 0.595, and agreement(q) returned 1.0. Rerun it on 20 real questions whenever you change store, index or settings. With exact search it should return 1.0; it earns its place the day you switch to an approximate index. The next step is to score answers rather than neighbors, which the D-RAG paper analysis turns into four retrieval rules you can copy.

When a local file is the wrong choice

An embedded store needs a disk that outlives your process, with every reader on that same machine. Serverless functions break the first condition: AWS describes Lambda's /tmp as "temporary and unique to each execution environment". Two machines writing break the second. The usual answer to both is a server: chroma run for Chroma, Qdrant in Docker, or pgvector in your Postgres. For the serverless case, LanceDB can also keep tables on S3-compatible storage, which its storage docs cover. If an agent should query the store, search() becomes one of its tools, and connecting an agent to your apps covers the three ways to wire it.

Before installing anything, list every process that will open the store and the language each one runs in. If the list holds one Python process, any of the four works, though Qdrant's client advises against local mode past 20,000 vectors. If a Node app and a Python script share the data, use sqlite-vec, or LanceDB with the read consistency setting above. If two machines write to it, run a server.

FAQ

Is FAISS a local vector database? FAISS describes itself as "a library for efficient similarity search and clustering of dense vectors", and its README assumes vectors "identified by an integer". Keeping the text and metadata that go with those integers is your code's job; the four stores above keep them together.

Did this article help?

Get the best articles, carefully selected to save you time.

Read next

OpenClaw connects to MCP servers as a client: each server exposes tools, and OpenClaw hands those tools to your agents. You add one from Settings, from the chat composer, or from the command line. Connecting a server does not bypass your tool policy, and saving a definition proves nothing about reachability. Only a probe does.

Featured

A pivot is often just the polite word we use with investors when the first company is dead and we have decided to build another one. And that is fine. Not because failure is noble, but because luck needs exposure: every market you enter, every product you ship and every channel you test is one more surface where something unexpected can land.

Marketing articles

SaaS marketing automation is the set of messages your product sends because of what a user did inside it: signed up, reached the first useful result, went quiet, ran out of trial. The hard part is not picking a tool from a list of thirty. It is naming the one event that means a user got value, then hanging every trigger and every exit condition off that name.

An AI marketing cloud is a vendor suite that stores customer data, runs campaigns across channels and reports on them, with AI attached to five tasks: writing content, choosing who receives it, deciding when and where, explaining results, and answering replies. Salesforce sells its version from $1,500 per org per month; HubSpot from $7 per seat. The data underneath is priced separately.

Projects

Brands

The essentials, by email.

What works, what does not, what I would do differently. Sent when I have something useful to say.