feat(kg): add PGTableGraphStorage — PostgreSQL-native graph backend (no AGE dependency)#3103
feat(kg): add PGTableGraphStorage — PostgreSQL-native graph backend (no AGE dependency)#3103ysys143 wants to merge 49 commits into
Conversation
|
Applied |
…th recursive CTEs Implements BaseGraphStorage using plain PostgreSQL tables and recursive CTEs. No Apache AGE, no Cypher wrapper, no custom extensions required. Works on any PostgreSQL 14+ including managed services (RDS, Cloud SQL, Supabase, Neon) where AGE cannot be installed. Schema (auto-created on initialize): lightrag_graph_nodes(workspace, id, properties JSONB) lightrag_graph_edges(workspace, src_id, tgt_id, properties JSONB) All 18 BaseGraphStorage abstract methods implemented. Five batch methods override defaults to eliminate N+1 patterns. get_knowledge_graph runs as a single recursive CTE (bounded BFS). Equivalence verified against NetworkX, AGE, and Neo4j on synthetic (8K nodes) and real LightRAG production data (8K nodes / 25K edges). Closes HKUDS#3095
4057d09 to
25d712f
Compare
|
Rebased onto the latest The previous CI failure was caused by pre-existing lint issues in upstream files ( |
There was a problem hiding this comment.
Thanks for this — a pure-SQL graph backend that drops the AGE requirement is genuinely valuable for managed Postgres (RDS / Cloud SQL / Supabase / Neon). The structure is clean and all 18 BaseGraphStorage methods are present. Below are the issues I think should be resolved before merge, plus notes on the benchmark framing.
Blocking
1. Independent connection pool bypasses ClientManager / PostgreSQLDB
_PgPool calls asyncpg.create_pool() directly instead of going through the shared, ref-counted pool that PGKVStorage / PGVectorStorage / PGGraphStorage all use (ClientManager in postgres_impl.py). Consequences when this backend runs alongside the other PG storages:
- Connection counts stack (each pool has its own
max_size), makingtoo many connectionseasy to hit against the default PGmax_connections=100. - You lose the shared infra that lives in
PostgreSQLDB: SSL config (POSTGRES_SSL_*), connection retry/backoff (POSTGRES_CONNECTION_RETRIES), thePOSTGRES_WORKSPACEprecedence, and centralized table creation/migration (check_tables). - Workspace handling diverges: this uses
self.workspacedirectly and ignores thePOSTGRES_WORKSPACEenv var the rest of the codebase honors.
Recommend reusing ClientManager.get_client() / release_client() and PostgreSQLDB.query/execute rather than a private pool.
2. Undirected edges are not normalized on write
The contract is undirected, and PGGraphStorage canonicalizes edges (LEAST/GREATEST) so (A,B) and (B,A) are one edge. Here the PK is (workspace, src_id, tgt_id) with no normalization, so upsert_edge(A,B) followed by upsert_edge(B,A) produces two rows. That means:
- a duplicated undirected edge with possibly divergent properties (
get_edgethen returns an arbitrary one), and node_degree(src_id=$2 OR tgt_id=$2) double-counts that edge.
The "both directions" handling only covers the read path; the write path still needs normalization. Note the equivalence tests likely won't catch this since each backend is ingested independently.
3. Batch write methods are not overridden
Only the read batches are overridden. upsert_nodes_batch / upsert_edges_batch / has_nodes_batch fall back to the base-class N+1 serial loops, so ingestion does one acquire+execute per node/edge — much slower than PGGraphStorage's chunked batched MERGE. (This is also why the benchmark's single-upsert_node mix doesn't reveal it.)
Correctness / structure (non-blocking)
get_knowledge_graphtruncation is non-deterministic:SELECT DISTINCT ... LIMIT max_nodeshas noORDER BY, so which nodes survive truncation is arbitrary — whereas the AGE backend ranks by degree. The two are not equivalent here, and results aren't reproducible on large graphs. Consider ordering by degree.is_truncated = len(node_ids) >= max_nodesfalsely reports truncation when the graph has exactlymax_nodesnodes and nothing was cut.- The DISTINCT comment ("deduplicates by id across depths") is inaccurate —
DISTINCTapplies to the whole(id, properties)row, notidalone. idx_lightrag_graph_nodes_props(GIN onproperties) isn't used by any query in this file — pure write amplification unless a property-filter query is planned.- Module docstring names the tables
graph_nodes/graph_edges, but the DDL createslightrag_graph_nodes/lightrag_graph_edges.
Benchmark credibility
The directional result is believable — plain indexed SQL > Neo4j > AGE, and AGE's per-call cypher() parse overhead blowing up p95 under concurrency is a known effect. Two caveats on the framing though:
- The workload is described as mirroring LightRAG's real call distribution, but it's point-queries only — it excludes
get_knowledge_graph(the expensive recursive query) and the*_batchmethods that LightRAG's retrieval and ingestion paths actually rely on (including the write batches not optimized here). So it tends to flatter this implementation specifically where it's weakest. - It's not reproducible as presented: no script/raw data, request counts, variance, or the pool sizes/configs used for each backend. Equalizing pool size across backends matters a lot for an RPS comparison.
- The equivalence claim covers 5 read ops and explicitly not
get_knowledge_graph, writes, or deletes — i.e. it doesn't exercise the normalization/truncation issues above.
Happy to help with any of these if useful.
Generated by Claude Code
|
Thanks a lot for the contribution, @ysys143 — a pure-SQL graph backend that removes the AGE dependency is a genuinely useful direction for users on managed Postgres (RDS / Cloud SQL / Supabase / Neon), and the implementation is clean and well-organized. Before we can merge, could you please refine a few things? I've left the details in the review above; the key ones are:
On the benchmark: the direction is believable, but could you share the benchmark script and raw data, and extend the workload to include Happy to help if any of these are unclear. Looking forward to the next revision! Generated by Claude Code |
- Replace private _PgPool / asyncpg.create_pool with ClientManager.get_client() so connection counts, SSL config, retry/backoff, and POSTGRES_WORKSPACE env are shared with PGKVStorage / PGVectorStorage - Normalise edges to canonical order (min, max) on all write paths so upsert_edge(A,B) and upsert_edge(B,A) map to one row; fixes node_degree double-counting and get_edge returning arbitrary results - Add upsert_nodes_batch / upsert_edges_batch via single unnest INSERT and has_nodes_batch via get_nodes_batch — replaces N+1 base-class serial loops - Fix get_knowledge_graph: fetch max_nodes+1 to detect true truncation, sort results by degree descending to match AGE backend behaviour - Remove unused GIN index on properties (no query in this file uses it) - Fix module docstring table names (graph_nodes → lightrag_graph_nodes)
Mirrors TestPostgresBatchOrdering pattern to verify: - upsert_nodes_batch: single _execute call, last-write-wins dedup - upsert_edges_batch: (A,B)+(B,A) collapse to one canonical row, last-write-wins, weight preserved correctly - upsert_edges_batch: canonical order is lexicographic min/max regardless of call order (ZNode,ANode → ANode,ZNode)
ClientManager.get_config() defaults enable_vector=True when vector_storage is None, which breaks PgRcteGraphStorage on plain PostgreSQL instances (no pgvector extension). Fall back to a non-PGVectorStorage sentinel so the extension is not loaded unless another PG storage in the same process explicitly needs it.
test_batch_graph_operations.py — TestPgRctePipelineIntegration:
Offline pipeline test that patches all DB methods and drives
ainsert_custom_kg() against the real upsert_nodes_batch /
upsert_edges_batch logic. Verifies:
- Batch writes are issued (not N+1 serial upserts)
- A relation submitted as (EntityB, EntityA) is stored in SQL
as (EntityA, EntityB) — canonical min/max normalisation
flows end-to-end through the pipeline
bench/:
- bench_graph_ops.py — synthetic-only, BENCH_POOL_SIZE=20,
get_knowledge_graph + *_batch methods in workload mix,
batch seeding, per-op breakdown, --output JSON flag
- compare_three_way.py — equalised --pool flag, extended
check_equiv (get_node/get_edge/get_node_edges), new
check_write_equiv (upsert update, delete_node cascade,
remove_edges), make_rcte updated for ClientManager
- bench-results.json — raw results from GCP n2-standard-8
(8K nodes, 10 workers, 30s): RCTE 591 RPS / 1.3s seed vs
AGE 69 RPS / 433s seed vs Neo4j 282 RPS / 6.6s seed
Revision — PgRcteGraphStorageThanks for the detailed review. All blocking and non-blocking items have been addressed. What changed1. Shared connection pool (
2. Undirected edge normalisation on write All write paths now canonicalise 3. Batch write overrides
4. Non-blocking fixes
BenchmarkInfrastructure: GCP Reproduce with: docker compose -f docker-compose.bench.yml up -d --wait
python bench/bench_graph_ops.py --nodes 8000 --workers 10 --duration 30 --output bench-results.jsonRaw data:
Per-operation breakdown — PgRcteGraphStorage vs PGGraphStorage (AGE):
RCTE p95 (173 ms) is dominated by Test coverageOffline unit tests —
Offline pipeline integration test —
Equivalence tests — 19 tests covering all E2E smoke test (run locally on GCP VM, not included in upstream test suite per project conventions):
|
- Remove f-string prefixes without placeholders (F541) - Remove unused import (F401) - Split semicolon-separated statements onto separate lines (E702) - Apply ruff-format v0.6.4 line-wrapping - Add missing newline at end of bench-results.json - Add .omc/ and .cwf/ to .gitignore and untrack them
… upstream-clean - Remove bench/bench-results.json from the tree; raw results now live in a public gist linked from bench_graph_ops.py (results are reproducible via the --output flag anyway). - Revert the .omc/.cwf .gitignore entries (local-only tool state; moved to .git/info/exclude so they stay ignored without touching the tracked file).
🔴 Blocking —
|
🟡
|
🟡
|
🟡 The real-DB glue is not covered in CIAll unit tests mock The param-passing glue is subtle (an Suggested fix: add a real-PostgreSQL smoke test (e.g. testcontainers, CI-gated) covering at least |
🟢 Minor / style
|
… feedback Blocking fixes: - get_node_edges/get_nodes_edges_batch: restore queried-node-first convention - get_knowledge_graph: use exact-match seed (not ILIKE substring), add visited-array RCTE guard (UNION ALL, no in-CTE LIMIT), sort by degree DESC before truncation, respect global_config["max_graph_nodes"] cap - entity_id validation in upsert_node/upsert_nodes_batch (mirrors AGE) - fix get_all_nodes id injection order: inject id from entity_id last - fix self-loop double-count in node_degrees_batch (exclude src=tgt in tgt arm) - add NULL/non-string guard in remove_edges Minor fixes: - move asyncio import to module level - update canonicalization comment: Python min/max, not SQL LEAST/GREATEST - add logger.error in drop() to surface silent exceptions Tests: - add offline pytest suite under tests/kg/pg_rcte/ (11 tests, no DB required) Chore: - remove bench/ scripts; results linked via gist in PR description Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- add pg-smoke.yml CI workflow running smoke tests on PostgreSQL 15/16/17 - add test_pg_rcte_smoke.py exercising real DB glue (positional binding, JSONB decode, queried-node-first ordering, Unicode round-trip) - register offline/pg_smoke pytest markers in pyproject.toml - sort get_knowledge_graph nodes by (-degree, id) for deterministic truncation independent of PostgreSQL row order - add offline test asserting degree-then-id truncation order
- get_knowledge_graph("*"): replace Python-side full-scan + sort with a
single SQL pass (degrees CTE → LEFT JOIN → ORDER BY degree DESC, id ASC
→ LIMIT max_nodes+1), eliminating unnecessary full-table fetch
- pg-smoke.yml: switch pip install to uv for faster CI setup
- test_pg_rcte_smoke.py: add init_shared_storage session fixture and
pass embedding_func=None to satisfy BaseGraphStorage signature
- add offline test asserting wildcard path uses flat SQL (no RECURSIVE)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- get_all_edges: rename src_id/tgt_id → source/target to match BaseGraphStorage contract consumed by storage_migrations.py - test_pg_rcte_smoke.py: remove unused `import json` (ruff F401) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- drop(): delete edges and nodes in a single data-modifying CTE so a mid-drop failure can no longer commit the edge delete independently; edges are still deleted explicitly (correct even on a legacy table without FK CASCADE). - _bfs_frontier(): return (rows, degrees), accumulating per-level degrees so get_knowledge_graph reuses them instead of a second full node_degrees_batch over the collected set. - Correct the docstring overclaim: the BFS *result* size is bounded by node_budget, but per-hop *work* still scales with the frontier neighbourhood (SQL-side degree-rank + cap remains a tracked follow-up). Update the exact-label mock tests to the new (rows, degrees) return shape.
Add smoke regressions for behaviour the mock-based unit tests only assert as SQL text: - upsert_edge with both endpoints missing must satisfy the FK from rows inserted in the same data-modifying CTE (transaction, not snapshot, visibility), - concurrent upsert_node must not lose updates (row-locked || merge), - drop() must clear nodes and edges atomically. Also fix a stale 'WITH RECURSIVE' docstring (the traversal is the frontier-capped iterative _bfs_frontier, not a recursive CTE).
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: cb94c61651
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
…skip) Codex review follow-up. test_execute_retries_transient_write_error is marked `offline` but imported asyncpg directly. asyncpg is genuinely required here (the retry predicate _is_transient_write_error matches by isinstance against real asyncpg.exceptions.* classes, so a fabricated exception cannot exercise it), so this guards the test with pytest.importorskip rather than dropping the dependency: it runs where asyncpg is present (the offline CI installs it via the offline-storage extra) and skips gracefully in a bare test-extra environment.
Code review — PGTableGraphStorageReviewed the new backend end-to-end. The implementation is careful and conforms to 1. [Blocking / correctness] Legacy-edge normalization can crash
|
Two one-time legacy-table migration paths (reachable only on pre-existing adversarial data, which the offline unit tests mock away) are made robust, each with a real-PostgreSQL regression test: - _normalize_legacy_edges put the canonical (src,tgt) tuple in BOTH the CTE delete set and the ON CONFLICT DO UPDATE target, i.e. modified the same row twice in one data-modifying CTE. Postgres documents this as unspecified; empirically PG 15-18 happen to produce the correct single row, but the result is not guaranteed across versions/plans. Exclude the canonical row from the delete set so it is disjoint from the INSERT target and the outcome is well-defined. Also make the surviving-properties choice deterministic on an updated_at tie (prefer the already-canonical row). - The namespaced-PK migration ran ADD PRIMARY KEY directly; on a legacy table holding duplicate rows for the new key this fails (could not create unique index) and aborts startup. De-dupe (keep newest by updated_at, ctid tiebreak) before rebuilding each primary key.
_execute already retried query-level transient errors (deadlock / serialization / lock-timeout / cancel) that PostgreSQLDB._run_with_retry does not cover. The read helpers (_fetch / _fetchrow / _fetchval) did not, so under a SERIALIZABLE / REPEATABLE READ pool a read racing concurrent ingestion could surface a SerializationError to the caller. Factor the retry into a shared _with_retry helper used by both reads and writes, and rename the predicate to _is_transient_error (it now covers reads too). No effect under the default READ COMMITTED isolation.
… in CI The shared, registry-driven contract suite (tests/kg/test_graph_storage.py) is what every graph backend must pass, but pg-smoke.yml only ran the bespoke PGTable smoke tests. Add a CI step that runs the contract suite with LIGHTRAG_GRAPH_STORAGE=PGTableGraphStorage, and guard against a false green (the suite's fixture skips when the backend isn't configured) by asserting tests>0 and skipped==0 via the JUnit XML. Also add PGTableGraphStorage to the suite's docstring backend list.
|
Thanks for the careful review. Addressed in 1. Legacy-edge normalizationFixed: the canonical One note for transparency: while verifying I tried to reproduce the 3. Non-deterministic survivorFixed: the survivor tie-break now prefers the already-canonical row when 2. Read-path transient retryFixed: factored the transient-retry into a shared 4. Shared contract suiteRan it against All 8 pass, including Additional fix found while reviewingThe namespaced-PK migration ran Considered, no change needed
Follow-ups (not in this PR)The table-wide orphan cleanup and the per-hop BFS cost being unbounded by |
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 00efdad3a9
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
The shared contract tests in tests/kg/test_graph_storage.py wrap their body in try/except and `return False` on failure. pytest records a non-None return as a passed test (only a PytestReturnNotNoneWarning), so a swallowed assertion would slip past the JUnit failures/skipped guard and the gate would go green on a real regression. Run the suite with -s so the except block's marker reaches the log even for a "passed" test, and fail the step if that marker appears. Verified: injecting a failure into one test reports "8 passed" yet the grep guard catches it; a clean run passes both guards.
|
Follow-up on the contract-suite CI gate (commit While double-checking the false-green guard I added for #4, I found a second false-green that's worth flagging for the shared suite itself: most tests in Verified locally: injecting a failure into For now I made the PGTable CI step robust to it: run the suite with |
get_popular_labels fetched every node and its degree, then sorted and sliced in Python. On large graphs the /graph/label/popular endpoint (capped result) would transfer the entire node set. Rank and truncate in SQL instead (ORDER BY degree DESC, id COLLATE "C" ASC LIMIT), which reproduces the previous key=(-degree, id) ordering exactly while preserving degree-0 nodes via the existing LEFT JOIN. (Codex review P2.)
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 3354c302f3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
_normalize_legacy_edges holds an advisory lock, but ingestion writers (upsert_edge / upsert_edges_batch) do not take it, and FOR UPDATE only locks the rows the snapshot already sees. A writer can insert a fresh canonical (A,B) after the snapshot observed only the legacy (B,A); the migration's re-insert would then overwrite that fresh row with the older legacy survivor via ON CONFLICT DO UPDATE. Guard the conflict update with `WHERE updated_at <= survivor` so a newer concurrent write is preserved, and correct the comment that wrongly claimed the lock alone prevented this. (Codex review P2.)
The wildcard get_knowledge_graph ordered by `degree DESC, n.id ASC LIMIT` without COLLATE "C", so on a non-C database collation the LIMIT cutoff among degree-tied nodes could include/omit different nodes than the codepoint order used by the Python re-sort and by get_popular_labels. Apply COLLATE "C" to match. (Codex review P3.)
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1548d7a43c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| rows = await self._fetch( | ||
| """ | ||
| SELECT id FROM lightrag_graph_nodes | ||
| WHERE workspace=$1 AND namespace=$2 AND LOWER(id) LIKE $3 ESCAPE '\\' |
There was a problem hiding this comment.
Use a one-character LIKE escape
When PGTableGraphStorage.search_labels() runs against PostgreSQL with the default standard_conforming_strings=on, the SQL literal in ESCAPE '\\' evaluates to two backslash characters, but PostgreSQL requires the ESCAPE value to be empty or exactly one character. This makes any label search on the new PGTable backend fail at execution time instead of returning matching labels; use a single-character escape literal such as ESCAPE E'\\' or otherwise avoid the doubled standard string literal.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
This looks like a false positive on the default configuration — verified on PG18.
The query is a regular (non-raw) Python triple-quoted string, so the source '\\' is already a single backslash by the time the SQL reaches Postgres (Python collapses \\ -> \), not two. Checked directly:
- the SQL text sent to the server contains exactly one backslash;
standard_conforming_stringsison(the Postgres default since 9.1);... LIKE $1 ESCAPE '\'executes with no "invalid escape string" error (returns a normal boolean).
The shared contract suite's test_graph_query_helpers also exercises search_labels against a live PG18 and passes, so the escape clause works end to end.
The only configuration where a single-backslash literal would be a problem is the non-default, deprecated standard_conforming_strings=off. If you'd like it to be robust there too I can switch to ESCAPE E'\\' (explicit escape-string syntax, unambiguously one character regardless of that setting), but no change is needed for default Postgres.
There was a problem hiding this comment.
Agreed — this is a false positive, your analysis is correct.
The literal here is a regular (non-raw) triple-quoted Python string, so the \ in the source collapses to a single backslash before the SQL ever reaches Postgres. The text on the wire is ... ESCAPE '', and under standard_conforming_strings=on (the default since 9.1) that's a one-character escape literal — exactly what ESCAPE expects. The bot conflated the source-level representation (two characters in the .py file) with the runtime string value (one backslash). It would only hold if the string were raw (r"""..."""), which it isn't. The contract suite exercising search_labels against live PG18 confirms it end to end.
So no change is needed for default Postgres. The one edge case is the non-default, deprecated standard_conforming_strings=off. Since E'\' is unambiguously one character regardless of that setting and the change is essentially free, I'd lean toward applying it purely as defensive hardening — but I'm happy either way; it's not blocking.
Resolving this one as non-actionable for the default configuration.
|
@codex review |
|
Codex Review: Didn't find any major issues. Bravo. Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
Review summaryDid a full read of One thing I want to flag as a non-issue in case it comes up: The remaining items are all test-hardening suggestions — nothing blocking:
Overall: nice work — clean, well-documented, and the integration smoke suite genuinely exercises the core paths against live PostgreSQL. The above are just suggestions to close coverage gaps. Generated by Claude Code |
- get_knowledge_graph max_nodes=None fallback test now returns more rows than the cap and asserts truncation, so it actually exercises the None -> global_config fallback (the old test mocked _fetch -> [] and passed even if it were broken). - upsert_nodes_batch: add entity_id-required (ValueError) and mismatched-entity_id -> node_id coercion tests, matching the single upsert_node coverage. - edge upsert REPLACE semantics: real-PG test that a re-upsert with a different key set drops keys absent from the new payload (would catch a regression to ||). - get_node_edges self-loop: real-PG test that (A, A) appears exactly once on the single-node path (previously only get_nodes_edges_batch covered this).
search_labels declared the LIKE escape char as ESCAPE '\' (one backslash via the
regular Python string). That is a valid one-character escape under the default
standard_conforming_strings=on, but a syntax error ("unterminated quoted string")
under the deprecated standard_conforming_strings=off. Switch to ESCAPE E'\\',
which is unambiguously one backslash regardless of that setting. Verified on PG18
with the setting both on and off.
|
Pushed two follow-up commits addressing the test-coverage gaps from the review (head now
Verified: shared contract suite 8/8 (real PG18, 0 skipped), full offline suite green on 3.12 and 3.14, and |
|
I really looking forward for that. AGE messing up with planner predictions which causing huge loops and performance significantly degrading. |
Summary
PGTableGraphStorage— aGRAPH_STORAGEbackend on plain PostgreSQL tables + B-tree indexes, with no Apache AGE, no Cypher, and no custom extensions. Closes #3095.Why: AGE can't be installed on managed PostgreSQL (RDS, Cloud SQL, Supabase, Neon). This backend makes LightRAG's graph layer run on any stock PostgreSQL 14+.
Changes
lightrag/kg/pgtable_impl.py—PGTableGraphStorage(all 18BaseGraphStoragemethods + 5 batch overrides)lightrag/kg/__init__.py— registers inSTORAGE_IMPLEMENTATIONS/STORAGE_ENV_REQUIREMENTS/STORAGESlightrag/kg/postgres_impl.py— pgvector imported lazily (stock-PG boxes can load the module).github/workflows/pg-smoke.yml— pg-smoke on PostgreSQL 18Schema (auto-created on
initialize())Implementation notes
get_knowledge_graphis a frontier-capped iterative BFS bounded bymax_nodes(degree-priority admission, seed pinned). Replaces a recursive CTE that blew up on hubs / deepmax_depth— see the depth sweep.min/max(never SQLLEAST/GREATEST— avoids non-C collation divergence).jsonb ||) instead of replacing;entity_idrequired.PGGraphStorage.Benchmark (PostgreSQL 18, GCP n2-standard-8, BA 8k nodes / ~40k edges)
get_knowledge_graphp50: PGTable 39 ms vs AGE 1,099 ms (~28×); seed ~145×;get_node_edges/upsert_edges_batch~25×.Cross-backend depth sweep —
get_knowledge_graphlatency (ms) asmax_depthgrows (BA n=3000,max_nodes=1000, hub seed degree 245):PGTable's frontier BFS beats AGE at every depth and matches/beats Neo4j at deep hubs (depth 5: 234 vs 446 ms) — a plain-table BFS competing with a native graph DB. The
/graphsAPI defaults tomax_depth=3with no upper bound on depth; the recursive-CTE approach this PR initially used (then dropped) timed out at hubmax_depth ≥ 3, which is exactly why it was replaced by the frontier BFS. Raw results: gist.Review follow-up
_search_score, same ordering], edge canonical sort)."*"; self-loop double-count inget_nodes_edges_batchfixed;SELECT ... FOR UPDATEin legacy edge normalization (serializes the one-time migration against concurrent upserts). Regression tests added.validate_workspace(matches PGGraphStorage / NetworkXStorage); wildcardget_knowledge_graph("*")is bounded in SQL (degree-orderedLIMIT, no full-table fetch);get_nodes_edges_batchOR predicate → index-friendlyUNION;PGTableGraphStorageregistered in the setup wizard (scripts/setup/lib/storage_requirements.sh).Known constraints
vector_storage, so mixing different non-vector vector backends against the same pool is rejected by design.Tests
Unit (mock) + integration smoke (
-m pg_smoke) on PG18 + NetworkX equivalence (19) + batch-ordering. Full offline suite green (2076 passed) on Python 3.12 and 3.14 after merging upstream/main.