lightrag-pg-rcte.md

All posts
raw .md

AI / RAG

LightRAG Without Apache AGE — Graph Storage in Recursive CTE

Implementing LightRAG's BaseGraphStorage on plain PostgreSQL with RCTE — why a 1-hop-dominant retrieval pattern fits flat SQL

lightrag_pg_not_age implements LightRAG's BaseGraphStorage interface on plain PostgreSQL using Recursive CTE — no Apache AGE, no Cypher wrapper, no graph extension. The retrieval call distribution is 1-hop-lookup dominant, and flat SQL with B-tree indexes turns out to be the most efficient expression of that pattern.


LightRAG's graph abstraction

LightRAG exposes its knowledge graph through BaseGraphStorage — an abstract class with 18 methods. Any conforming implementation serves as the graph layer: NetworkX (the in-process default), Neo4j, Apache AGE, or something custom.

The official PostgreSQL backend is PGGraphStorage. It depends on Apache AGE, which grafts Cypher onto PostgreSQL via a cypher() wrapper function. Every graph operation passes through AGE's parsing and plan generation before PostgreSQL can execute it.

The question lightrag_pg_not_age starts from: can the same 18-method interface be satisfied using plain PostgreSQL, with no extensions beyond a standard postgres:17-alpine image?


Why RCTE fits LightRAG

The answer depends entirely on what those 18 methods do at runtime. LightRAG's actual retrieval call distribution is:

MethodShare
get_node35%
get_node_edges25%
upsert_node15%
node_degree15%
has_node10%

85% of calls are 1-hop operations — point lookups and direct-edge scans. The remaining 15% is upsert_node, a write, not a traversal. Graph database strengths — variable-depth path queries, shortestPath, cycle detection — are entirely absent from the retrieval critical path.

get_knowledge_graph runs a BFS but is bounded: depth 2, max_nodes 50 cap. It serves visualization, not retrieval. RCTE's weakness — deep BFS frontier explosion — is intentionally unreachable.

get_node with a B-tree index on (workspace, id) is a point lookup; get_node_edges with (workspace, src_id) is a small range scan. Routing either through cypher() adds parse and plan-generation cost with no benefit for these operations.

The benchmark_gdb Phase 1–8 series decomposes the 290x gap: cypher() wrapper cost (~100x, isolated via AgensGraph) plus PostgreSQL's planner limits on graph traversal. AgensGraph removes the wrapper cost but not the planner limit — all Cypher-on-PostgreSQL approaches trail flat SQL on this workload.


Implementation

Two tables, auto-created on initialization:

sql
CREATE TABLE lightrag_graph_nodes (workspace TEXT, id TEXT, properties JSONB, ...);
CREATE TABLE lightrag_graph_edges (workspace TEXT, src_id TEXT, tgt_id TEXT, properties JSONB, ...);

workspace partitions separate LightRAG instances within the same database. properties is JSONB, carrying entity metadata without schema changes as LightRAG's model evolves.

The implementation is lightrag-upstream/lightrag/kg/pg_rcte_impl.py — PgRcteGraphStorage covering all 18 abstract methods, no stubs. Five batch overrides eliminate N+1 patterns. get_knowledge_graph is a single Recursive CTE BFS, bounded by depth-2 and max_nodes-50. Upserts use INSERT ON CONFLICT DO UPDATE.

Configuration: POSTGRES_HOST, POSTGRES_PORT, POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DATABASE. Switching an existing deployment is one constructor change — graph_storage="PgRcteGraphStorage". Everything else stays the same.


Benchmarks

The bench/ directory contains four scripts: bench_e2e.py (end-to-end pipeline timing), bench_graph_ops.py (per-backend operation throughput), bench_lightrag_mix.py (the 35/25/15/10/15 call-distribution replay), and compare_three_way.py (side-by-side across backends). Environment: Colima VM, 8 vCPU / 10 GB, each backend container at 2 GB / 2 cores, workers=10.

Synthetic graph (Barabasi-Albert, 8,000 nodes / 39,975 edges)

BackendSeed (s)RPSp50 (ms)p95 (ms)
NetworkX0.847,7300.20.3
RCTE19.612,7760.71.3
Neo4j85.91,6844.313.2
AGE351.81754.8213.3

EZIS real LightRAG data (8,050 nodes / 25,844 edges)

BackendSeed (s)RPSp50 (ms)p95 (ms)
NetworkX0.647,1130.20.3
RCTE14.39,1690.71.4
Neo4j66.51,6934.213.2
AGE208.71594.6238.6

NetworkX is an in-process reference with no network round-trip. Among the three remote backends: RCTE at 12,776 RPS versus Neo4j at 1,684 RPS is 7.6x; RCTE versus AGE is 58–73x. AGE p95 at 213–238 ms under 10 concurrent workers shows latency collapse under concurrency. RCTE p95 holds at 1.3–1.4 ms.

For the broader multi-engine picture, benchmark_gdb Phase 6 reports RCTE at 22.5K RPS, Neo4j at 14.5K RPS, AGE at 78 RPS under u=50 on a general GraphRAG OLTP mix (Q1–Q7 at 30/30/20/10/3/4/3). The ratio narrows versus the pure LightRAG distribution because the general mix carries more traversal-weighted queries. The Phase 1–8 series is the reference for the full eight-engine comparison and gap decomposition.


When to use it

The target case is LightRAG on PostgreSQL without Apache AGE or AgensGraph. AGE requires a non-standard build or a managed service that ships it — standard postgres:17-alpine does not include it. PgRcteGraphStorage provides full BaseGraphStorage compliance on a stock instance.

The equivalence test suite — 34 functional tests, four-backend correctness comparison on synthetic and real data — confirms that switching backends does not change LightRAG's output. Recommended indexes: (workspace, id) on nodes and (workspace, src_id) on edges.

The limit: workloads with variable-depth traversal on the hot path — shortestPath, multi-hop expansion beyond depth 2, cycle detection — need a native graph database. LightRAG's design avoids those patterns; that is why flat SQL works here.


Closing

The pattern generalizes: measure the actual graph operations a workload runs, then pick the storage representation that matches the measured distribution. "We need a graph database" is a claim that rarely survives contact with benchmarks on lookup-dominant workloads running on standard relational infrastructure.