7 min read
AI assisted

Apache AGE 없이 LightRAG — Recursive CTE로 그래프 저장소 구현

LightRAG의 BaseGraphStorage를 plain PostgreSQL + RCTE로 구현. 1-hop lookup 중심의 retrieval 패턴이 평면 SQL과 잘 맞는 이유

LightRAG의 BaseGraphStorage 인터페이스를 Apache AGE나 Cypher 없이 PostgreSQL Recursive CTE로 구현한 백엔드입니다. retrieval 호출 분포가 1-hop lookup 중심이라는 워크로드 특성과, 평면 SQL + B-tree 인덱스가 그 패턴에서 가장 효율적인 표현이라는 점이 결합되어, 그래프 확장 없이도 동일한 인터페이스를 충족합니다.


LightRAG의 그래프 추상화

LightRAG는 지식 그래프 스토리지를 BaseGraphStorage라는 추상 인터페이스로 분리합니다. 구현체를 교체할 수 있는 구조이고, 공식 구현체인 PGGraphStorage는 Apache AGE + Cypher wrapper에 의존합니다.

BaseGraphStorage는 18개의 추상 메서드를 정의합니다. has_node, has_edge, get_node, get_edge, get_node_edges, node_degree, edge_degree, get_all_labels, get_knowledge_graph, upsert_node, upsert_edge, delete_node, remove_nodes, remove_edges, embed_nodes — 그래프 연산에서 일반적으로 기대하는 CRUD와 탐색이 고르게 포함되어 있습니다.

이 인터페이스를 보면 Cypher나 Gremlin 같은 그래프 질의 언어가 필요하다는 인상을 받습니다. Apache AGE가 공식 PostgreSQL 백엔드로 선택된 것도 그 논리입니다 — PostgreSQL 위에 Cypher를 올려서 그래프 연산을 처리한다는 방향이었습니다. 그러나 그 인상이 실제 워크로드를 반영하는지는 별개의 질문입니다.

질문은 단순합니다. AGE 없이 plain PostgreSQL만으로 18개 메서드를 전부 구현하면서 동일한 인터페이스를 충족할 수 있는가. 그리고 그것이 성능상 합리적인가.


왜 RCTE가 LightRAG에 fit인가

그래프 DB를 쓸지 말지는 워크로드를 먼저 측정해야 결정할 수 있습니다. LightRAG의 retrieval 호출 분포는 다음과 같습니다.

연산 비율
get_node 35%
get_node_edges 25%
node_degree 15%
upsert_node 15%
has_node 10%

get_node 35%, get_node_edges 25%, has_node 10% — lookup 성격의 연산이 전체의 70%를 넘습니다. 모두 1-hop, 단일 노드 기준의 OLTP 패턴입니다.

그래프 DB가 강점을 보이는 연산은 다릅니다. variable-depth path traversal, shortestPath, cycle detection — 이 연산들은 retrieval critical path에 없습니다. LightRAG의 retrieval은 노드 하나를 꺼내거나, 그 노드에 연결된 엣지를 가져오거나, 차수를 계산하는 것이 전부입니다.

get_knowledge_graph는 BFS 탐색을 포함하지만, 이 메서드는 retrieval이 아닙니다. 시각화 용도의 보조 경로입니다. depth 2, max_nodes 50으로 상한이 고정되어 있습니다. RCTE의 알려진 약점인 deep BFS에서의 frontier 폭발이 이 워크로드에서는 의도적으로 회피된 구조입니다.

결론은 명확합니다. 평면 SQL + B-tree 인덱스는 1-hop lookup의 가장 효율적인 표현입니다. Cypher wrapper(AGE) 또는 PostgreSQL fork(AgensGraph)는 PostgreSQL plan generation 비용을 추가하기만 합니다. 그래프 질의 레이어가 제공하는 기능을 쓰지 않으면서 그 비용만 지불하는 구조입니다.

RCTE가 이 워크로드에서 우위를 보이는 것은 우연이 아닙니다. 워크로드 구조와 자료구조가 매칭된 결과입니다.


구현 구조

스키마

테이블은 두 개입니다. 자동으로 생성됩니다.

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 컬럼은 동일한 PostgreSQL 인스턴스에서 여러 LightRAG 인스턴스를 격리하는 용도입니다. 노드와 엣지의 속성은 properties JSONB에 담깁니다. 스키마가 단순한 만큼 B-tree 인덱스 전략이 명확합니다 — (workspace, id) 복합 인덱스가 get_node, has_node의 주요 조회 경로를 커버합니다.

구현체

구현 파일은 lightrag-upstream/lightrag/kg/pg_rcte_impl.py입니다. 이 파일 하나에서 BaseGraphStorage 18개 추상 메서드를 모두 구현합니다.

