Skip to content

RFC: optional SQLite tiered storage backend for serve (~17–64× lower query latency, byte-identical results) #1297

Description

@papinto

Summary

I'd like to propose an optional --format sqlite storage backend for serve (JSON stays the default; serve auto-detects by file extension). In a working prototype it delivers ~17× lower p50 query latency at 54k nodes, ~21× at 177k, and ~64× at 271k nodes, with byte-identical results (Jaccard 1.000 vs the current JSON/networkx path on 8 NL queries). The relative win grows with graph size because tiered latency is index-bounded while the JSON scan degrades super-linearly. I'd like a read on whether you'd accept this upstream before I invest in a mergeable PR.

Motivation — JSON is being used as a query database

_load_graph (serve.py) parses the entire graph.json into networkx, then every query brute-force scans it. _score_nodes (serve.py) loops over every node per query (for nid, data in G.nodes(data=True), O(nodes × terms)), so query latency scales with total node count regardless of where the answer lives. On a large monorepo this is the dominant cost. Concretely, on our graphs:

Backend / scale Fidelity (Jaccard) p50 @ conc 1 q/s @1 load Notes
JSON / networkx — 54k nodes — (baseline) 428 ms 2.3 0.97 s current
SQLite tiered — 54k nodes 1.000 25 ms 36 ~0 s (mmap) ~17×
JSON / networkx — 177k nodes — (baseline) 1730 ms 0.6 3.16 s current
SQLite tiered — 177k nodes 1.000 83.5 ms ~7–11 ~0 s (mmap) ~20.7×
JSON / networkx — 271k nodes — (baseline) 3705 ms 0.28 4.03 s current
SQLite tiered — 271k nodes 1.000 57.6 ms ~10–15 ~0 s (mmap) ~64×

The relative win grows with N (JSON's O(N) scan degrades super-linearly: 428→1730→3705 ms, while tiered stays index-bounded at ~25–85 ms) — the opposite of what large repos need today.

Design (clean seams already exist)

  • Introduce a StorageBackend abstraction at the single load seam (_load_graph, serve.py) + a new exporter alongside the existing ones. AST extraction is unchanged. export.py already exposes a uniform to_<format>(G, output_path) seam (to_json, to_cypher [Neo4j], to_graphml, to_obsidian, to_canvas, to_svg) — a to_sqlite slots into the same pattern.
  • The real work is reimplementing the scorer as FTS5 MATCH + a bounded recursive-CTE BFS. (NB: there's no SQLite/FTS5 in the codebase today — this is net-new; the to_cypher exporter is the closest existing precedent for a graph→external-store serialization.)
  • "Tiered" is the key insight — a naive storage swap is not enough. A faithful 1:1 swap was only ~2.9× because common trigrams (e.g. "component" → 22k nodes) make the substring candidate set 30–56% of N. The tiered design does indexed GLOB 'term*' exact/prefix retrieval (where graphify's seeds actually come from — _pick_seeds prunes <20% of top score) + a small bm25 safety net, then runs the full existing graphify rescore on the small candidate set. That preserves exact fidelity.
  • Cannot use FTS5 native bm25 as the ranker — fidelity collapses to ~0.25 because graphify's scoring is exact/prefix-dominated (1000×/100× bonuses), unlike bm25 TF. The rescore must stay graphify's own.

Secondary wins (measured)

  • Startup ~instant (mmap open vs 2–3 s parse) → kills the hot-reload parse blip (_maybe_reload, serve.py) where the first post-change query eats the full re-parse under a lock.
  • Memory per replica drops and shares: a single-threaded replica is ~46 MB private + ~111 MB shared-once (mmap'd db + DLLs), so N replicas ≈ db-shared-once + N×46 MB rather than N× the full resident set.
  • Conversion is cheap: 192 MB graph.json → 266 MB .db in ~24 s.
  • Row-level incremental update (177k-node graph): applying a changed file is a DELETE+INSERT of its node/edge rows + targeted external-content FTS5 maintenance + degree recompute on the affected set — ~69 ms for 1 file, ~647 ms for 20, vs a ~6.5 s whole-file graph.json rewrite (~93×), with fidelity preserved (Jaccard 1.000) and WAL readers not blocked by the writer. Cost scales with changed rows, not total N — so serve could hot-apply commits cheaply instead of swapping whole files.

Honest non-goals / what it does NOT fix

  • Not a concurrency fix. Throughput stays flat across thread concurrency — most time is still the Python rescore/BFS loop holding the GIL. The scaling lever remains scale-by-process. (This is inherent to CPython, not a storage choice.)
  • Not a cross-folder-traversal fix — that needs a connected whole-repo extract, orthogonal to storage.

Bonus it would unlock

A single .db with path-prefix scoping could dissolve the per-folder-vs-whole-repo fork (one graph, bounded latency via the index, scoped queries) — which also directly serves #569. And sqlite-vec would make semantic query cheap later.

Ask: Would you accept an optional --format sqlite (JSON default, fully opt-in) along these lines? If so I'll prepare a PR with the StorageBackend seam + tiered scorer + a fidelity test harness (the 8-query Jaccard==1.000 check I used). Happy to share the bench/fidelity harness if useful.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions