Zum Inhalt springen
RAGRAF

Architecture: three SQLite files and zero infrastructure

Stores, the indexing pipeline, the answer envelope, provenance, deterministic identifiers and degradation — how RAGRAF works inside.

Dieser Artikel ist noch nicht übersetzt — angezeigt wird die English.

The model in one paragraph #

RAGRAF snapshots a corpus into an immutable revision (rev-NNNN): property/AST graph, FTS5 lexicon, 384-dimensional vectors, DB schema from migrations, and optional interprocedural calls from Joern — all in three SQLite files inside <project>/.ragraf/. Reads go through a facade of 22 fixed operations (no query language); every answer is wrapped in an envelope (index_rev, capability, drift, provenance, limits, stale_warnings, freshness). While the agent edits code, the index does not change: deltas accumulate in an append-only drift ledger, affected facts get marked stale, and a checksum guard reindexes automatically (or via reindex), archiving the ledger into a "what changed in the knowledge" report.

Eight principles #

Each is derived from a measured defect, not from taste. They are not revisited without revisiting the architecture.

#PrincipleGrounding
P1The index is immutable within a revisionOtherwise facts from different moments mix in one answer — a source of wrong conclusions
P2Staleness is shown, never turned into nullStaleness indistinguishable from an error is worse than no index
P3An edge without evidence does not live in the graph as a factProtection against "link hallucinations" inside the index itself
P4The agent gets no query language — only fixed operationsLLMxCPG: valid CPGQL from base models — 19–132 out of 1278
P5The graph is the primary source; vectors and lexicon are supplementaryVectors catch surface similarity and miss interprocedural flows
P6Free text is separated from structural factsALIBI: comment injections suppress detection >90%; wording shifts verdicts by 33.2%
P7The pilot is tiny but end-to-end (6–10 files)Debugging the pipeline on 162k lines is a waste of time
P8Precision beats recall at the first stageRepoformer: the right not to search yields up to 70% speedup

Three stores #

Separated by lifecycle: graph and lexicon are large and rebuildable; control is small and never deleted.

FileContentsLifecycle
graph.sqliterev, file (sha256, parse_status), node (kind/fqn/path/lines/props), edge (provenance, evidence_path/line, confidence, sources, edge_uid), text_chunk, cfg_edge/dfg_edge (CPG), db_table/column/fk, web_route, invalidated, coveragelarge, rebuildable
lexvec.sqliteft_chunk (FTS5: 4 tokenized content columns — their count is a contract with bm25() weights), chunk_text (raw text — a separate table on purpose), vec_chunk (vec0, FLOAT[384]), chunk_map, vec_metalarge, rebuildable
control.sqliterev_history, drift_entry (append-only, dedupe_key UNIQUE), drift_archive, calibration, stale_edge, telemetry usage_callsmall, eternal

Why chunk_text is a separate table rather than an FTS column: an eighth column would silently shift all bm25() weights, and sqlite3 excludes UNINDEXED columns from the index — text would not be readable by column number. The answer must return a fragment in one call (search --with-text), and a second-step address must resolve without touching disk (code --entry-ids).

Identifiers = content #

Determinism is a requirement (NFR-5), so every identifier is a content hash, never time or traversal order:

Text
node_id     = sha1(kind | fqn | path | start_line)      ← the string component is mandatory
edge_uid    = sha1(src | dst | kind | evidence_path | evidence_line)
chunk_id    = sha1(content_sha256 | chunk_kind | model) ← deliberately WITHOUT revision and path
corpus_hash = sha1(sorted list of (path, sha256))

Revision-independence of chunk_id is the main economy of rebuilds: unchanged chunks keep their embeddings across revisions. Two builds of the same revision must produce identical identifier sets (test M1-T4). No UUIDv4, no timestamps inside identifiers.

Provenance: stated vs derived #

The provenance column is mandatory and takes exactly two values:

  • stated — the edge is proven by the analyzer: an exact declaration/call line, an information_schema row. confidence = 1.0.
  • derived — inferred by heuristic or statistics (dynamic call, name coincidence, history inference). confidence from a calibratable table: 0.60 unambiguous heuristic · 0.40 ambiguous · 0.30 history/embedding inference.

calls_heuristic is a separate edge kind: heuristics are admitted into the graph (otherwise dynamic PHP yields no links at all) but never silently mixed with proven ones — impact --mode hard returns only stated. Source conflicts are resolved by priority (information_schema > migrations > CPG > SCIP > tree-sitter) and recorded in coverage as conflict.* — never swallowed.

The indexing pipeline #

Text
0. Version and availability checks (Joern/JDK, PHP, tree-sitter) → capabilities
1. scan    — file selection, exclusions, sha256, languages      → corpus_hash
2. ast     — tree-sitter: symbol boundaries, tokens, calls, SQL literals
3. resolve — name resolution: exact → name+file → honest refusal
4. schema  — tables/columns/FKs from information_schema and migrations/*.sql
5. cpg     — Joern: interprocedural calls; failure → degraded (indexing does not die)
6. lexvec  — FTS5 + embeddings (reuse by chunk_sha256)
7. coverage, conflicts, duplicates → coverage; invariants → rev.state = 'ready'

The graph and lexicon loading flows are independent: the graph does not wait for vectors. An honest detail: in the shipped code the AST walk is sequential (the documented "parallel parse, 8 cores/16 threads" is a goal, not a fact; closing the gap is in future directions, R-PERF-1).

The answer envelope #

A single format for every answer — requirement FT-16: the agent always sees what it is trusting.

JSON
{
  "op": "impact",
  "index_rev": "rev-0007",
  "capability": "full",
  "drift": { "files": 3, "symbols": 7, "entries": 4 },
  "provenance": "index",
  "limits": ["static analysis; dynamics not covered"],
  "stale_warnings": [
    { "path": "src/Pipeline/PipelineEngine.php",
      "reason": "modify after rev-0007",
      "suggest": "live_read" }
  ],
  "result": [ "…" ]
}

provenance values: index (index only) · live (file reads only) · mixed (each record marked). stale_warnings is populated automatically for every ledger-affected path; suggest is mandatory. How to read the envelope — in the operations reference.

Capability and degradation #

ModeConditionAvailableContract
fullCPG builteverything, including slice—
degradedCPG absent/failedgraph, symbols, schema, routes, lexicon, vectorsrev_history.joern_used=0; every envelope carries capability: degraded; slice refuses with code 3 and a reason

No embedding model → lexical search works, the envelope reports it. No free port → the server names the cause. Silent degradation does not exist (src/ragraf/cpg.py:9-11).

Layers and processes #

Components are separate processes, not libraries inside the agent's process:

LayerComponentsTechnologies
L5 integrationMCP server (stdio, JSON-RPC, 23 ragraf_* tools), the AGENTS.md regimen, skillPython
L4 facade22 operations + envelope + drift gatesrc/ragraf/facade.py
L3 storesgraph / lexvec / controlSQLite 3.50.4 + FTS5 + sqlite-vec
L2 loadingnormalize → schema → dedupe → idempotent revision load → invariantsindexer.py, revisions.py
L1 extractorsCPG (Joern+JDK) · symbols (SCIP/PHPStan where available) · syntax (tree-sitter, 16 languages) · SQL/DB (migrations, information_schema) · text (md/comments/docblocks)extract/, cpg.py, schema_db.py
L0 sourcesrepository files · migrations · documentation—

Performance contract (NFR-4) and reality #

OperationTargetFact
symbol / references / callers≤ 200 ms p95indexed lookups (fqn, src/dst); measured mean 0.7 ms
impact (depth 3)≤ 1 s p95recursive CTEs over edge
search (hybrid)≤ 1 s p95FTS5 + KNN scan; up to ~1 s — dominated by query embedding; no query cache yet (R-PERF-3)
slice≤ 2 s p95inside Joern
Full pilot indexing, 162k lines≤ 15 minmeasured 71 s on the 323-file corpus (64 s of it — CPG)
Reindex after an editminutesembedding reuse by chunk_sha256; CPG rebuilds wholesale (Joern has no incremental CPG)

Schema-tier operations are practically free: measured means schema 0.5 ms · symbol 0.7 ms · table_usage 0.9 ms · write_check 3.3 ms.

Security and secrets #

  • Secrets are excluded, not masked: files matching secrets_exclude never reach text_chunk, vectors, or logs (FT-39/40, sample-based test M3-T2).
  • db.dsn_from indexes the fact "this symbol references a config", without values.
  • Telemetry stores sha1 of the argument + a 60-character digest instead of full query texts.
  • Admin panel: loopback only, mutations POST+token (secrets.compare_digest), project-config writes validated with a .bak copy. Known gap: GET /api/token hands the token to any local client — documented honestly in the panel article and the project's SECURITY.md.
  • shell=True is absent everywhere except the documented Joern .bat launch (manual quoting; hardening — R-SEC-4).

Where the numbers come from #

Every "measured" claim in the documentation points to an artifact: bench/results/*.json (M2 runs rev-0012 and rev-0025 published), the usage_call journal (per corpus), stage timings in index_status. Estimates (tokens ≈ chars/4, lines ≈ chars/45) are marked "estimate" wherever they appear — the coefficients themselves were measured on two corpora.


Next: freshness and drift — the mechanics that keep the index from lying →