pgGraph: PostgreSQL 관계를 그래프로 탐색하는 법
기존 PostgreSQL 테이블은 그대로 두고, 관계 탐색만 그래프 런타임으로 빠르게 처리하는 확장.
테이블 등록부터 CSR 생성, 검색·관계 확장, 동기화까지 이어지는 pgGraph의 전체 흐름
pgGraph는 PostgreSQL을 정본으로 유지한다
1. 원본 테이블
users, orders, order_items 같은 일반 테이블이 실제 업무 데이터다. 제약·ACL/RLS·WAL·MVCC와 백업은 PostgreSQL이 계속 책임진다.
2. 파생 그래프 인덱스
pgGraph는 등록한 행과 관계를 읽어 노드 좌표·필터·양방향 CSR을 만든다. 이 그래프 상태는 원본 테이블에서 언제든 다시 만들 수 있는 projection이다.
3. SQL 안의 그래프 실행
graph.search(), graph.traverse(), graph.shortest_path() 같은 SQL 함수가 이 projection을 사용한다. 실제 행 데이터는 필요할 때만 원본 테이블에서 가져온다.
출발점은 평범한 관계형 테이블이다
아래 모델에서 FK와 junction table에는 이미 그래프 간선이 될 정보가 들어 있다. pgGraph는 이 스키마를 복사해 vertex/edge 테이블을 새로 만들지 않는다. 대신 어떤 테이블을 노드로 볼지, 어떤 열로 연결할지를 등록한다.
erDiagram
CUSTOMERS {
text id PK
text name
text tier
}
ORDERS {
text id PK
text customer_id FK
text status
}
ORDER_ITEMS {
text order_id FK
text product_id FK
int quantity
}
PRODUCTS {
text id PK
text name
text category
}
CUSTOMERS ||--o{ ORDERS : "customer_id"
ORDERS ||--o{ ORDER_ITEMS : "order_id"
PRODUCTS ||--o{ ORDER_ITEMS : "product_id"
erDiagram
CUSTOMERS {
text id PK
text name
text tier
}
ORDERS {
text id PK
text customer_id FK
text status
}
ORDER_ITEMS {
text order_id FK
text product_id FK
int quantity
}
PRODUCTS {
text id PK
text name
text category
}
CUSTOMERS ||--o{ ORDERS : "customer_id"
ORDERS ||--o{ ORDER_ITEMS : "order_id"
PRODUCTS ||--o{ ORDER_ITEMS : "product_id"
예를 들어 orders.customer_id → customers.id는 주문에서 고객으로 향하는 placed_by 간선이 된다. order_items는 주문과 상품을 잇는 관계 행으로 해석할 수 있다.
예제로 끝까지 따라가 보자: “Alice의 주문은?”
이후 장표에서는 설명을 쉽게 하기 위해 customers와 orders만 사용한다. 이름 Alice를 찾은 뒤, Alice와 연결된 주문을 그래프 탐색으로 찾는 것이 목표다.
orders.customer_id가 그래프 간선의 재료가 된다.customers | orders | ||||
|---|---|---|---|---|---|
| id | name | tier | id | customer_id | status |
c-1 | Alice | gold | o-101 | c-1 | paid |
c-2 | Bob | basic | o-102 | c-1 | paid |
o-103 | c-2 | pending |
관계형 의미: o-101.customer_id = c-1
그래프 의미: (orders, o-101) ─placed_by→ (customers, c-1)
실제 1단계: 원본 테이블과 데이터를 만든다
테이블 정의
CREATE TABLE customers (
id text PRIMARY KEY,
name text NOT NULL,
tier text NOT NULL
);
CREATE TABLE orders (
id text PRIMARY KEY,
customer_id text NOT NULL REFERENCES customers(id),
status text NOT NULL
);
샘플 행 입력
INSERT INTO customers VALUES
('c-1', 'Alice', 'gold'),
('c-2', 'Bob', 'basic');
INSERT INTO orders VALUES
('o-101', 'c-1', 'paid'),
('o-102', 'c-1', 'paid'),
('o-103', 'c-2', 'pending');
이 단계에서 별도의 그래프용 DDL은 필요 없다. 애플리케이션은 지금처럼 INSERT, UPDATE, FK, 인덱스를 그대로 쓴다.
실제 2단계: “무엇이 노드이고 간선인가”를 등록한다
CREATE EXTENSION graph;
SELECT graph.add_table(
table_name := 'public.customers'::regclass,
id_column := 'id',
columns := ARRAY['name', 'tier']
);
SELECT graph.add_table(
table_name := 'public.orders'::regclass,
id_column := 'id',
columns := ARRAY['status']
);
SELECT graph.add_edge(
from_table := 'public.orders'::regclass,
from_column := 'customer_id',
to_table := 'public.customers'::regclass,
to_column := 'id',
label := 'placed_by',
bidirectional := true
);
이 설정은 “orders.id를 출발 노드로 보고, orders.customer_id 값은 목표 customers.id로 연결하라”는 뜻이다. bidirectional := true를 지정하면 반대 방향 탐색용 인접성 정보도 함께 만든다.
실제 3단계: graph.build()로 테이블을 읽고 그래프를 만든다
호출
SELECT * FROM graph.build();
SELECT node_count, edge_count, schema_status
FROM graph.status();
이 예제에서 일어나는 일
노드: customers 2행 + orders 3행
→ 내부 node_idx 5개
간선: o-101 → c-1
o-102 → c-1
o-103 → c-2
→ placed_by 관계를 CSR로 정렬
graph.build()는 원본 행을 읽기만 하며 수정하지 않는다. 빌드가 끝나면 SQL의 (public.customers, 'c-1') 같은 좌표를 내부 node_idx로 바꿀 수 있다. 간선은 CSR 인접성 배열에서 찾는다.
실제 4단계: 먼저 graph.search()로 Alice를 찾는다
호출
SELECT node_table_name, node_id, match_type, score, node
FROM graph.search(
property_key := 'name',
property_value := 'Alice',
table_filter := 'public.customers'::regclass,
mode := 'exact',
case_sensitive := false,
hydrate := true
);
이 예제의 결과 의미
| node_table_name | node_id | match_type | node |
|---|---|---|---|
customers | c-1 | exact | { "id": "c-1", "name": "Alice", "tier": "gold" } |
이 호출은 CSR 배열을 검색하지 않는다. pgGraph는 등록된 customers.name에 SQL predicate를 만들고 원본 테이블을 읽는다. 결과 행을 확인한 뒤 그래프 좌표 (customers, c-1)를 반환한다.
graph.search() 안에서는 실제로 무엇을 할까?
%%{init: {"flowchart": {"htmlLabels": false, "useMaxWidth": true}} }%%
flowchart LR
A["입력\nname = Alice"]
B["등록 확인\ncustomers.name이 columns에 있는가?"]
C["원본 테이블 SQL\nlower(name::text) 조건"]
D["PostgreSQL index\n표현식 index / pg_trgm"]
E["행 검증\nACL·RLS가 보이는 행만"]
F["그래프 좌표\n(customers, c-1)"]
A --> B --> C --> D --> E --> F
%%{init: {"flowchart": {"htmlLabels": false, "useMaxWidth": true}} }%%
flowchart LR
A["입력\nname = Alice"]
B["등록 확인\ncustomers.name이 columns에 있는가?"]
C["원본 테이블 SQL\nlower(name::text) 조건"]
D["PostgreSQL index\n표현식 index / pg_trgm"]
E["행 검증\nACL·RLS가 보이는 행만"]
F["그래프 좌표\n(customers, c-1)"]
A --> B --> C --> D --> E --> F
검색은 먼저 등록된 속성인지 확인한 뒤 원본 테이블에 조건을 건다. 각 결과는 (node_table, node_id)로 중복을 제거하고, 테이블 OID와 ID 순으로 정렬한 뒤 페이지를 나눈다. 즉 graph.search()는 현재 PostgreSQL 행을 믿을 수 있는 그래프 출발점으로 바꾸는 함수다.
검색 결과의 핵심은 “그래프 좌표”다
사람이 보는 검색 결과
node_table_name = customers
node_id = c-1
node = { name: "Alice", tier: "gold", ... }
여기서 customers / c-1은 일반 ID가 아니라, 어느 테이블의 어느 행인지까지 포함한 식별자다. 다른 테이블에 같은 id = 'c-1'이 있어도 섞이지 않는다.
그래프 엔진이 쓰는 값
SELECT node_idx, node
FROM graph.get_node(
graph_name := 'default',
label := 'customers',
id := 'c-1',
hydrate := false
);
엔진은 이 좌표를 내부 node_idx로 바꾼다. 그 뒤의 BFS, DFS, 최단 경로는 문자열 ID가 아니라 이 작은 정수와 CSR 배열을 사용한다.
그래프 탐색 결과는 “연결된 행 + 연결 이유”다
graph.traverse()가 주는 정보
node_table,node_id: 도착한 원본 행의 좌표depth: 시작점에서 몇 hop 떨어졌는지path: 시작점부터 도착점까지의 좌표 경로edge_path: 그 경로에서 지난 관계 labelnode:hydrate := true일 때의 실제 원본 행 JSONBcapped: 제한 때문에 탐색이 중간에 멈췄는지
서비스에서 쓰는 방식
검색 결과: Alice
└─ depth 1: Alice가 만든 주문
└─ depth 2: 그 주문에 담긴 상품
└─ edge_path: 어떤 관계를 거쳐 왔는지 설명
API 응답: 관련 행 + 거리 + 관계 경로
그래프는 “비슷한 문서 목록”만 주는 것이 아니다. 왜 이 결과가 연결됐는지를 path와 edge label로 함께 전달할 수 있다.
실제 응답은 이렇게 조립할 수 있다
관계 정보만 먼저 얻기
SELECT node_id, depth, path, edge_path
FROM graph.traverse(
seed_table := 'public.customers'::regclass,
seed_id := 'c-1',
max_depth := 2,
direction := 'in',
include_start := false,
hydrate := false
);
큰 탐색에서는 우선 좌표·깊이·경로만 가져오는 편이 낫다. 결과 수를 줄인 뒤 필요한 주문만 일반 SQL로 조인하거나, hydrate := true로 원본 행을 붙인다.
클라이언트에 줄 수 있는 형태
{
"seed": { "table": "customers", "id": "c-1" },
"related": [
{
"table": "orders",
"id": "o-101",
"depth": 1,
"via": ["placed_by"]
}
]
}
depth는 가까운 관계를 우선 보여 주는 기준이 되고, via와 path는 추천·검색 결과의 근거를 화면이나 로그에 남기는 데 쓸 수 있다.
pgGraph 검색은 자동 그래프 랭킹이 아니다
pgGraph가 하는 일
graph.search()는 등록된 원본 속성에서 exact·contains·prefix·token 검색을 한다.- 확인된 행의 그래프 좌표를 반환하고, 이후 관계 탐색의 시작점으로 쓴다.
graph.traverse()는 depth·관계 type·tenant·등록된 filter 조건으로 연결된 노드를 좁힌다.
애플리케이션이 정할 일
- FTS·trigram·
pgvector같은 후보 검색과 점수 계산 - 검색 결과를 어떤 depth까지 확장할지
- 거리, 관계 종류, 주문 상태 등을 이용한 재정렬·필터링
- 사용자에게 보여 줄 최종 응답 모양
현재 graph.search()의 score는 검증된 일치에 대해 1.0이다. 그래프 중심성이나 의미 유사도로 자동 순위를 매기는 기능으로 이해하면 안 된다. 필요하면 후보 검색과 그래프 탐색 결과를 애플리케이션 또는 후속 SQL에서 결합한다.
RAG의 문서 청크와 pgGraph의 저장 청크는 다른 말이다
RAG 문서 청크
- 긴 PDF·위키·매뉴얼의 본문을 검색 가능한 작은 텍스트 조각으로 나눈다.
- 각 조각에는 보통 원문, 순서, 문서 ID, 메타데이터, 임베딩 벡터가 붙는다.
- 질문과 의미가 가까운 텍스트 후보를 찾기 위해 존재한다.
pgGraph의 artifact chunk
- 빌드한 node·edge·CSR projection을 파일에 나누어 저장·복구하는 내부 저장 단위다.
- 문장을 자르거나 텍스트를 벡터로 바꾸는 기능이 아니다.
- 관계 탐색용 그래프 상태를 효율적으로 지속화하기 위해 존재한다.
따라서 pgGraph 문서에서 보이는 chunk를 RAG의 “문서 청킹”으로 읽으면 안 된다. 이름만 같고 대상과 목적이 다르다.
pgGraph가 청킹·임베딩을 하지 않는 이유
pgGraph가 최적화한 문제
원본 PostgreSQL 행을 노드·간선으로 해석하고, (table, PK)를 node_idx로 바꾼 뒤 CSR을 따라 관계를 확장하는 일이다. 성능의 중심은 반복적인 bounded traversal과 최신 원본 행의 hydration이다.
RAG 전처리가 결정해야 하는 문제
어디서 텍스트를 끊을지, overlap을 얼마나 둘지, 어떤 임베딩 모델·차원·언어 정책을 쓸지는 문서 종류와 검색 품질 목표에 따라 달라진다. 벡터 검색도 CSR이 아니라 거리 연산과 HNSW/IVFFlat 같은 ANN 인덱스가 담당한다.
이 둘을 한 엔진의 한 기능으로 섞지 않고, 후보 찾기와 관계 확장을 분리한 것이 pgGraph의 PostgreSQL-first 설계와 맞는다. pgGraph 로드맵도 pgVector 보조 후보 랭킹을 탐색 대상으로 언급하면서, 임베딩을 traversal hot path나 정본으로 만들지 않겠다고 설명한다.
GraphRAG에서는 역할을 이렇게 나눈다
%%{init: {"flowchart": {"htmlLabels": false, "useMaxWidth": true}} }%%
flowchart LR
A["원문 문서\nPDF · 위키 · 매뉴얼"]
B["청킹\n크기 · overlap · 문단 경계"]
C["임베딩 생성\n선택한 모델로 벡터화"]
D["PostgreSQL + pgvector\nchunk · metadata · vector"]
E["후보 검색\nvector / FTS / hybrid"]
F["pgGraph\nchunk · 문서 · 엔터티 관계"]
G["근거 있는 응답\n본문 + path + 관계 이유"]
A --> B --> C --> D --> E --> F --> G
%%{init: {"flowchart": {"htmlLabels": false, "useMaxWidth": true}} }%%
flowchart LR
A["원문 문서\nPDF · 위키 · 매뉴얼"]
B["청킹\n크기 · overlap · 문단 경계"]
C["임베딩 생성\n선택한 모델로 벡터화"]
D["PostgreSQL + pgvector\nchunk · metadata · vector"]
E["후보 검색\nvector / FTS / hybrid"]
F["pgGraph\nchunk · 문서 · 엔터티 관계"]
G["근거 있는 응답\n본문 + path + 관계 이유"]
A --> B --> C --> D --> E --> F --> G
청킹과 임베딩은 수집·인덱싱 단계다. pgvector는 벡터 열을 저장하고 최근접 이웃 검색을 수행한다. pgGraph는 그 결과를 seed로 받아 문서·청크·엔터티·권한·업무 객체의 연결을 확장한다.
구현 예시 1: 청크와 벡터는 일반 테이블에 둔다
문서·청크 테이블
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE documents (
id text PRIMARY KEY,
title text NOT NULL
);
CREATE TABLE document_chunks (
id text PRIMARY KEY,
document_id text NOT NULL REFERENCES documents(id),
chunk_index integer NOT NULL,
content text NOT NULL,
embedding vector(1536) NOT NULL
);
벡터 검색용 인덱스
CREATE INDEX document_chunks_embedding_hnsw_idx
ON document_chunks
USING hnsw (embedding vector_cosine_ops);
청킹 프로그램은 content를 만들고, 선택한 임베딩 API 또는 로컬 모델이 embedding 값을 만든다. 여기서 1536은 예시 차원이다. 실제 차원은 사용한 모델과 동일해야 한다.
구현 예시 2: 벡터 후보를 pgGraph 탐색의 시작점으로 쓴다
① pgvector로 관련 청크를 찾는다
-- :query_embedding은 애플리케이션이 질문을 임베딩한 값
SELECT id, document_id,
1 - (embedding <=> :query_embedding) AS similarity
FROM document_chunks
ORDER BY embedding <=> :query_embedding
LIMIT 20;
이 단계는 “질문과 의미가 가까운 본문은 무엇인가?”에 답한다. pgvector의 cosine 거리 연산과 HNSW 인덱스를 이용하는 일반 SQL이다.
② 각 후보를 그래프 seed로 확장한다
SELECT node_table_name, node_id, depth, path, edge_path
FROM graph.traverse(
seed_table := 'public.document_chunks'::regclass,
seed_id := 'chunk-042',
max_depth := 2,
include_start := true,
hydrate := false
);
여기서는 “이 청크가 어느 문서·엔터티·제품·권한과 연결되는가?”를 묻는다. 후보 20개는 애플리케이션이 제한·중복 제거·재정렬한 뒤 필요한 seed만 탐색한다.
GraphRAG용 테이블은 이렇게 pgGraph에 연결한다
%%{init: {"flowchart": {"htmlLabels": false, "useMaxWidth": true}} }%%
flowchart LR
D["documents\n문서 제목"]
C["document_chunks\n본문 · vector"]
E["entities\n제품 · 기능 · 사람"]
P["permissions\n접근 가능 사용자/조직"]
C -->|"belongs_to"| D
C -->|"mentions"| E
D -->|"visible_to"| P
%%{init: {"flowchart": {"htmlLabels": false, "useMaxWidth": true}} }%%
flowchart LR
D["documents\n문서 제목"]
C["document_chunks\n본문 · vector"]
E["entities\n제품 · 기능 · 사람"]
P["permissions\n접근 가능 사용자/조직"]
C -->|"belongs_to"| D
C -->|"mentions"| E
D -->|"visible_to"| P
SELECT graph.add_table(
table_name := 'public.documents'::regclass,
id_column := 'id',
columns := ARRAY['title']
);
SELECT graph.add_table(
table_name := 'public.document_chunks'::regclass,
id_column := 'id',
columns := ARRAY['document_id', 'chunk_index']
);
SELECT graph.add_edge(
from_table := 'public.document_chunks'::regclass,
from_column := 'document_id',
to_table := 'public.documents'::regclass,
to_column := 'id',
label := 'belongs_to',
bidirectional := true
);
embedding은 pgGraph에 등록할 필수 속성이 아니다. 벡터 후보 검색에는 원본 테이블과 pgvector 인덱스를 쓰고, graph에는 관계를 탐색하는 데 필요한 키와 label을 등록한다. 엔터티 추출 결과나 권한 관계도 같은 방식으로 간선 규칙으로 추가할 수 있다.
검색 성능은 PostgreSQL 인덱스로 준비한다
대소문자 무시 검색
CREATE INDEX customers_name_lower_idx
ON public.customers (lower(name::text));
case_sensitive := false 검색은 lower(name::text) 표현식을 쓴다. PostgreSQL이 이 검색을 빠르게 처리하려면 같은 표현식의 인덱스가 필요하다.
부분 문자열 검색이 많다면
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX customers_name_lower_trgm_idx
ON public.customers
USING gin (lower(name::text) gin_trgm_ops);
중요한 점은 검색 인덱스와 CSR의 역할이 다르다는 것이다. 검색 인덱스는 “Alice가 누구인가”를 찾고, CSR은 “Alice와 무엇이 연결됐는가”를 찾는다.
실제 5단계: Alice에서 연결된 주문을 탐색한다
호출
SELECT node_table_name, node_id, depth, edge_path
FROM graph.traverse(
seed_table := 'public.customers'::regclass,
seed_id := 'c-1',
max_depth := 1,
edge_types := ARRAY['placed_by'],
direction := 'in',
include_start := false,
hydrate := false
);
결과와 내부 동작
| node_table_name | node_id | depth | edge_path |
|---|---|---|---|
orders | o-101 | 1 | ["placed_by"] |
orders | o-102 | 1 | ["placed_by"] |
placed_by는 주문 → 고객 방향으로 등록했다. 그래서 고객 c-1에서 주문을 찾을 때는 direction := 'in'을 쓴다. pgGraph는 c-1을 내부 node index로 바꾼 뒤 inbound CSR의 해당 구간만 읽어 o-101, o-102를 얻는다. 매 hop마다 orders를 다시 조인하지 않는다.
애플리케이션에서는 검색과 탐색을 이렇게 조합한다
1. graph.search(name = 'Alice')
→ 검증된 좌표: (customers, c-1)
2. graph.traverse(seed = customers/c-1, direction = in)
→ 연결된 좌표: (orders, o-101), (orders, o-102)
3. 필요할 때만 hydrate = true 또는 일반 SQL JOIN
→ 주문 상태·금액·상품 등 최종 응답 구성
검색어 입력부터 관계 결과까지 한 번에 처리하고 싶다면 workflow API를 쓸 수도 있다.
SELECT *
FROM graph.find_related(
property_key := 'name',
property_value := 'Alice',
source_table := 'public.customers'::regclass,
max_depth := 1,
target_table := 'public.orders'::regclass
);
find_related()는 후보 검색 → 후보별 traversal → 결과 필터·hydration·페이지네이션을 한 번에 처리한다. 후보가 많이 나올 수 있는 서비스라면 candidate_limit, max_depth, max_nodes, max_frontier를 함께 제한한다.
1단계: 테이블과 관계를 그래프 규칙으로 등록한다
%%{init: {"flowchart": {"htmlLabels": false, "useMaxWidth": true}} }%%
flowchart LR
C["customers\nid · name · tier"]
O["orders\nid · customer_id · status"]
I["order_items\norder_id · product_id"]
P["products\nid · name · category"]
C -->|"add_table(id, columns)"| CN(("Customer\nnode"))
O -->|"add_table(id, columns)"| ON(("Order\nnode"))
P -->|"add_table(id, columns)"| PN(("Product\nnode"))
O -->|"add_edge(customer_id → customers.id)\nlabel: placed_by"| CN
I -->|"add_edge(order_id/product_id)\nlabel: contains"| PN
ON -->|"registered relation"| CN
%%{init: {"flowchart": {"htmlLabels": false, "useMaxWidth": true}} }%%
flowchart LR
C["customers\nid · name · tier"]
O["orders\nid · customer_id · status"]
I["order_items\norder_id · product_id"]
P["products\nid · name · category"]
C -->|"add_table(id, columns)"| CN(("Customer\nnode"))
O -->|"add_table(id, columns)"| ON(("Order\nnode"))
P -->|"add_table(id, columns)"| PN(("Product\nnode"))
O -->|"add_edge(customer_id → customers.id)\nlabel: placed_by"| CN
I -->|"add_edge(order_id/product_id)\nlabel: contains"| PN
ON -->|"registered relation"| CN
add_table()은 node table, ID 열, 검색·GQL에 보여 줄 속성을 기록한다.add_edge()는 FK 또는 junction/edge table의 endpoint와 edge label을 기록한다.add_filter_column()은 탐색 중 SQL을 다시 조회하지 않고 판정할 속성을 따로 등록한다.
pgGraph는 관계형 행을 CSR로 “컴파일”한다
빌드 경로
- node table, edge, filter column을 extension catalog에 등록
- SPI로 원본 행을 읽어
(table OID, PK) → node_idx를 구성 - 관계 endpoint를
node_idx로 해석 - forward CSR과 inbound CSR을 생성·지속화
읽기 경로
seed (table, PK)
→ ResolutionIndex
→ node_idx
→ CSR offsets[i..i+1]
→ BFS / DFS / shortest path
→ 필요할 때만 원본 행 hydration
CSR은 한 노드의 이웃을 연속된 배열 구간에서 찾는다. 매 hop마다 관계 테이블을 다시 조인하는 대신, 미리 만든 인접성 배열을 순회한다.
2단계: graph.build()가 그래프 런타임을 만든다
%%{init: {"flowchart": {"htmlLabels": false, "useMaxWidth": true}} }%%
flowchart LR
A["등록 catalog\ntables · edges · filters"]
B["SPI batch scan\n원본 PostgreSQL 행"]
C["NodeStore\nnode_idx 부여"]
D["ResolutionIndex\n(table OID, PK) → node_idx"]
E["Edge resolution\nendpoint를 node_idx로 변환"]
F["Forward + inbound CSR\noffsets · targets · type IDs"]
G[".pggraph artifact\n검증 후 read-only mmap"]
A --> B --> C
C --> D
C --> E
D --> E --> F --> G
%%{init: {"flowchart": {"htmlLabels": false, "useMaxWidth": true}} }%%
flowchart LR
A["등록 catalog\ntables · edges · filters"]
B["SPI batch scan\n원본 PostgreSQL 행"]
C["NodeStore\nnode_idx 부여"]
D["ResolutionIndex\n(table OID, PK) → node_idx"]
E["Edge resolution\nendpoint를 node_idx로 변환"]
F["Forward + inbound CSR\noffsets · targets · type IDs"]
G[".pggraph artifact\n검증 후 read-only mmap"]
A --> B --> C
C --> D
C --> E
D --> E --> F --> G
NodeStore는 원래 테이블 좌표를 보존한다. ResolutionIndex는 SQL에서 받은 business ID를 내부 node index로 바꾼다. 이후 CSR에는 문자열 PK 대신 작은 node index가 들어간다.
CSR을 10초 만에 읽어보자
먼저, 그래프를 번호로 바꾼다
0 = orders(o-7) ─placed_by→ 2 = customers(c-1)
1 = orders(o-8) ─placed_by→ 2 = customers(c-1)
2 = customers(c-1) (다음 노드 없음)
문자열 ID는 사람이 읽기 좋다. 하지만 탐색할 때는 작은 정수 node_idx가 더 효율적이다.
그 이웃을 두 배열로 저장한다
offsets = [0, 1, 2, 2]
targets = [2, 2]
0번 노드: targets[0..1] = [2]
1번 노드: targets[1..2] = [2]
2번 노드: targets[2..2] = []
offsets는 노드 수보다 한 칸 더 길다. 예를 들어 1번 노드는 offsets[1]에서 시작해 offsets[2] 직전에서 끝난다. 시작과 끝이 같으면 이웃이 없다.
CSR이 빠른 대신, 갱신에는 별도 절차가 필요하다
읽을 때: 필요한 구간만 순서대로 읽는다
offsets두 값으로 이웃의 시작과 끝을 바로 찾는다.- 실제로 읽는 양은 전체 간선 수가 아니라 해당 노드의 이웃 수에 가깝다.
- 연속 배열이라 CPU cache와 페이지 읽기에도 유리하다.
바꿀 때: 중간 삽입이 쉽지 않다
- 배열 중간에 새 간선을 넣으면 뒤쪽 위치가 달라질 수 있다.
- 그래서 pgGraph는 base CSR을 불변으로 두고, 변경분은 sync log·overlay로 먼저 반영한다.
- 변경이 쌓이면
maintenance()나vacuum()이 원본 테이블에서 CSR을 새로 만든다.
이런 특성 때문에 pgGraph는 반복 읽기와 깊이가 제한된 탐색에 특히 잘 맞는다.
pgGraph와 Neo4j: 가속 계층인가, graph-native DB인가
pgGraph: PostgreSQL-first
- 업무 행·제약·ACL/RLS·WAL/MVCC는 기존 PostgreSQL에 남는다.
graph.build()가 불변 base CSR을 만든다.graph.traverse()등 SQL 함수가 해당 projection을 실행한다.- GQL/openCypher 호환 표면은 문서화된 bounded subset이다.
- 검색은 원본 PostgreSQL SQL predicate와 인덱스를 사용한다.
Neo4j: graph-native-first
- 노드·관계·속성을 Neo4j graph store가 직접 저장한다.
- Cypher는 Neo4j의 선언형 그래프 질의 언어이며, 전용 query model의 중심이다.
- 현재 block store format은 관련 데이터의 co-location과 graph element용 store files를 사용한다.
- PostgreSQL을 정본으로 유지한다면 두 저장소 사이의 적재·동기화 경계를 따로 설계해야 한다. 이는 Neo4j의 결함이 아니라, 별도 graph database를 쓸 때 따르는 일반적인 운영 선택이다.
pgGraph와 Apache AGE: 둘 다 Postgres 확장, 그러나 모델은 다르다
pgGraph: 기존 스키마의 projection
- 일반 테이블과 FK/junction 관계를 명시적으로 등록한다.
- 그래프는 원본에서 다시 만들 수 있는 CSR projection이다.
- 그래프 쓰기는 PostgreSQL-first DML을 경유하며, 지원 범위가 좁게 설계돼 있다.
- 불변 CSR의 변경분은 sync log, overlay, maintenance/rebuild로 반영한다.
Apache AGE: PostgreSQL 안의 property graph
create_graph()와 label 생성으로 그래프 모델을 만든다.cypher('graph_name', ...)에서 openCypher 패턴으로 vertex·edge를 읽고 쓴다.- 반환 타입과 graph 값 모델에
agtype을 사용한다. - SQL과 Cypher를 함께 쓰는 multi-model 접근이 핵심이다.
“검색”의 뜻도 세 제품에서 다르다
| 관점 | pgGraph | Neo4j | Apache AGE |
|---|---|---|---|
| 텍스트/속성 후보 찾기 | graph.search()가 원본 테이블에 SQL predicate 실행 | graph property와 Neo4j 인덱스·query model 안에서 처리 | Cypher의 label/property pattern과 PostgreSQL 내부 모델을 사용 |
| 관계 확장 | CSR 기반 BFS/DFS, shortest path | graph store의 관계 탐색 | Cypher pattern matching |
| 검색 인덱스의 운영 | lower(column)·pg_trgm 등 PostgreSQL 인덱스와 맞춘다 | Neo4j의 index 관리 영역 | PostgreSQL/AGE의 graph property index 영역 |
| 실무 파이프라인 | FTS·trigram·vector 후보 → CSR 확장 | graph property/vector 등 → Cypher 패턴 확장 | Cypher에서 property·pattern 결합 |
pgGraph에서 특히 중요한 점: graph.search()는 CSR 아티팩트를 검색하지 않는다. 원본 행을 SQL로 찾아 확인한 뒤 그래프 탐색을 이어 붙인다.
3단계: 검색과 관계 탐색은 서로 다른 길을 걷는다
%%{init: {"flowchart": {"htmlLabels": false, "useMaxWidth": true}} }%%
flowchart LR
Q["사용자 질의\n'Alice와 관련된 주문'"]
S["graph.search()\nname = 'Alice'"]
PG["원본 PostgreSQL\nSQL predicate + index\nFTS / pg_trgm / vector"]
R["검증된 좌표\n(customers, c-1)"]
X["ResolutionIndex\n→ node_idx"]
G["CSR traversal\nBFS / DFS / shortest path"]
H["선택적 hydration\n원본 행 JSONB"]
Q --> S --> PG --> R --> X --> G --> H
%%{init: {"flowchart": {"htmlLabels": false, "useMaxWidth": true}} }%%
flowchart LR
Q["사용자 질의\n'Alice와 관련된 주문'"]
S["graph.search()\nname = 'Alice'"]
PG["원본 PostgreSQL\nSQL predicate + index\nFTS / pg_trgm / vector"]
R["검증된 좌표\n(customers, c-1)"]
X["ResolutionIndex\n→ node_idx"]
G["CSR traversal\nBFS / DFS / shortest path"]
H["선택적 hydration\n원본 행 JSONB"]
Q --> S --> PG --> R --> X --> G --> H
graph.search()는 원본 행을 찾고, graph.traverse()는 연결 구조를 확장한다. 대량 문자열 검색은 PostgreSQL 표현식·trigram 인덱스로, 관계 확장은 CSR로 각각 최적화한다.
쓰기와 최신성: pgGraph의 트레이드오프는 의도적이다
pgGraph의 방식
- base CSR은 빠른 읽기를 위해 불변이다.
manual은 재빌드, 기본trigger는 변경을_sync_log에 기록한다.mutable_overlay는 동기화된 edge 변경을 projection segment로 읽기 경로에 겹친다.- 누적된 변경은
maintenance()/vacuum()이 원본에서 새 base를 만들어 반영한다.
선택에 미치는 영향
- 읽기 우세 + bounded traversal: build 비용이 있어도 CSR의 이점이 크다.
- 고빈도 그래프 쓰기 + 즉시 임의 패턴 질의: graph-native 쓰기 모델을 먼저 검토할 만하다.
- 기존 Postgres 트랜잭션 경계 유지: pgGraph의 PostgreSQL-first 모델이 단순하다.
무엇을 선택할지: 요구사항을 저장 모델에 매핑하자
| 요구사항 | 우선 검토 | 이유 |
|---|---|---|
| 이미 Postgres에 데이터와 권한 모델이 있고, 1~수 hop 탐색이 반복된다 | pgGraph | 원본을 이동하지 않고 CSR projection을 추가한다 |
| 그래프가 도메인의 정본이며, Cypher 중심 개발·도구 생태계가 필요하다 | Neo4j | graph-native 저장·질의 모델을 중심에 둔다 |
| Postgres 프로세스 안에서 vertex/edge property graph를 만들고 openCypher를 쓴다 | Apache AGE | SQL + openCypher multi-model 접근을 제공한다 |
| 문자열/전문/벡터 후보를 관계로 재랭킹하거나 확장한다 | pgGraph + PostgreSQL 검색 도구 | 후보 검색과 관계 탐색을 각자 강한 엔진에 맡긴다 |
근거와 읽을거리
| 제품 | 확인한 근거 | 이 장표에서 사용한 사실 |
|---|---|---|
| pgGraph | README, Architecture, Querying, Roadmap | 원본 테이블 정본, CSR·mmap, SQL 검색, bounded GQL/Cypher 호환, pgVector 보조 랭킹 탐색 방향 |
| RAG / vector | pgvector README, Neo4j GraphRAG KG Builder, OpenAI vector store API | 벡터 열·ANN 검색, 텍스트 분할기와 임베더의 분리, 청크 전략이 별도 ingestion 정책이라는 근거 |
| Neo4j | Store formats, Cypher introduction | graph store files·data co-location, Cypher query language |
| Apache AGE | 공식 README | PostgreSQL extension, SQL/openCypher, graph schema·label table, agtype |
'관심있는 주제 > 탐구 노트' 카테고리의 다른 글
| AI 시대의 개발자는 왜 Latency Numbers를 알아야 하는가 (0) | 2026.08.17 |
|---|---|
| LangGraph를 쓰며 생긴 의문: Graph Engineering은 워크플로우 엔진과 무엇이 다른가 (0) | 2026.08.12 |
| 일정을 못 잡는 개발자를 위한 WBS 실전 가이드 (1) | 2026.07.25 |
| 지식 자산화와 OKF: 개인 노트에서 기업 AI 지식 인프라까지 (0) | 2026.07.19 |
| Ilya Sutskever가 추천했다고 알려진 30 Papers로 보는 AI의 큰 그림 (0) | 2026.07.12 |