배치 메서드 오버라이드. 기본 구현을 그대로 쓰면 N+1 쿼리가 발생합니다. 5개 배치 메서드를 오버라이드해서 단일 쿼리로 처리합니다.

get_knowledge_graph — Recursive CTE BFS. 시각화 경로에서만 RCTE가 등장합니다. depth 2, max_nodes 50 상한 내에서 단일 쿼리로 BFS를 수행합니다.

WITH RECURSIVE graph_bfs AS (
    -- 시작 노드
    SELECT id, properties, 0 AS depth
    FROM lightrag_graph_nodes
    WHERE workspace = $1 AND id = $2

    UNION ALL

    -- 인접 노드 확장
    SELECT n.id, n.properties, b.depth + 1
    FROM graph_bfs b
    JOIN lightrag_graph_edges e
        ON e.workspace = $1 AND (e.src_id = b.id OR e.tgt_id = b.id)
    JOIN lightrag_graph_nodes n
        ON n.workspace = $1 AND n.id = CASE
            WHEN e.src_id = b.id THEN e.tgt_id
            ELSE e.src_id
        END
    WHERE b.depth < 2
)
SELECT DISTINCT id, properties FROM graph_bfs
LIMIT 50;

upsert_node / upsert_edge — INSERT ON CONFLICT DO UPDATE. PostgreSQL의 upsert 문법을 그대로 활용합니다. 별도의 존재 확인 쿼리 없이 단일 문장으로 처리됩니다.

진입점 등록. lightrag_pg_rcte/__init__.py에서 PgRcteGraphStorage를 re-export합니다. 사용 측에서는 graph_storage="PgRcteGraphStorage" 한 줄로 교체합니다.

from lightrag import LightRAG
from lightrag.llm.ollama import ollama_model_complete, ollama_embed

rag = LightRAG(
    working_dir="./workdir",
    llm_model_func=ollama_model_complete,
    llm_model_name="qwen3:0.6b",
    embedding_func=...,
    graph_storage="PgRcteGraphStorage",   # 이것만 바꾸면 됩니다
)

환경변수

POSTGRES_HOST
POSTGRES_PORT
POSTGRES_USER
POSTGRES_PASSWORD
POSTGRES_DATABASE

별도의 설정 파일 형식 없이 표준 PostgreSQL 환경변수를 그대로 사용합니다.


벤치마크

검증

정확성 검증이 먼저입니다. 성능 수치가 의미 있으려면 구현이 올바른 결과를 반환해야 합니다.

검증 항목 결과
기능 테스트 (34개) 통과
NetworkX 동등성 일치
Neo4j 동등성 일치
AGE (PGGraphStorage) 동등성 일치
4백엔드 합성 데이터 동등성 일치
4백엔드 EZIS 실데이터 동등성 일치

tests/test_storage.py (34개 기능 테스트), tests/test_equivalence.py (NetworkX / Neo4j / AGE 대비 결과 일치 검증), tests/test_equivalence_synthetic.py (4백엔드 합성 데이터 동등성), tests/test_equivalence_ezis.py (실데이터 동등성) — 네 파일이 각각 다른 차원에서 구현의 정확성을 확인합니다.

성능

벤치마크 환경은 Colima VM (8 vCPU / 10GB)이고, 각 백엔드 컨테이너에 동일한 자원(2GB / 2코어)을 고정했습니다. 벤치마크 컨테이너는 별도 2코어, workers=10입니다.

워크로드는 LightRAG 실제 호출 분포를 그대로 따릅니다. get_node 35% / get_node_edges 25% / node_degree 15% / has_node 10% / upsert_node 15%.

Synthetic (Barabási-Albert, 8,000 nodes / 39,975 edges)

백엔드 Seed(s) RPS p50(ms) p95(ms)
NetworkX 0.8 47,730 0.2 0.3
RCTE 19.6 12,776 0.7 1.3
Neo4j 85.9 1,684 4.3 13.2
AGE 351.8 175 4.8 213.3

EZIS (실제 LightRAG 추출 데이터, 8,050 nodes / 25,844 edges)

백엔드 Seed(s) RPS p50(ms) p95(ms)
NetworkX 0.6 47,113 0.2 0.3
RCTE 14.3 9,169 0.7 1.4
Neo4j 66.5 1,693 4.2 13.2
AGE 208.7 159 4.6 238.6

RCTE는 Neo4j보다 7.6x 빠르고, AGE보다 58~73x 빠릅니다. AGE의 p95는 200ms를 넘습니다 — workers=10 동시 부하에서 응답 지연이 폭발합니다.

