Status: accepted — direction is Option 1 (split MetadataStore +
VectorStore + FtsStore), three phases, async seam first. Implementation not
started.
Targets: Phases 101–103 (async seam first, then pgvector + Qdrant)
Scope: Pluggable storage backends (SQLite · Postgres+pgvector · Qdrant),
local or remote, with a clear index-scoping model (project / user / named).
Today gitsema stores everything in a single better-sqlite3 file at
.gitsema/index.db. That is excellent for the zero-config, single-developer,
single-repo case. It does not serve three other situations the user wants to
support:
- Shared / team indexes — multiple people querying one index without each re-embedding the whole history.
- Large indexes — repos where a single SQLite file and in-memory cosine scan become the bottleneck, where a purpose-built vector DB (pgvector, Qdrant) pays off.
- User freedom — let people run gitsema the way their infrastructure already works: a Postgres they already operate, a Qdrant cluster they already run, or just a file on disk.
The goal is to make where data lives a configuration choice, without forking the codebase or duplicating business logic (per the CLI-first design constraint).
The original request mixed several ideas under the word "scope". Untangling them is the most important design step, because each axis is configured independently.
This is about identity and lifetime of an index, not where bytes sit.
| Scope | Meaning | Anchor | Analogue |
|---|---|---|---|
| project | One index per git repo (today's default) | .gitsema/ in repo root |
.git/ |
| user | One index shared across all of a user's repos | ~/.gitsema/ (XDG) |
~/.gitconfig |
| named | An explicitly addressed index (e.g. a team's shared "backend-monorepo" index) | config / connection string | a database name |
On "local": "local" is not a scope — it is a location (Axis B). A project-scoped index can live on local disk or in a remote Postgres. The three scopes above replace the ambiguous "local / user / project" triad with a clean one. (gitsema's existing config already has exactly two config scopes —
local= repo.gitsema/config.json, andglobal= user~/.config/gitsema/config.json— which map onto project and user here.)
| Location | Meaning |
|---|---|
| local | On this machine's disk (SQLite file; or a Postgres/Qdrant on localhost) |
| remote | A network service: a Postgres/Qdrant server, or gitsema's own HTTP server fronting an index |
Location is largely implied by the backend's connection string (a file
path is local; a postgres://host or https://qdrant URL is remote). gitsema
also already has a "remote" mode where a thin client delegates embedding +
storage to a gitsema HTTP server (GITSEMA_REMOTE); that remains a valid way
to reach a remote index and is orthogonal to the DB backend the server uses.
| Backend | Relational metadata | Vectors | BM25 / full-text |
|---|---|---|---|
| sqlite (default) | SQLite | SQLite BLOB + optional usearch HNSW | SQLite FTS5 |
| postgres | Postgres | pgvector | Postgres tsvector (or pg_search/ParadeDB BM25) |
| qdrant | requires a companion relational store | Qdrant collections | from the companion store |
The three axes compose. e.g. project-scope + remote + postgres = "this
repo's index lives in our shared Postgres". user-scope + local + sqlite =
"one personal index for all my repos, in ~/.gitsema".
From the architecture study:
- ~115 import sites call
getActiveSession()/getRawDb()/getDb()directly. There is no storage interface — call sites reach for the DB handle. - 230+ raw
.prepare()statements, synchronous.transaction(),PRAGMA, and FTS5 are scattered across indexing, search, server, and CLI. - 8 tables store vectors as Float32/Int8 BLOBs; the other ~15 are relational metadata (blobs, paths, commits, branches, chunks, symbols, clusters, config, settings, repos, tokens…).
- Search loads all candidate vectors into JS memory and computes cosine in
pure JS;
usearchHNSW is an optional candidate-prefilter only. - The killer detail:
better-sqlite3is synchronous; Postgres and Qdrant drivers are async. Introducing them forces an async boundary that today does not exist anywhere in the read/write path.
The user asked to explain the choices rather than pre-pick one. Here they are, with the trade-offs that matter.
Keep a relational metadata store always present (SQLite or Postgres),
holding everything that is fundamentally relational — blobs, paths, commits,
blob_commits, branches, chunks/symbols metadata, clusters metadata, config,
settings, repos. Make the vector store pluggable (SQLite BLOB, pgvector,
Qdrant) and the keyword/BM25 store (FtsStore) independently pluggable
(SQLite FTS5, Postgres tsvector/pg_search, or a dedicated search engine).
A StorageProfile wires up all three; a backend may implement one, two, or all
three interfaces. FtsStore is optional — a profile with fts: null simply
has no keyword search, and --hybrid reports "unavailable" instead of erroring.
┌─────────────────────┐ ┌────────────────────┐ ┌──────────────────────┐
indexer ───► │ MetadataStore │ │ VectorStore │ │ FtsStore (opt.) │
search ───► │ (SQLite | Postgres) │ │ (SQLite | pgvector │ │ (FTS5 | tsvector / │
│ blobs, paths, │ │ | Qdrant) │ │ pg_search | engine) │
│ commits, branches… │ │ embeddings │ │ BM25 keyword search │
└─────────────────────┘ └────────────────────┘ └──────────────────────┘
- Pros:
- Matches the user's own observation — "some data cannot be placed into qdrant, so it's likely stored in db anyway." This makes that a first-class design fact instead of a workaround.
- Keyword search is independently swappable. BM25 differs the most across
backends (FTS5 vs
tsvectorvs none-in-Qdrant), so giving it its own seam lets us later drop in OpenSearch / Tantivy / Meilisearch without touching metadata, and lets hybrid search be cleanly optional. - Natural Postgres story:
postgresmetadata +pgvectorvectors +tsvectorFTS are all the same Postgres connection — one backend, three interfaces. - Qdrant becomes "just a vector store"; pair it with SQLite/Postgres metadata
and any
FtsStore. No need to reinvent the commit graph in Qdrant.
- Cons:
- Three coordinated stores → write paths must keep them consistent (a blob's metadata row, its vector, and its FTS content). Needs a documented consistency story (idempotent re-index already gives us most of this — §8).
- With Qdrant (or an external FTS engine), a write spans multiple systems (no
single transaction). Acceptable because indexing is idempotent and
content-addressed (re-running heals drift);
doctorchecks all three stores for orphans.
Define a single interface; each backend implements all operations including FTS/BM25 and the commit graph. SQLite, Postgres, and "Qdrant + relational sidecar" each implement the whole surface.
- Pros: One seam, one mental model; call sites never think about "which store". Cleanest long-term if gitsema grows many backends.
- Cons: Largest refactor — every relational query (commit graph, ownership, clustering, debt scoring, temporal joins, FTS) goes behind the interface and becomes async. That's the bulk of the 230+ raw SQL sites, many of which are rich relational queries that pgvector/Qdrant gain nothing from abstracting. High effort, high regression risk, slow payoff. The Qdrant implementation still secretly needs a relational sidecar, so the "single backend" abstraction is partly fiction.
SQLite always holds metadata + FTS; only embeddings may move to pgvector/Qdrant.
- Pros: Smallest possible change. Quickest path to "vectors in Qdrant".
- Cons: No all-Postgres / no server-side relational deployment — every node still needs a local SQLite metadata file, which undercuts the "shared team index in our Postgres" use case. A dead end for the team scenario.
Option 1 — split MetadataStore + VectorStore + FtsStore — is the chosen
direction. It is the only option that (a) honors the "some data can't go in
Qdrant" reality as a design principle, (b) still unlocks a fully-remote
all-Postgres deployment for teams, and (c) keeps the FTS/commit-graph/temporal
machinery in the relational world where it's cheap, instead of forcing it
through a vector-store-shaped hole. Option 3 is what we'd fall back to if we
needed Qdrant fast and were willing to give up the shared-Postgres story;
Option 2 is a possible later consolidation once Option 1's seams have proven
themselves.
better-sqlite3 is synchronous; pg and Qdrant's client are async. Whatever
abstraction we pick, the moment a non-SQLite backend exists, the store
interface must be async (return Promise). The question is how to get
there without a destabilizing big-bang.
Make the MetadataStore/VectorStore/FtsStore methods async. The SQLite adapter wraps its
synchronous calls in already-resolved promises (zero real cost). Migrate call
sites to await incrementally, slice by slice, behind a green test suite:
- Vector read path (
vectorSearch,hybridSearch,commitSearch) first — smallest, highest-value surface for pgvector/Qdrant. - Indexing write path (
blobStore,indexer,deduper). - The long tail of analysis/temporal/clustering queries — these can keep using the SQLite session directly until a Postgres metadata backend needs them; they're guarded by "metadata backend = sqlite" until then.
- Pros: Mechanical, reviewable, reversible per slice. SQLite behavior is unchanged (sync under the hood). Lets pgvector/Qdrant land before the entire codebase is async.
- Cons: Large diff (lots of
awaitthreading). Mixed sync/async during the transition needs discipline (lint rule / typed boundary).
Local CLI stays on synchronous SQLite. Postgres/Qdrant are reachable only
through gitsema's existing HTTP server (gitsema tools serve / GITSEMA_REMOTE),
which is already an async Express app and can talk to async backends internally.
- Pros: Near-zero churn in CLI/search/indexing call sites. Reuses the remote client/indexer plumbing that already exists. Ships a "team Postgres" story quickly.
- Cons: No direct local Postgres/Qdrant from the CLI — you always go
through a server, even on
localhost. Feels heavyweight for a solo dev who just wants their vectors in a local pgvector. Splits behavior ("some backends only via server") which is confusing.
Run async drivers in a worker thread and block the main thread via
Atomics.wait to preserve a synchronous facade.
- Pros: No call-site changes at all.
- Cons: Real complexity and fragility (serialization across the worker boundary, error propagation, connection pooling per worker), and it throws away async's actual benefit (concurrency). Not recommended; listed for completeness.
Strategy A. It's the honest path: gitsema is going to be partly async forever once Postgres/Qdrant exist, so make the seam async and migrate behind tests. Strategy B is attractive as a first deliverable (ship team-Postgres via the server while the CLI migration proceeds) and the two are compatible — we can do B's server wiring early and A's CLI migration in parallel.
New module src/core/storage/ with backend-agnostic interfaces:
// src/core/storage/types.ts
export interface VectorStore {
upsert(kind: VectorKind, items: VectorRecord[]): Promise<void>
// ANN/topK search with metadata filters (model, branch, time, allowedHashes)
search(kind: VectorKind, query: Float32Array, opts: VectorSearchOpts): Promise<VectorHit[]>
delete(kind: VectorKind, ids: string[]): Promise<void>
count(kind: VectorKind, model: string): Promise<number>
}
export type VectorKind = 'file' | 'chunk' | 'symbol' | 'module' | 'commit'
export interface MetadataStore {
// relational surface: blobs, paths, commits, blob_commits, branches,
// chunks, symbols, clusters, config, settings, repos…
// (mirrors today's blobStore + query helpers, made async)
// …the existing query helpers, behind one async seam
}
// keyword / BM25 store — independently pluggable, and OPTIONAL
export interface FtsStore {
index(blobHash: string, content: string): Promise<void>
search(query: string, limit: number): Promise<Bm25Hit[]> // returns blob_hash + score
delete(blobHashes: string[]): Promise<void>
}
export interface StorageProfile {
metadata: MetadataStore
vectors: VectorStore
fts: FtsStore | null // null ⇒ no keyword search; --hybrid reports unavailable
}| Store | sqlite profile | postgres profile | qdrant profile |
|---|---|---|---|
| MetadataStore — blobs, paths, commits, blob_commits, branches, chunks, symbols (metadata), clusters meta, repos, tokens, config, settings, indexed_commits, checkpoints | SQLite tables | Postgres tables | SQLite or Postgres (companion) |
| VectorStore — file/chunk/symbol/module/commit embeddings | SQLite BLOB (+ usearch) | vector columns (pgvector, HNSW/IVFFlat) |
Qdrant collections (one per kind) |
| FtsStore — keyword/BM25 (optional) | SQLite FTS5 | Postgres tsvector (or ParadeDB pg_search) |
companion's FTS, an external engine, or null |
| query embedding cache, projections, blob_clusters centroid | SQLite BLOB | pgvector | stay in companion relational store (small, not worth Qdrant) |
Why the companion store for Qdrant: Qdrant holds vectors + a small payload for filtering (model, blob_hash, branch, first_seen). Everything relational (commit graph, ownership, temporal joins) lives in the SQLite/Postgres companion, and keyword search lives in whatever
FtsStorethe profile names. This is the concrete form of the user's "some data is stored in db anyway."
The FtsStore seam isolates the part that differs most across backends. Scores
are normalized the same way hybridSearch.ts already does, so the vector⊕BM25
fusion math stays backend-independent.
- sqlite: unchanged FTS5
bm25(). - postgres:
tsvector+ts_rank_cdas a baseline; optionally ParadeDB'spg_searchfor true BM25 if available (the Phase 102 open question). - qdrant: no native BM25 — pair with a companion
FtsStore(FTS5/Postgres) or an external engine; ornullfor vector-only deployments. - future: a dedicated engine (OpenSearch / Tantivy / Meilisearch) can be
added as just another
FtsStoreimplementation, touching nothing else.
- sqlite: keep today's behavior exactly (load → JS cosine, usearch
prefilter). The SQLite
VectorStore.searchis a thin wrapper over the existingvectorSearch.tsinternals. - pgvector: push topK + filters into SQL (
ORDER BY embedding <=> $1 LIMIT k), with HNSW index. Dequantization decision: store full Float32 in pgvector initially (simplest); quantization is a later optimization. - qdrant: native ANN with payload filters (model/time). Re-rank / exact-cosine top candidates in JS if we need parity with SQLite scoring.
Qdrant does hold metadata, but only a deliberate slice. Each stored vector is a
point = id + vector + payload, where the payload is per-vector JSON you can
filter and return on. The rule: put in the payload only what's needed to filter
the ANN query server-side or to identify the hit; everything else stays in the
companion MetadataStore and is fetched by blob_hash after search.
In the Qdrant payload:
blob_hash— identity / join key back to theMetadataStore.model— filter by embedding model.first_seen(immutable commit timestamp) —--before/--aftertime filtering.- chunk/symbol points also carry
start_line/end_line(+symbol_name,symbol_kind,language) so a hit renders without a second round-trip. - the vector kind (file/chunk/symbol/module/commit) is the collection itself — one collection per kind — not a payload field.
Stays in the companion MetadataStore (fetched by blob_hash post-search):
the commit graph, full path lists, branch membership, ownership, clusters —
everything relational, mutable, or many-to-many. Qdrant can't do the joins and
aggregations gitsema's analysis commands need, and duplicating mutable data into
payloads invites drift.
Branch filtering is the judgment call. Branch membership is mutable and
many-to-many (a blob joins new branches as history grows), so denormalizing it
into the payload would mean rewriting points. Instead, mirror the existing
usearch candidate-pool pattern: filter model+first_seen in Qdrant, pull a
wider topK, then post-filter by branch in JS against a companion lookup.
first_seen is safe to denormalize precisely because it is immutable for a
content-addressed blob.
storage.backend sqlite | postgres | qdrant preset wiring all three (default: sqlite)
storage.scope project | user | named (default: project)
storage.metadata.url file path | postgres://… MetadataStore
storage.vectors.url (e.g. qdrant) https://host… VectorStore (else inferred from preset)
storage.vectors.apiKey vector store auth
storage.fts.backend fts5 | tsvector | none | … FtsStore (else inferred from preset)
storage.fts.url (external engine only)
storage.name <named-index> when scope=named
storage.backend is a preset that picks sensible defaults for all three
stores (e.g. qdrant ⇒ Qdrant vectors + SQLite metadata + SQLite FTS). The
per-store keys override individual seams, so a power user can mix freely (e.g.
Postgres metadata + Qdrant vectors + fts.backend=none).
- Resolution order (unchanged precedence): env vars → repo
.gitsema/config.json→ user~/.config/gitsema/config.json→ defaults. - Scope → default location:
project→./.gitsema/…(today)user→~/.gitsema/…named→ resolved purely from connection strings (no on-disk anchor)
- A single
resolveStorageProfile(cwd)function replaces the scatteredDB_PATH/getActiveSession()assumptions and becomes the one place that decides whatStorageProfilea command runs against. This is also the natural home to retire the scattered path logic flagged in the architecture study.
storage.metadata.url = .gitsema/index.db → local SQLite (today).
storage.metadata.url = postgres://user@db.internal/gitsema → remote Postgres.
storage.vectors.url = https://qdrant.internal:6333 → remote Qdrant.
No separate "local vs remote" flag needed — the URL scheme says it.
- Indexing is idempotent and content-addressed, so a partial write (e.g.
metadata committed, Qdrant upsert failed, or FTS not yet written) self-heals on
the next
indexrun: the deduper sees the blob is missing its vector/FTS entry and re-processes only that one. We document this and add agitsema doctorcheck that reports orphans across all three stores (metadata ↔ vector ↔ FTS). gitsema storage migrate --from <profile> --to <profile>(Phase 103): stream blobs/embeddings from one profile to another. Because identity is the blob hash, this is a straight copy with re-upsertinto the target — no re-embedding required. This is the user's "freedom to move" guarantee.- Provenance:
embed_configalready records model/dimensions/chunker; extend it (orsettings) to record the activestorage.backendso a mismatched reconnect is detected and explained rather than silently wrong.
Three phases. The async seam comes first and is a prerequisite for the other
two; the two backend phases can then proceed in parallel since they implement
the same seam against different drivers. Each phase ends green
(pnpm build && pnpm test) and ships a changeset.
The big, mostly-mechanical phase. Ships no new backend and no behavior change — it only relocates today's logic behind an async interface and makes gitsema scope-aware.
- Introduce
src/core/storage/(MetadataStore,VectorStore,FtsStore,StorageProfile) with async signatures (Strategy A, §5). - Implement the SQLite adapter for all three interfaces wrapping today's
code (sync calls returned as resolved promises — zero real cost;
FtsStore= FTS5). - Make the vector read path async: convert
vectorSearch/hybridSearch/searchCommitstoasyncand threadawaitthrough their callers (done). The indexing write path moves to Phase 102 (see below). Long-tail relational queries stay on the direct SQLite session (gatedmetadata=sqlite) until Phase 102 needs them. - Add
storage.*config keys,resolveStorageProfile(), and theproject/user/namedscope model (§7) — still SQLite-only, but now the one place that decides which index a command runs against. - Add
withStorageProfiletest helper + the adapter-conformance suite skeleton.
- Route consumers through the profile (carried over from 101): production
call sites still invoke the now-async
vectorSearch/hybridSearch/searchCommitsdirectly. Point them atprofile.vectors.*/profile.fts.*(viaresolveStorageProfile()/withStorageProfile()) so the backend is actually swappable — otherwise pgvector would forcevectorSearchitself to branch on backend, defeating the seam. - Indexing write-path migration (moved from 101): route
blobStore/indexer/deduperwrites through the seam, including the cross-store transaction boundary (SQLite/Postgres commit blob + embedding + FTS atomically; Qdrant relies on idempotent re-index instead). Prerequisite for a writable Postgres backend. - Postgres
MetadataStore+ PostgresFtsStore(tsvector/ts_rank_cdBM25,pg_searchBM25 as opt-in). - pgvector
VectorStore(HNSW index, wider ANN candidate pool re-ranked by the existing JS three-signal ranking for parity). - Postgres migrations, Docker compose for dev/CI service container.
- Qdrant
VectorStorepaired with a SQLite or Postgres metadata companion andFtsStore(collection-per-kind, payload filters, JS re-rank for parity). gitsema storage migrate --from <profile> --to <profile>(hash-keyed copy, no re-embedding — §8).gitsema doctorcross-store orphan checks;gitsema statusreports the active backend; perf / when-to-use-what docs.
Phases 102 and 103 can run concurrently once 101 lands.
- Adapter conformance suite: one shared test set run against every
VectorStore/MetadataStoreimplementation (SQLite always; Postgres/Qdrant gated behind env / service availability in CI via service containers). - Parity tests: identical query → ranked results must match SQLite within a tolerance for pgvector/Qdrant (ANN approximate, so top-k overlap, not exact).
- Idempotency / heal tests: simulate a half-failed write, re-
index, assert the store converges. - Keep
withDbSession-style isolation; addwithStorageProfilefor tests. - Postgres/Qdrant use ephemeral containers (mirrors the existing
mkdtempSync/temp-repo integration pattern).
- Diff size of Phase 101. Threading
awaitthrough search/indexing is large but mechanical; the long-tail relational queries can stay sync until a Postgres metadata backend needs them (gated). Mitigation: slice-by-slice PRs. - BM25 fidelity on Postgres.
tsvectorranking ≠ FTS5 BM25. Decide whether baselinets_rank_cdis acceptable or we require ParadeDBpg_searchfor the Postgres profile. (Open question — leaning: shipts_rank_cd, document the difference, offerpg_searchas opt-in.) - Scoring parity with ANN backends. pgvector/Qdrant return approximate
neighbors; gitsema's three-signal ranking (vector + recency + path) currently
assumes a full candidate set. Plan: fetch a wider ANN candidate pool, then run
the existing JS ranking over it (same trick
--vssalready uses). - Quantization. Start unquantized for pgvector/Qdrant; revisit once baseline works.
- Multi-repo + new scopes. Reconcile
storage.scope=namedwith the existingreposregistry /GITSEMA_DATA_DIRserver storage so we don't end up with two overlapping "where's my index" mechanisms. (Open question for Phase 102.)
- No
ConfigStoreabstraction for now. Config splits into two kinds that want different homes, and neither needs a new seam:- User preferences / behavior knobs (
provider,model, search weights…) are already JSON files (.gitsema/config.jsonrepo,~/.config/gitsema/ config.jsonuser), scope-aware and env-overridable viasrc/core/config/configManager.ts. Nothing to abstract. - Index-describing facts (
embed_configprovenance,settings) must stay in theMetadataStore, co-located and consistent with the index they describe. Pulling them into a separate JSON file would re-introduce the split-brain the store split exists to prevent (e.g. a shared remote Postgres index whose "what model built this?" lived in one laptop's JSON). - Key-value config also has no backend variety worth a pluggable seam (unlike vectors or BM25), so the cost (a fourth async interface + conformance tests) isn't justified.
- User preferences / behavior knobs (
- Possible later: a pluggable config provider inside
configManager.ts— sourcing user config from somewhere other than local JSON (e.g. a remote config service or a merged fleet endpoint). That is a config-loading feature, not a storage backend, and is explicitly out of scope for Phases 101–103.
Each phase adds a minor changeset (new capability), e.g. Phase 101:
---
"gitsema": minor
---
Introduce a pluggable storage seam (MetadataStore / VectorStore / FtsStore)
behind the existing SQLite backend, with no behavior change — groundwork for
Postgres + pgvector and Qdrant backends.