NetworkX는 in-process 기준값입니다. 네트워크 왕복이 없는 조건이라 직접 비교 대상은 아닙니다.

격차의 원인

bench/compare_three_way.py와 benchmark_gdb Phase 1~10 시리즈가 이 격차를 분해합니다.

AGE의 290x 격차(Phase 1~8 보고서 기준)는 두 층으로 나뉩니다. cypher() wrapper 비용이 한 축입니다 — AGE가 PostgreSQL 위에서 Cypher를 실행하는 방식이 SQL plan generation 경로를 우회하면서 비용을 추가합니다. 다른 축은 PostgreSQL planner 자체의 그래프 traversal 한계입니다 — 그래프 연산을 관계형 플래너가 최적화하기 어렵습니다.

AgensGraph(PostgreSQL fork로 Cypher를 네이티브 통합)는 wrapper 비용을 해결하지만 planner 한계는 그대로입니다. PostgreSQL 위에 Cypher를 얹는 모든 시도가 이 지점에서 RCTE를 따라잡지 못하는 이유입니다.

bench/bench_lightrag_mix.py는 LightRAG 실제 호출 혼합으로 측정하고, bench/bench_graph_ops.py는 개별 그래프 연산을 분리해서 측정합니다. 더 일반적인 GraphRAG OLTP mix (Q1~Q7, 30/30/20/10/3/4/3 비율) 측정에서는 benchmark_gdb Phase 6 기준으로 RCTE 22,500 RPS / Neo4j 14,500 RPS / AGE 78 RPS (u=50)였습니다 — 방향은 같고, 워크로드가 lookup에 가까울수록 RCTE 우위 폭이 커집니다.


운영 권장 사항

어떤 경우에 쓸지

LightRAG를 PostgreSQL 위에서 운영하면서 Apache AGE나 AgensGraph 의존을 피하고 싶을 때 적합합니다. 기존 PostgreSQL 인프라가 있고, 그래프 전용 DB를 별도로 관리하고 싶지 않을 때 추가 의존 없이 같은 인터페이스를 충족합니다.

설치

# 소스에서 직접
git clone https://github.com/your-org/lightrag_pg_not_age
uv sync --dev

# 테스트 컨테이너 (port 5434)
docker run -d --name lightrag-pg-test \
  -e POSTGRES_USER=postgres \
  -e POSTGRES_PASSWORD=postgres \
  -e POSTGRES_DB=lightrag_test \
  -p 5434:5432 postgres:17-alpine

환경변수를 설정하고 LightRAG를 초기화하면 스키마는 자동으로 생성됩니다.

export POSTGRES_HOST=localhost
export POSTGRES_PORT=5434
export POSTGRES_USER=postgres
export POSTGRES_PASSWORD=postgres
export POSTGRES_DATABASE=lightrag_test

인덱스 권장

(workspace, id) 복합 인덱스가 get_node, has_node, node_degree의 주요 경로를 커버합니다. 엣지 테이블은 (workspace, src_id), (workspace, tgt_id) 각각에 인덱스를 두면 get_node_edges 성능이 유지됩니다. 스키마 자동 생성 시 기본 인덱스가 포함되지만, 운영 규모에 따라 FILLFACTOR 조정이나 partial index를 고려할 수 있습니다.

한계

variable-depth graph traversal이 retrieval critical path에 있는 워크로드는 이 구현에 맞지 않습니다. shortestPath, 임의 깊이 BFS, cycle detection이 실제로 쓰이는 시스템이라면 그것은 전용 그래프 DB의 영역입니다. RCTE는 frontier가 깊어질수록 중간 결과 집합이 폭발하고, 그 비용을 B-tree로 상쇄하기 어렵습니다.

LightRAG가 그 한계를 맞닥뜨리지 않는 이유는 get_knowledge_graph의 depth 2, max_nodes 50 상한 때문입니다. 워크로드 설계가 RCTE의 약점을 회피합니다. 다른 GraphRAG 시스템에 이 구현을 그대로 가져다 쓰려면 그 시스템의 호출 분포를 먼저 측정해야 합니다.


마무리

LightRAG가 실제로 어떤 그래프 연산을 쓰는지 측정하고, 거기에 맞는 표현을 고른 결과입니다. "GraphRAG는 그래프 DB가 필요하다"는 가정 자체가 워크로드 측정 없이 만들어진 경우가 많습니다.

PgRcteGraphStoragegraph_storage="PgRcteGraphStorage" 한 줄 교체로 기존 LightRAG 코드에 통합됩니다. AGE / AgensGraph / Neo4j 의존 없이 plain PostgreSQL 위에서 동일한 인터페이스를 충족하고, 벤치마크 기준으로 AGE보다 58~73x 빠른 throughput을 보입니다.


참고