ONTOLOGICAL DB

Agentic AI를 위한 온톨로지 데이터베이스

에이전트가 일하는 데 필요한 것이 데이터베이스 하나에 있습니다.

온톨로지를 OWL · Cypher · TypeQL 세 언어로 다루고, 다른 시스템의 데이터를 복사 없이 그래프로 묻고, 어느 시점의 사실이든 같은 질의로 되돌려 보고, 권한은 순회 도중에도 지켜집니다. 새 서버가 아니라 PostgreSQL 확장 하나입니다 — CREATE EXTENSION ontological.

3.96 ms
2홉 순회
50,000 노드 / 974,936 엣지
270 ms
8홉 순회
같은 질문이 이전엔 끝나지 않았습니다
5
같은 기계에서 직접 빌드해 검증한 엔진
Neo4j · AGE · TypeDB · pgGraph · 재귀 CTE
0
정답 불일치 · 무결성 위반
7개 시스템 전원 동일 답변

PostgreSQL 16.14 · Neo4j 5.26 · TypeDB 3.12 · pgGraph 1.1.0 · 같은 기계 · 각 시스템에 각자의 문서가 요구하는 인덱스와 자원 한도를 부여 · 중앙값. 모든 시스템의 답이 일치하는지 먼저 확인하고, 일치할 때만 시간을 보고합니다.
8홉 270 ms는 우리 자신의 이전 동작과의 비교입니다 — 다른 제품이 아니라. 왜 끝나지 않았고 무엇을 바꿔서 끝나게 되었는지는 깊은 홉 지름에 전부 적었습니다.

01 · 기능

일곱 가지를 데이터베이스가 직접 합니다. 각각 실행으로 확인한 튜토리얼이 있습니다.

아래의 문장은 모두 튜토리얼에서 실제로 실행해 보인 것입니다. 튜토리얼의 예제는 릴리스 때마다 회귀 테스트로 다시 돕니다. 되지 않는 것은 기능마다 "아직" 줄에 적었습니다.

01 · OWL · Cypher · TypeQL · working · partial

온톨로지는 세 언어로 들어오고, 그래프는 하나입니다

OWL/Turtle 파일이 별도의 트리플 저장소가 아니라 데이터베이스의 타입 계층이 됩니다. rdfs:subClassOf는 상속이, domain/range는 쓰기 시점에 검사되는 제약이 됩니다. 그 위에서 같은 그래프가 Cypher와 TypeQL에 답합니다.

  • 라벨 하나에 계층 전체가 답합니다. MATCH (v:Vehicle)와 match $v isa Vehicle이 하위 타입까지 같은 수를 돌려줍니다
  • 한 언어로 넣은 것을 다른 언어가 바로 읽고, 두 언어가 트랜잭션 하나와 감사 로그 하나를 공유합니다
  • Neo4j 드라이버는 주소만 바꿔 Bolt로 접속합니다. Neo4j의 MCP 서버가 수정 없이 동작합니다

아직 — SPARQL과 SHACL은 없습니다. TypeQL 사용자 정의 함수는 평가하지 않습니다. RDF 덤프는 무손실 왕복이 아닙니다.

한 그래프, 세 입구fleet
-- OWL: 온톨로지가 타입 계층이 된다
SELECT og_load_rdf('fleet', $rdf$
  :Truck         rdfs:subClassOf :Vehicle .
  :ElectricTruck rdfs:subClassOf :Truck .
$rdf$);
// Cypher: 배차 앱
MATCH (v:Vehicle) RETURN count(v)      // 트럭·전기트럭 포함

# TypeQL: 정비팀
match $v isa Vehicle;                 # 같은 수
02 · Data Fabric · proposed

다른 시스템의 데이터를 복사 없이 그래프로 묻습니다

ERP는 PostgreSQL에, 물류는 MySQL에 있어도 됩니다. 외부 테이블을 노드로, 시스템 사이의 키 대응을 관계로 한 번 선언하면 사이퍼 패턴 하나가 두 DB를 건넙니다. 조건과 같은 DB 안의 조인은 원천으로 내려가고, 넘어오는 것은 필요한 행뿐입니다.

  • 그래프에 저장된 지식과 원천의 행을 한 질의에서 섞습니다. 순수한 연합 질의 엔진이 못 하는 일입니다
  • EXPLAIN FABRIC이 각 원천에 실제로 보낸 SQL과 넘어온 행 수를 보여 줍니다
  • 원천은 읽기 전용입니다. 원천 계정에 쓰기 권한이 있어도 엔진이 먼저 거부합니다

아직 — 제안 단계(ADR-029)입니다. 지원 원천은 PostgreSQL과 MySQL이고, 가상 관계의 가변 길이 순회와 원천 쓰기는 없습니다.

선언 셋, 질의 하나scm
CREATE DATASOURCE wms WITH ENGINE = 'mysql',
  PARAMETERS = {host: '…', database: 'wms', password: $pw}
CREATE VIRTUAL NODE Shipment FROM wms.shipment KEY shipment_no
CREATE VIRTUAL RELATIONSHIP SHIPPED_BY
  FROM Shipment(vendor_code) TO Supplier(supplier_id)
// 리스크는 그래프, 공급사는 ERP, 선적은 WMS
MATCH (:Region {name: 'East Asia'})<-[:IN_REGION]-(e:RiskEvent)
      -[:AFFECTS]->(s:Supplier)<-[:SHIPPED_BY]-(sh:Shipment)
WHERE sh.ship_status <> 'DELIVERED'
RETURN e.title, s.name, sh.shipment_no
03 · Time-aware · Bitemporal · working · partial

그때 실제로 어땠는지와 그때 무엇을 믿었는지를 따로 묻습니다

타입마다 유효 시간과 시스템 시간을 함께 기록합니다. 세상이 바뀐 일(인상)과 잘못 적은 값을 고친 일(정정)이 구별되므로, "2023년 6월의 실제 연봉"과 "감사 보고서를 낼 때 우리가 알던 2023년 6월의 연봉"이 서로 다른 답으로 나옵니다.

  • 질의는 고치지 않습니다. 세션 설정만 바꾸면 같은 사이퍼가 다른 시점에 답하고, 컴파일된 SQL은 같은 문장입니다
  • 2030년부터 적용될 변경은 그날이 되기 전까지 오늘의 답을 바꾸지 않습니다
  • 시점을 답할 수 없는 질의는 오늘 데이터로 답하는 대신 오류로 거부합니다

아직 — 가변 길이 경로·라벨 없는 스캔·TypeQL은 시점 질의를 거부합니다. 이력 보존 정책은 없습니다. 미래 날짜 변경 뒤에는 패턴에 라벨을 붙여야 합니다.

같은 질의, 다른 시점corp
MATCH (p:Person {name: 'ann'}) RETURN p.salary
SET og.valid_at  = '2023-06-01';          → 250   그때 실제로
SET og.system_at = '<보고서를 낸 시각>';  → 200   그때 우리가 알던
RESET …;                                   → 현재
04 · Built-in security · working · partial

권한은 애플리케이션이 아니라 데이터베이스의 것입니다

사용자는 PostgreSQL 역할이고, Neo4j 드라이버로 접속해도 그 역할로 인증됩니다. 타입에 행 수준 보안을 걸면 그 타입을 지나는 패턴에서 보이지 않는 노드는 조인에서 빠지고, 그 노드를 지나는 경로 전체가 사라집니다.

  • 쓰기 권한은 타입·연산·속성 단위이고 실제로는 컬럼 GRANT입니다. 사이퍼든 원시 SQL이든 Bolt든 같은 거부를 받습니다
  • 읽기만 가능한 AI 에이전트에게 파라미터 계약과 전제 조건이 붙은 행위(Action)만 실행하게 할 수 있습니다
  • 누가, 누구를 대신해, 왜 했는지가 감사 로그에 남고 쓰기 권한자도 고칠 수 없습니다

아직 — 노드·간선 레지스트리의 읽기 제한은 아직 없어, 연결이 존재한다는 사실까지 숨기지는 못합니다. 라벨 없는 패턴에서는 경로가 남습니다.

같은 질의, 다른 역할hospital
SELECT og_enable_rls('hospital', 'Patient',
  $$p_ward <> '정신건강' OR current_user = 'doctor'$$);
SELECT og_grant_write('nurse', 'hospital', 'Patient',
  ARRAY['set'], ARRAY['room']);
MATCH (d:Doctor)<-[:TREATED_BY]-(p:Patient)-[:PRESCRIBED]->(m:Drug)
RETURN d.name, m.name

의사   → 경로 3개
간호사 → 경로 2개   가려진 환자를 지나는 경로가 없다
05 · Agent-native · working · partial

에이전트가 스스로 탐색하고, 틀리면 고치고, 실행 전에 가늠합니다

처음 보는 그래프에서 에이전트가 필요로 하는 것을 데이터베이스가 직접 줍니다. 프롬프트에 넣을 크기로 줄인 스키마, 실행하지 않고 받는 오류 코드와 위치, 틀리지 않았는데 0행인 이유, 실행 전의 추정입니다.

  • og_schema가 관계의 어느 쪽이 출발이고 도착인지까지 돌려주고, 예산을 주면 줄였다는 사실과 함께 줄여서 줍니다
  • og_diagnose_empty가 패턴을 한 요소씩 세어 어디서 끊겼는지, 또는 WHERE가 전부 지웠는지를 짚습니다
  • og_estimate가 관계 없이 나열된 노드의 카티션 곱을 실행 전에 경고합니다. 성공한 질의는 누가 왜 보냈는지와 함께 감사 로그에 남습니다

아직 — 없는 라벨은 오류가 아니라 0행입니다. 스키마 예산은 토큰이 아니라 타입 개수로 줄입니다. 역할 제한 중 max_rows와 read_only는 동작하지 않으니 읽기 전용은 PostgreSQL 역할로 겁니다.

에이전트의 한 바퀴shop
SELECT og_schema('shop', 4000);                -- 읽는다
SELECT og_explain_error('shop', $$MATCH …$$);  -- 검사한다
SELECT og_estimate('shop', $$MATCH …$$);       -- 가늠한다
SELECT * FROM og_cypher('shop', $$MATCH …$$); -- 실행한다
SELECT og_diagnose_empty('shop', $$MATCH …$$); -- 0행이면 이유를 묻는다

Studio:  :schema   :explain <cypher>   :why <cypher>
06 · Vector · Hybrid · working

임베딩은 노드뿐 아니라 관계에도 붙습니다

임베딩은 별도 벡터 저장소가 아니라 프로퍼티입니다. 그래서 관계에도 같은 방식으로 선언됩니다. "이 문의가 왜 이 문서로 해결됐는가" 같은 관계의 맥락을 의미로 찾을 수 있습니다.

  • 유사도 정렬이 그래프 패턴·프로퍼티 조건과 함께 SQL 한 문장이 됩니다. 조건은 top-k를 자르기 전에 적용됩니다
  • Neo4j와 같은 CREATE VECTOR INDEX와 db.index.vector.queryNodes를 받습니다
  • 하이브리드 검색은 벡터 순위와 그래프 거리를 RRF로 합치고 두 점수를 따로 돌려줍니다

아직 — 임베딩 생성은 외부 모델의 몫입니다. 전문 검색과 벡터의 결합, 경로 임베딩은 없습니다. 하이브리드는 노드 타입만 됩니다.

관계를 의미로 찾는다support
CREATE VECTOR INDEX article_emb FOR (a:Article) ON (a.emb)
OPTIONS {indexConfig: {`vector.dimensions`: 3,
         `vector.similarity_function`: 'cosine'}}
MATCH (t:Ticket)-[r:RESOLVED_BY]->(a:Article)-[:ABOUT]->(p:Product {name: '계정'})
WHERE a.status = 'published'
RETURN t.title, a.title, r.note,
       vector.similarity(r.note_emb, $q) AS score
ORDER BY score DESC LIMIT 5
07 · Schema evolution · working · partial

데이터가 들어 있는 그래프의 스키마를 서비스 중에 바꿉니다

스키마 변경은 plan → expand → migrate → validate → contract를 거치는 기록된 객체입니다. plan은 아무것도 바꾸지 않고 변환할 수 없는 값이 몇 개이고 어느 노드인지 먼저 알려 줍니다.

  • 타입 변경 중에도 읽기는 계속 답하고, 새 타입에 맞지 않는 쓰기는 사이퍼든 원시 SQL이든 그 순간부터 거부됩니다
  • 기존 행은 배치로 옮기고 중단한 곳에서 이어 갑니다. 이름 변경과 상위 타입 추가는 행을 한 줄도 다시 쓰지 않습니다
  • 모든 변경이 스키마 버전과 함께 이력으로 남고, 지난 버전의 카탈로그를 조회할 수 있습니다

아직 — 타입 분할·병합은 없습니다. TypeQL의 redefine은 이 절차와 연결되지 않습니다. 유니크 제약은 계층 전체가 아니라 테이블 단위입니다.

계획하고, 옮기고, 닫는다hr
SELECT og_schema_plan('hr', '{"kind": "alter_property_type",
  "type": "Person", "property": "age", "to": "int"}');
-- report: 5행 중 1개 값이 변환되지 않음 · 노드 id
og_schema_expand  → 이중 쓰기 시작, 읽기는 옛 컬럼
og_schema_migrate → 배치로, 이어서
og_schema_validate → 막히는 행의 id
og_schema_contract → 새 타입으로 전환
02 · 영상

2~3분씩, 빈 데이터베이스에서 시작해 실제로 실행한 화면입니다.

연출한 화면이 아닙니다. 영상 속의 문장은 녹화 중에 Studio 편집기에 입력해 실행한 것이고, 화면이 자막의 내용과 다르면 녹화가 중단되도록 장면마다 검사했습니다. 설명은 자막으로 들어 있습니다.

온톨로지는 세 언어로 들어오고, 그래프는 하나입니다 OWL로 들어온 온톨로지를 Cypher와 TypeQL이 함께 읽고 씁니다 튜토리얼로 따라 하기 →
다른 시스템의 데이터를 복사 없이 그래프로 묻습니다 PostgreSQL과 MySQL의 데이터를 복사 없이 한 패턴으로 튜토리얼로 따라 하기 →
그때 실제로 어땠는지와 그때 무엇을 믿었는지를 따로 묻습니다 같은 질의를 다섯 번, 시점만 바꿔 묻습니다 튜토리얼로 따라 하기 →
권한은 애플리케이션이 아니라 데이터베이스의 것입니다 같은 질의가 역할에 따라 다른 경로를 돌려줍니다 튜토리얼로 따라 하기 →
에이전트가 스스로 탐색하고, 틀리면 고치고, 실행 전에 가늠합니다 에이전트가 스키마를 읽고, 틀리고, 고치는 과정 튜토리얼로 따라 하기 →
임베딩은 노드뿐 아니라 관계에도 붙습니다 문서 검색이 놓친 답을 관계 검색이 찾습니다 튜토리얼로 따라 하기 →
데이터가 들어 있는 그래프의 스키마를 서비스 중에 바꿉니다 운영 중인 그래프에서 잘못된 타입을 고칩니다 튜토리얼로 따라 하기 →
03 · 제품의 핵심

Cypher가 들어가고, 플래너가 최적화하는 SQL이 나옵니다.

Apache AGE는 Cypher를 cypher('…') 함수 인자인 문자열로 받습니다. 그 경계에서 조인 순서·통계·파라미터 바인딩·플랜 캐시가 모두 사라집니다. Ontological은 손으로 짠 재귀 하향 파서로 Cypher를 파싱해 평범한 SQL로 컴파일합니다. 그래서 PostgreSQL 옵티마이저가 패턴 내부를 봅니다. 아래 버튼으로 직접 확인하세요 — 이 SQL은 og_cypher_sql()이 실제로 뱉는 문자열입니다.

$
Cypher

        
결과
5 rows 8 nodes · 6 relationships planner: 실 컬럼 술어 · 인덱스 사용 가능

위 SQL에서 :Work는 이미 구체 타입 테이블 목록으로 해소되어 있고, p_born > 1960은 인덱싱 가능한 진짜 컬럼 술어이며, 순회는 플래너가 비용을 매기는 평범한 조인입니다. 그대로 EXPLAIN에 붙여 넣어도 되고, 당신의 SQL 쿼리 안에 그대로 붙여 써도 됩니다.

04 · 특장점

여섯 가지가 구조적으로 다릅니다. 상수배가 아니라 설계가 다릅니다.

아래 항목은 기능 목록이 아니라 저장 구조와 컴파일 경로의 귀결입니다. 그래서 규모가 커질수록 격차가 벌어집니다.

Storage

인접 리스트를 CSR 세그먼트로 묶습니다

한 노드의 이웃 최대 256개를 정렬된 int8[] 두 개에 담아 하나의 힙 튜플에 넣습니다. 4 KB — 8 KB 페이지 하나에 들어갑니다. 차수 256 이하 노드 확장은 튜플 읽기 한 번입니다. 엣지 하나당 한 행 + B-tree 탐색 + 랜덤 힙 페치를 반복하는 방식과는 접근 횟수 자체가 다릅니다.

og_adj (src, etype, dir, seq, nbr int8[], eid int8[]) STORAGE MAIN — TOAST 밖으로 밀려나 지역성을 잃지 않도록
Properties

속성은 JSON 블롭이 아니라 진짜 타입 컬럼입니다

타입 카탈로그가 타입마다 테이블을 생성하고, 선언된 속성은 실제 컬럼이 됩니다. 컬럼 프루닝, 컬럼별 통계, CHECK 제약, 유니크 인덱스, HNSW 인덱스가 공짜로 따라옵니다.

선언하지 않은 속성은 __ext jsonb로 떨어져 스키마리스 사용도 그대로 됩니다 — 다만 빠른 경로를 받지 못합니다. 올바른 인센티브입니다.

og_data.n_4 (id int8 PK, p_model text, p_year int4, __ext jsonb)
Type system

상속이 실제로 상속됩니다

MATCH (v:Vehicle) 하나로 Car와 EV가 함께 답합니다. 두 번째 라벨을 손으로 관리하는 사람은 없습니다.

하위 타입 판정은 구간 라벨(interval label) 범위 비교 한 번입니다 — 재귀 순회가 아닙니다. 계층이 얼마나 깊든 넓든 비용은 같습니다. 다중 상속은 경로마다 라벨 행을 하나씩 갖고, 판정은 여전히 범위 비교입니다.

Vehicle.lft ≤ EV.lft AND EV.rgt ≤ Vehicle.rgt 회귀 테스트가 상위타입 질의 플랜에 Recursive 노드가 없음을 단언합니다
Roles

관계에 역할(role)과 제약이 있습니다

문자열 타입 관계로는 표현할 수 없는 것 — 관계 타입이 이름 붙은 참여자 슬롯을 선언하고, 데이터베이스가 직접 거부합니다. n항 관계도 ordinal 2+로 표현됩니다.

transitive / symmetric / reflexive / inverse 특성도 선언합니다.

ERROR: role 'owner' of relation 'OWNS' requires a 'Person', got 'EV'
Vectors

임베딩이 관계 위에도 있습니다

임베딩은 vector(N) 타입 속성 = 타입 테이블의 컬럼입니다. 엣지 타입에도 노드 타입과 정확히 동일하게 붙습니다.

그 한 가지 결정에서 전부 따라옵니다 — HNSW는 그 컬럼의 평범한 인덱스이고, MVCC와 RLS가 그대로 적용되며, 그래프 술어가 ANN 인덱스와 같은 릴레이션에 떨어지므로 필터 푸시다운이 구조적으로 보장됩니다. 사후 필터가 숨을 곳이 없습니다.

og_add_embedding('kb','CITES','context_emb',1536,'cosine','note') og_hybrid_search(...) — 벡터 순위 + 그래프 근접도 RRF 융합
Interop

RLS가 순회 도중에 적용됩니다

컴파일된 질의는 평범한 테이블을 읽습니다. 그래서 호출자가 볼 수 없는 노드는 조인되지 않고, 그 노드를 지나는 모든 경로가 결과에서 사라집니다. 적용 코드가 한 줄도 없습니다 — 포크가 아니라 확장이기 때문에 생기는 배당금입니다.

기존 테이블은 og_map_table()로 복사 없이 노드 타입으로 노출하고, 결과는 og_cypher_json()을 PostgREST RPC로 그대로 씁니다.

og_enable_rls(graph, type, policy_expr) og_node_view · og_edge_view · og_type_view
04.1 · 구조 대조

Apache AGE와 무엇이 다른가

설계상의 차이는 이렇습니다. 다만 측정해 보면 관측된 비용은 거의 전부 「Cypher」 행에서 나옵니다 — 인덱스를 갖춘 1홉에서 AGE가 만지는 페이지 수는 우리와 거의 같고, 갈라지는 곳은 플래너가 패턴 내부를 못 보는 가변 길이 경로입니다. 나머지 행은 설계의 차이이지, 이 워크로드에서 측정된 격차의 원인이 아닙니다. 아래 벤치마크를 먼저 읽으십시오.

Apache AGEOntological
인접(adjacency) 엣지 하나당 한 행 + B-tree차수 d → d회 인덱스 탐색 + d회 랜덤 힙 페치 CSR 세그먼트힙 튜플 하나에 이웃 ≤256개
속성 agtype JSON 블롭속성 하나 읽을 때마다 JSON 파싱 타입 카탈로그가 생성한 실 타입 컬럼통계 · 인덱스 · 제약 모두 적용
식별자 graphid int8 비트필드 [shard:9][type:18][local:36] — 타입 판별이 시프트 + 마스크
Cypher cypher() 함수의 문자열 인자그 경계에서 플래너가 눈을 감습니다 파싱 → SQL로 컴파일플래너가 조인 순서 · 스캔 방식 · 병렬성을 선택
상속 다중 라벨 수작업 관리 구간 인덱스 타입 계층상수 시간 하위타입 판정
벡터 — pgvector — 노드 및 관계HNSW · 필터 푸시다운 · 하이브리드 RRF
05 · 벤치마크

수치는 그 근거만큼만 값어치가 있습니다. 그래서 하네스를 우리에게 적대적으로 짰습니다.

동일 데이터 · 동일 기계. 결정론적 엣지 생성기 하나가 그래프를 만들고, 각 시스템은 자기 벌크 경로로 적재합니다. 기계 간 비교는 하지 않습니다. 모든 시스템이 같은 논리 질의를 돌리고, 답이 다르면 하네스가 불일치를 출력한 뒤 그 측정치를 무효(void) 처리합니다. 이건 형식적인 절차가 아닙니다 — 개발 중 실제 불일치를 잡아냈고, 그 이전 수치는 전부 무의미했습니다.

각 시스템에는 그 시스템 문서가 만들라고 하는 인덱스를 전부 만들어 줍니다. AGE는 설치 상태 그대로면 엣지 끝점에도 속성에도 인덱스가 없습니다 — 그 상태로 재면 데이터베이스가 아니라 설정 실수를 재는 것입니다. 권장 인덱스를 붙이자 AGE의 1홉은 29.6 ms에서 1.6 ms로, 속성 스캔은 12.1 ms에서 0.27 ms로 내려갔습니다. 같은 기준을 우리에게도 적용해 og_create_index를 붙였습니다.

1홉 — 이웃 확장

ontological
2.10 ms
└ storage path
0.31 ms
neo4j 5
0.74 ms
apache age
2.15 ms
typedb 3
0.46 ms
recursive CTE
0.24 ms

2홉

ontological
3.96 ms
└ storage path
0.50 ms
neo4j 5
0.92 ms
apache age
799.50 ms
└ 고정 길이로
7.99 ms
typedb 3
1.48 ms
recursive CTE
0.36 ms

3홉 — 우리가 Neo4j에 지는 지점

ontological
33.86 ms
└ storage path
4.33 ms
neo4j 5
2.99 ms
apache age
22,412 ms
└ 고정 길이로
34.63 ms
typedb 3
19.73 ms
recursive CTE
3.49 ms

속성 스캔

ontological
0.62 ms
└ storage path
0.24 ms
neo4j 5
0.68 ms
apache age
0.27 ms
typedb 3
0.52 ms
recursive CTE
0.19 ms

50,000 노드 / 974,936 엣지 · 평균 차수 20 · 중앙값 · 7개 시스템 정답 전원 일치 · 무결성 위반 0건. 「└ 고정 길이로」는 같은 질문을 *1..n 없이 고정 길이 패턴의 합집합으로 쓴 AGE입니다 — TypeQL에는 가변 길이 경로 연산자가 없어 TypeDB가 강제로 쓰는 방식이며, 그래서 AGE에도 같은 기회를 줬습니다. 1홉과 속성 스캔은 두 방식이 같은 질의라 한 줄만 싣습니다.
Neo4j와 TypeDB는 별도 서버라 클라이언트 왕복 비용이 다릅니다. 빈 질의 한 번의 비용이 psql 0.19 ms, TypeDB 0.37 ms, Neo4j 0.79 ms — Neo4j의 1·2홉 수치는 대부분 이 프로토콜 바닥값이며, 그만큼 엔진 시간은 이 방법으로 측정되지 않습니다.

05.1 · 논리 페이지

지연 시간은 캐시 상태를 따라 움직입니다. 페이지 접근 횟수는 저장 구조의 직접 함수입니다.

그래서 저장 구조에 대한 주장은 이 표로 검증하는 것이 정직합니다.

질의ontologicalstorage pathapache ageage · 고정 길이recursive CTE
1홉1,7423891,7071,7078
2홉3,3401,33548,50815,481420
3홉32,0046,51048,52397,7218,898
속성 스캔1,174835356

단위: 논리 페이지 접근(buffers). PostgreSQL 안에 사는 시스템에만 존재하는 계측이라 Neo4j · TypeDB 칸은 없습니다 — 비슷해 보이는 다른 숫자로 채우지 않았습니다.
1홉에서 AGE는 1,707 페이지, 우리는 1,742 페이지를 만집니다 — 거의 같습니다. AGE의 저장 구조가 느린 것이 아닙니다. 갈라지는 곳은 *1..n입니다: 2홉·3홉에서 48,508 / 48,523 페이지, 즉 질문이 달라져도 읽는 양이 그대로입니다. 엣지 테이블(약 7,200 페이지)을 매번 통째로, 그것도 여러 번 훑는다는 뜻입니다. 순회가 아니라 스캔의 서명입니다.

05.2 · 깊은 홉

4홉을 넘기면 우리가 하던 일은 순회가 아니었습니다. 경로 열거였습니다.

Cypher의 가변 길이 매치는 경로마다 한 행을 냅니다. 그게 규격입니다 — RETURN count(b)는 걸어간 경로를 세고, count(DISTINCT b)는 도달한 노드를 셉니다. 다른 질문입니다. 문제는 우리가 두 번째 질문에도 첫 번째 답을 만들어 주고 있었다는 것입니다. 평균 차수 20인 그래프에서 그 비용은 홉마다 20배씩 곱해집니다.

a에서 3홉 안의 노드를 세는 두 방법 만드는 행 6홉에서
경로 열거 — 서로 다른 엣지 순서를 전부 나열
사이클 방지를 위해 행마다 경로 배열을 복사하고 선형 검사
degreek 약 6,700만 행
도달성 — 방문 집합을 들고 노드를 한 번씩만 확장
힙 튜플도 MVCC도 RLS도 그대로. 나가는 것은 경로뿐
≤ |V| 50,000 행

답은 5만 노드입니다. 그걸 알아내려고 6,700만 개의 경로를 만들고 있었습니다. 이건 AGE의 *1..n이 보이는 재스캔과는 다른 문제입니다 — 우리 함수는 그래프를 실제로 걸었습니다. 다만 질문의 형태에 비례하는 일을 했지, 답의 크기에 비례하는 일을 하지 않았습니다.

05.3 · 무엇을 바꿨나

저장 구조는 그대로입니다. 컴파일러가 질문을 다시 읽을 뿐입니다.

새 인덱스도, 새 테이블도, 새 문법도 없습니다. Cypher가 뜻하는 바도 바뀌지 않았습니다. 컴파일러가 경로를 관측할 수 있는 질의인지 판정하고, 관측할 수 없을 때만 경로 열거 대신 방문 집합 BFS(og_reach)를 내보냅니다.

질의컴파일 결과이유
-[:K*1..8]->(b) RETURN count(DISTINCT b) og_reach중복 행이 답을 바꿀 수 없음
-[:K*1..8]->(b) RETURN DISTINCT b.name og_reach같음
-[:K*1..8]->(b) RETURN count(b) og_vlp경로 수를 세는 질문 — 답이 달라짐
MATCH p = (a)-[:K*1..8]->(b) og_vlp경로 변수를 묶음
-[e:K*1..8]->(b) RETURN count(DISTINCT b) og_vlp관계 변수를 묶음
-[:K*1..2]->(b) RETURN count(DISTINCT b) og_vlp얕아서 바꾸면 손해 — 아래 설명

마지막 줄이 이 변경에서 가장 많이 배운 지점입니다. og_reach는 Rust 집합 반환 함수라 og_vlp(SQL 함수)와 달리 주변 플랜에 인라인되지 않고 레벨마다 SPI 비용을 냅니다. 첫 구현은 조건 없이 바꿨고, 2홉에서 1.9 ms → 4.9 ms로 더 느려졌습니다. 그래서 교차점을 계산합니다 — Σ degreei > |V|, 즉 걸어야 할 경로 수가 찾을 노드 수를 넘는 지점. 두 값 모두 플래너 통계에서 읽으므로 스캔 한 번 하지 않습니다. 5만 노드/차수 20에서는 3홉에 「아니오」, 4홉에 「예」가 나오고, 측정된 교차점과 정확히 일치합니다.

05.4 · 깊은 홉 벤치마크

Neo4j · Apache AGE · pgGraph를 직접 빌드해서 같은 서버에 올렸습니다.

pgGraph 1.1.0은 소스에서 빌드해 이 확장과 같은 PostgreSQL 16에 설치했습니다. AGE 1.5.0도 소스에서 빌드했습니다 — 공식 이미지가 x86-64뿐이라 arm64 호스트에서 에뮬레이션으로 재는 건 허수아비를 세우는 일이기 때문입니다. 질문은 시작 노드를 제외한, k홉 안의 서로 다른 노드 수 — 다섯 시스템이 모두 정확히 같은 형태로 쓸 수 있는 유일한 형태이고, 끝난 모든 칸에서 답이 일치했습니다.

3홉

ontological
29.06 ms
└ storage path
1.06 ms
neo4j 5
4.66 ms
pggraph 1.1
237.87 ms
apache age
13,697 ms
recursive CTE
2.17 ms

6홉 — AGE는 4홉에 도달하지 못합니다

ontological
267.75 ms
└ storage path
67.10 ms
neo4j 5
168.82 ms
pggraph 1.1
2,457.50 ms
└ 순회만
42.76 ms
recursive CTE
374.39 ms

8홉

ontological
270.31 ms
└ storage path
70.51 ms
neo4j 5
151.67 ms
pggraph 1.1
2,540.89 ms
└ 순회만
42.46 ms
recursive CTE
788.02 ms

50,000 노드 / 974,936 엣지 · 평균 차수 20 · 시작점 5개 중앙값 · 문장당 60초 상한 · 끝난 모든 칸에서 7개 시스템 정답 일치.
이 그래프는 5홉이면 전체가 덮입니다. 그래서 위의 평평한 곡선은 「깊이에 강하다」가 아니라 「프론티어가 더 자랄 곳이 없다」는 뜻입니다. 깊이 자체를 묻는 측정은 지름이 큰 그래프에 따로 있고, 거기서는 결론이 달라집니다.
「pggraph └ 순회만」을 반드시 함께 읽으십시오. 같은 순회를 max_rows := 10으로 요청하면 pgGraph는 6홉 42.76 ms, 8홉 42.46 ms — 20홉까지 평평합니다. 자기 아키텍처가 주장하는 그대로입니다. 2.4초는 도달 노드 5만 개를 path·edge_path·node jsonb를 포함한 11컬럼 행으로 내보내는 비용(행당 약 48 µs)이고, hydrate := false와 무관합니다. pgGraph의 max_rows 기본값은 1000이고 공개 벤치마크도 depth 1–2의 바운드된 순회입니다 — 바운드된 질문에는 빠릅니다 (shortest_path 4홉 8.0 ms). 그 분해 없이 2.4초만 싣는 것이 바로 이 사이트가 계속 경계하는 허수아비입니다.
AGE 칸은 약 90 ms 과대평가되어 있습니다. 질문을 통일하려 붙인 b.val <> a.val이 agtype에서는 행마다 JSON 추출이라, 1홉이 1.44 ms에서 94 ms가 됩니다(우리는 1.47→1.52, Neo4j는 1.15→0.96). 빼도 3홉은 13.6초 대 1.06 ms입니다. 그리고 AGE를 고정 길이 깊이로 전개하면 5·6홉에서 백엔드가 커널에 OOM-kill 당해 postmaster가 재시작합니다 — 이 사이트가 권하던 회피책이 100만 엣지에서 4홉을 넘기면 무너진다는 뜻입니다.

05.5 · 지름이 큰 그래프

10만 홉을 걸어봤습니다. 그리고 결론 하나를 정정해야 합니다.

랜덤 그래프에서 「20홉」은 「8홉」과 같은 질문입니다 — 이미 다 덮였으니까요. 깊이가 의미를 가지려면 지름이 커야 합니다. 그래서 100만 노드짜리 두 형태를 만들었습니다: 체인(한 줄, 지름 100만, 차수 1 — 계보·이력·공급망)과 격자(1000×1000, 지름 1998, 차수 2 — 도로망·의존성 DAG).

체인 · 100만 노드 og_vlpog_reach_sql og_reach컴파일 CSR
100홉0.44 ms0.26 ms0.98 ms0.08 ms
1,000홉8.96 ms1.40 ms8.82 ms0.13 ms
10,000홉707.82 ms12.89 ms95.42 ms0.73 ms
100,000홉65,820 ms154.49 ms1,015.93 ms9.85 ms
격자 · 100만 노드 og_vlpog_reach_sql og_reach컴파일 CSR
10홉4.29 ms0.28 ms0.28 ms0.07 ms
20홉2,784.76 ms0.49 ms0.56 ms0.08 ms
100홉> 120초22.31 ms6.00 ms0.43 ms
1,000홉> 120초235.07 ms125.02 ms10.61 ms

10만 홉이 SQL로 154 ms, 컴파일 배열로 9.85 ms입니다. 같은 질문을 경로 열거로 풀면 65.8초입니다. 격자에서는 20홉이면 이미 끝납니다 — 거기서 터지는 건 노드가 아니라 경로 수(C(i+j, i))이기 때문입니다.
그리고 여기서 우리 설계의 약점이 드러났습니다. 체인에서 Rust BFS(og_reach)가 평범한 재귀 CTE에 6.6배 집니다(1,016 ms 대 154 ms). 레벨마다 SQL로 돌아가는데 체인은 프론티어가 노드 하나뿐이라 일은 없고 레벨만 10만 개이기 때문입니다. 루프 안에서 SPI 연결과 플랜을 매번 새로 만들던 것은 고쳐서 1,196 ms → 1,016 ms가 되었지만, 나머지는 SPI 경계를 넘는 비용 자체라 튜닝으로 사라지지 않습니다. 프론티어가 얇고 깊은 그래프라면 og_reach_sql()이 정답이고, 컴파일러는 아직 그걸 고르지 않습니다.

05.6 · 지름이 큰 그래프 · 엔진 비교

여기서는 Neo4j가 우리를 이깁니다.

25만 노드, 같은 정규화된 질문, 문장당 60초 상한, 끝난 모든 칸에서 정답 일치.

체인 · 25만 ontological└ 저장 경로 재귀 CTEapache age pggraphneo4j 5
10홉5.430.200.10216.151.657.55
100홉5.991.080.171,083.8644.567.30
1,000홉12.839.591.00>60초4,163.6613.01
10,000홉122.3091.017.86—2 GB 한도 초과10.86
격자 · 25만 ontological└ 저장 경로 재귀 CTEapache age pggraphneo4j 5
10홉6.360.370.1420,0685.531.33
20홉10.320.670.31>60초28.998.62
100홉27.565.584.98—2,975.774.33
500홉649.17146.34145.15—>60초61.35

단위 ms. 앞 절에서 「5홉 이후로 Neo4j보다 2.3배 빠르다」고 쓴 것은 그 그래프 형태에 한정된 이야기였습니다. 지름이 있는 그래프에서는 Neo4j가 앞섭니다 — 격자 500홉에서 61 ms 대 우리 146 ms, 체인 10,000홉에서 10.9 ms 대 우리 91 ms. 일반화하면 이렇습니다: 프론티어가 그래프를 덮는 형태에서는 일이 중복 제거라 우리 힙 BFS가 앞서고, 프론티어가 얇고 깊은 형태에서는 일이 홉당 지연이라 Neo4j의 포인터 추적과 PostgreSQL 자신의 재귀 CTE가 둘 다 우리보다 앞섭니다. 체인 10,000홉을 7.86 ms에 답하는 재귀 CTE가 표 전체에서 1등입니다.
pgGraph는 깊이에 대해 제곱으로 증가합니다. 체인에서 프론티어가 노드 하나뿐이고 답이 선형으로 느는데도, 깊이를 2배로 하면 시간이 4배가 됩니다 — 100홉 44 ms, 200홉 177 ms, 400홉 671 ms, 800홉 2,687 ms, 1,600홉 10,639 ms. 10,000홉에서는 6.3 GB를 요구하다 자기 서킷 브레이커에 막히고, graph.query_memory_mb를 최대치로 올려도 실효 상한은 약 2 GB에서 고정입니다. 행 출력 비용이 아닙니다 — 노드별 행을 만들지 않는 graph.neighborhood() 집계도 같은 질의에서 4,088 ms입니다. 다만 graph.shortest_path는 영향받지 않고 아주 빠릅니다 — 1,000홉 거리 8.8 ms, 3,000홉 거리 23.4 ms. (max_depth 기본값이 20이라 그 이상은 오류가 아니라 빈 결과를 냅니다. 이 사이트의 초안은 그걸 「경로를 못 찾았다」고 적었는데, 인자를 넘기지 않은 우리 잘못이었습니다.)

우리에게 불리한 수치 · 숨기지 않습니다

이 표에서 우리가 지는 칸들

  • 1홉부터 4홉까지 Neo4j가 우리보다 빠릅니다 (3홉 4.66 ms vs 29.06 ms). 랜덤 그래프에서는 5홉을 넘기면 우리 저장 경로가 2.3배 앞서지만 (67 ms vs 169 ms), Cypher 표면으로는 그래도 1.6배 뒤집니다. 그리고 지름이 있는 그래프에서는 그 우위조차 사라집니다 — 격자 500홉 61 ms 대 146 ms, 체인 10,000홉 10.9 ms 대 91 ms로 Neo4j가 앞섭니다. 03.5~03.6에 그대로 실었습니다.
  • 손으로 짠 재귀 CTE가 얕은 홉의 거의 모든 칸에서 우리를 이깁니다. 인덱스 걸린 테이블 두 개에 질의 엔진도, 타입 시스템도, Cypher도 없는 구성입니다 — 그런데 1홉에서 0.08 ms 대 0.11 ms로 더 빠릅니다. 아닌 척하면 나머지 수치의 값어치까지 떨어집니다. 이 표에 있는 무엇이든 다른 제품이 아니라 저 줄을 상대로 자기 존재를 증명해야 합니다.
    뒤집히는 지점은 6홉입니다 — 8홉에서 CTE 788 ms 대 우리 70.5 ms. 프론티어가 그래프를 덮을 때까지는 SQL 열 줄이 그래프 데이터베이스를 이기고, 그 다음부터는 아닙니다. 어느 쪽 제품의 헤드라인 수치보다 이게 더 쓸모 있는 사실입니다.
  • 첫 구현은 얕은 홉을 더 느리게 만들었습니다 — 2홉 1.9 ms → 4.9 ms. 도달성으로 바꾸는 것이 언제나 이득이라고 가정했기 때문이고, 측정이 아니었으면 그대로 배포했을 것입니다. 지금은 통계로 교차점을 계산해 그 아래에서는 바꾸지 않습니다.
  • AGE에 대한 격차는 저장 구조가 아니라 *1..n 하나입니다. 같은 질문을 고정 길이 패턴의 합집합으로 쓰면 AGE는 3홉을 22,412 ms가 아니라 34.63 ms에 답합니다. 그 칸을 기준으로 하면 우리는 662배 앞선 것이 아니라 거의 동률입니다. 이 사이트는 이전에 「AGE 대비 615배」라고 적었습니다. 그 수치는 엣지 끝점에 인덱스가 하나도 없는 AGE를 상대로 잰 것이었고, 게시하지 말았어야 했습니다.
  • Cypher 표면은 3홉에서 저장 경로 대비 7.8배를 더 씁니다 (33.86 ms vs 4.33 ms). 대부분 jsonb 프로젝션과 SPI 왕복입니다. 그것이 질의 표면의 현재 정직한 가격이고, 코드베이스에서 가장 명확한 최적화 대상입니다.
  • 벌크 적재는 AGE가 3.4배 빠릅니다 — 429,790 edges/sec vs 124,580 edges/sec. 인접 세그먼트를 양방향으로 채우는 비용을 적재 시점에 선불로 내고, 순회에서 회수하는 거래입니다.
  • 깊은 홉 비교는 균일 랜덤 그래프 한 종류, 한 규모입니다. 허브가 있는 power-law 그래프는 프론티어 기반 순회를 깨뜨리는 바로 그 형태인데 측정하지 않았습니다. 그리고 pgGraph는 이 문서가 다루는 질문으로 쟀습니다 — 자기 벤치마크가 쓰는 바운드된 순회가 아니라. 그래서 「순회만」 줄을 함께 싣습니다.
  • 아직 돌리지 않은 것들: LDBC SNB(생성기 미연결), openCypher TCK 통과율 추적, 동시성(모든 질의를 단독으로 돌렸습니다), 장애 주입 및 장기 스트레스, TypeDB 순회를 재귀 함수로 다시 쓴 변형, 측정 중 쓰기. 이건 하네스의 공백이지, 돌려놓고 뺀 결과가 아닙니다.
# 전부 직접 재현하십시오
docker run -d --name bench-neo4j  -p 27687:7687 -e NEO4J_AUTH=neo4j/benchpass123 neo4j:5
docker run -d --name bench-typedb -p 21729:1729 typedb/typedb:latest
pip install neo4j typedb-driver

# 03 · 기존 워크로드 (1·2·3홉, 경로 의미 그대로)
python3 bench/harness.py --scale 50000 --degree 20 --runs 8

# 03.4 · 깊은 홉 (도달성 질문, 다섯 엔진 동시)
python3 bench/harness.py --scale 50000 --degree 20 --workload reach \
  --hops 1,2,3,4,5,6,8 --query-timeout 60 \
  --systems ontological,ontological_raw,cte,age,age_explicit,pggraph,neo4j

# pgGraph의 순회 비용과 행 출력 비용을 분리
psql -d bench_pggraph -f bench/pggraph_cost.sql

# 03.5~03.6 · 지름이 큰 그래프 (체인 · 격자)
python3 bench/harness.py --scale 250000 --shape chain --workload reach \
  --hops 10,100,1000,10000 --query-timeout 60 --systems ontological,cte,age,pggraph,neo4j
psql -d bench_chain -v shape=chain -v nodes=1000000 -f bench/csr/gen_shape.sql
python3 bench/csr/deep.py --db bench_chain --depths 10,100,1000,10000,100000

python3 bench/harness.py --compare-baseline bench/results/baseline.json  # 20% 이상 느려지면 non-zero 종료
06 · 에이전트 네이티브

LLM이 틀리는 방식은 사람과 다릅니다. 그건 프롬프트가 아니라 데이터베이스가 고칠 문제입니다.

에이전트는 없는 라벨을 지어내고, 화살표를 뒤집고, 실수로 카티션 곱을 씁니다 — 아주 확신에 차서. 그래서 이 표면은 스스로 교정하는 루프로 설계했습니다. 모든 단계가 평범한 SQL 호출입니다.

og_schema_for(graph, question)

DDL 말고 스키마를 주십시오

질문에 관련된 부분만, 토큰 예산에 맞춰 돌려줍니다. roles 필드가 화살표가 어느 쪽을 향하는지를 알려줍니다 — 생성된 Cypher에서 가장 흔한 버그가 그겁니다. 예산 때문에 잘렸다면 잘렸다고 명시합니다. 스키마가 부분적임을 아는 모델은 되묻고, 모르는 모델은 지어냅니다.

og_estimate(graph, query)

돌리기 전에 비용을 확인합니다

예상 행 수, 예상 비용, 그리고 구체적 조언 — "패턴에 연결되지 않은 노드가 있습니다. 관계로 연결하지 않으면 카티션 곱이 됩니다." 에이전트가 자기 나쁜 질의를 스스로 철회하게 두는 편이 나중에 죽이는 것보다 낫습니다.

og_explain_error(graph, query)

오류가 정정을 들고 옵니다

code는 릴리스 간 안정적이라 재시도 로직이 분기할 수 있습니다. 후보는 실제 카탈로그에 대한 편집 거리에서 나옵니다 — 모델이 뭐가 있는지 추측할 필요가 없습니다.

{"code":"UNKNOWN_LABEL", "message":"unknown label 'Persn' … did you mean: Person", "suggestions":["Person"]}
og_diagnose_empty(graph, query)

빈 결과가 가장 어렵습니다

틀린 게 없고, 읽을 것도 없으니까요. 그래서 패턴을 원소 단위로 걸으며 어디서 0행이 되었는지를 보고합니다 — "패턴은 매칭됐고, WHERE 절이 모든 행을 지웠습니다."

og_create_role / og_apply_role

할 수 있는 일을 제한합니다

역할별 statement_timeout, work_mem, 읽기 전용, 행 수 상한. 모든 og_cypher 호출은 주체·질의문·행 수·소요 시간·오류 코드와 함께 og_audit에 남습니다 — 사전 예방만이 아니라 사후 검토가 가능합니다.

og_set_source / og_history / og_as_of

답에 출처를 답니다

출처 URL·신뢰도·작성자를 노드에 붙이고, 필요한 타입만 이력 추적을 켭니다(쓰기 비용이 있으니 기본은 꺼짐). og_as_of는 이력이 없으면 현재 값을 돌려주는 대신 오류를 냅니다 — 시제 질문에 현재형 데이터로 조용히 답하는 건 지식베이스의 신뢰를 무너뜨리는 종류의 오답이기 때문입니다.

07 · 활용 용도

이미 PostgreSQL을 쓰고 있는데 그래프가 필요해진 순간

각 항목에 실제 호출할 함수까지 적었습니다. 데모가 아니라 오늘 쓸 수 있는 표면입니다.

GraphRAG · 에이전트 지식베이스

LLM이 질의하고, 스스로 고치고, 출처를 다는 지식 그래프

벡터 검색만으로는 "왜 이 문서인가"를 설명하지 못하고, 그래프만으로는 의미 유사도를 못 잡습니다. 여기서는 둘이 같은 릴레이션에 있어서 og_hybrid_search()가 벡터 순위와 그래프 근접도를 RRF로 융합하고 두 성분 점수를 함께 돌려줍니다.

og_schema_for → og_estimate → og_cypher → og_explain_error / og_diagnose_empty → 재시도 og_hybrid_search(graph, type, prop, q, anchor, k)
전사 온톨로지 · 마스터 데이터

도메인 모델을 문서가 아니라 데이터베이스가 강제합니다

다중 상속 타입 계층, 역할별 참여자 타입 제약, 카디널리티, transitive· symmetric 규칙. 상위 개념 하나로 질의하면 모든 하위 개념이 답하므로, 개념 체계가 바뀌어도 애플리케이션 질의를 다시 쓰지 않습니다.

og_create_type(g,'EV','entity',ARRAY['Car']) og_add_role(g,'OWNS','owner','Person',0) og_add_rule(g,'PART_OF','transitive')
Supabase · PostgREST 제품

추가 서비스 0개로 제품에 그래프 기능을 붙입니다

og_cypher_json()을 PostgREST RPC로 노출하면 supabase-js가 바로 호출합니다. 결과는 jsonb라 어떤 드라이버든 특수 라이브러리 없이 읽습니다. 그리고 RLS가 순회 도중 적용되므로 멀티테넌시 격리를 애플리케이션 코드로 다시 구현하지 않습니다.

og_map_table(g, 'public.users', 'User', 'id', '{...}') — 기존 테이블을 복사 없이 노드 타입으로 og_enable_rls(g, 'User', 'tenant_id = current_tenant()')
의미 검색 · 추천

"이것과 비슷한 관계를 찾아줘"

노드 유사도는 어디에나 있습니다. 여기서는 관계 자체가 임베딩을 갖습니다 — "이 협업과 비슷한 협업", "이 인용 맥락과 비슷한 인용". 엣지 타입 테이블의 HNSW 인덱스이므로 그래프 술어가 사후 필터가 아니라 인덱스와 같은 스캔에서 걸립니다.

og_similar('default', <edge_id>, 'context', 3) og_vector_search(g, t, p, q, k, filter := 'p_year > 2020') og_stale_embeddings(g) — 원본이 바뀐 임베딩 추적
시맨틱 웹 자산 이관

OWL 온톨로지를 타입 계층으로 그대로 받습니다

Turtle / N-Triples를 적재하면 OWL 클래스는 타입으로, rdfs:subClassOf는 상속으로, domain/range는 역할로 매핑됩니다. 다시 직렬화할 때는 매핑되지 않은 트리플까지 보존합니다. 매핑되지 않은 것과 그 이유는 og_mapping_report()가 알려줍니다 — 조용히 삼키지 않습니다.

주의: 이 영역은 부분 구현입니다. RDF 적재·덤프와 OWL 매핑은 동작하지만, SPARQL은 아직입니다.

og_load_rdf(g, document, 'turtle') og_dump_rdf(g, 'turtle') · og_mapping_report(g)
규제 · 감사 · 데이터 거버넌스

답이 어디서 왔고 언제 그랬는지 말할 수 있어야 하는 도메인

모든 og_cypher 호출의 감사 로그, 엔터티별 출처·신뢰도·작성자, 선택적 변경 이력, 과거 시점 조회. 그리고 이력이 없으면 추정하지 않고 오류를 냅니다. 역할별 자원 상한으로 폭주 질의도 사전에 막습니다.

og_set_source(id, 'https://…/doc/42', 0.92, 'ingest-v3') og_enable_history(g,'Person') · og_as_of(id, ts) og_data.og_audit — principal · query · rows · ms · code
이럴 땐 쓰지 마십시오

맞지 않는 경우도 먼저 말씀드립니다

  • 1~2홉 고정 깊이 조회만 필요하다면 — 인덱스 두 개짜리 재귀 CTE가 더 빠릅니다. 위 벤치마크가 그렇게 말합니다. 타입 시스템·Cypher·벡터가 필요 없다면 그걸 쓰십시오.
  • WITH·UNION에 의존하는 질의가 많다면 — 아직 컴파일되지 않습니다. 조용히 다르게 해석하지 않고 대안을 제시하며 명확히 실패합니다. 질의를 쪼개거나 og_cypher_sql() 출력을 SQL CTE로 감싸는 우회가 필요합니다.
  • 분산 쓰기 · 샤딩이 지금 필요하다면 — 읽기 복제본은 오늘 동작하지만 샤딩은 설계만 되어 있습니다. 2단계 커밋 없는 분산 쓰기는 헌법 원칙을 조용히 위반하는 일이라 미뤘습니다. 식별자에 shard 비트는 이미 예약되어 있어 나중에 재작성 없이 분산됩니다.
  • PostgreSQL을 쓰지 않기로 이미 결정했다면 — 이 제품의 장점 절반은 "확장이라서 공짜로 따라오는 것들"입니다. 그 전제가 없으면 절반이 사라집니다.
08 · Neo4j 샘플 이식

Neo4j 애플리케이션이 접속 URI만 바꾸면 그대로 돕니다.

:play movies 뒤에 있는 그 예제 — neo4j-graph-examples/movies 의 데이터셋과 공식 가이드 쿼리, 그리고 공식 샘플 앱입니다. 한 줄도 이 데이터베이스에 맞게 고쳐 쓰지 않았습니다. 고쳐 쓰면 다른 질문에 답하는 셈이기 때문입니다.

프로토콜 · Bolt 게이트웨이

드라이버도, 세션도, 결과 객체도 그대로입니다

Neo4j 드라이버가 Bolt로 붙습니다(명세 011). 게이트웨이는 파서도 플래너도 캐시도 사용자 저장소도 갖지 않습니다 — 받은 Cypher를 og_cypher()로 넘길 뿐이라 질의 경로는 여전히 하나입니다. 인증은 PostgreSQL 역할 그대로여서 권한·RLS·감사가 둘로 갈라지지 않고, 세션의 database는 그래프가 됩니다. PostgreSQL 프로토콜 경로는 아무것도 달라지지 않습니다.

# Neo4j GraphDatabase.driver("bolt://neo4j:7687", auth=basic_auth(user, pw)) # Ontological — 바뀌는 건 이 두 줄뿐 GraphDatabase.driver("bolt://og:7687", auth=basic_auth(pg_role, pg_pw))
검증 · 세 경로 대조

같은 쿼리 24개를 세 경로로 던져 결과를 맞춰봅니다

movies.cypher를 구문 단위로 적재하고, 가이드 Cypher 24개를 PostgreSQL 경로 · Bolt 경로 · 진짜 Neo4j 5 세 곳에 똑같이 던져 대조합니다. Bolt 쪽은 우리가 만든 클라이언트가 아니라 Neo4j 공식 드라이버가 던집니다 — 우리 클라이언트로 우리 서버를 테스트하는 건 증거가 아니기 때문입니다. Neo4j와 다른 점은 둘 뿐이고 모두 타입 시스템에서 나옵니다: 타입 선언(명세 002), 그래서 필요 없어진 CREATE CONSTRAINT.

loaded 39/39 statements → 38 movies, 133 people q14 ACTED_IN 전체 172 rows = neo4j = bolt q17 Cloud Atlas 감독 3 rows = neo4j = bolt q18 Tom Hanks 공동출연자 39 rows = neo4j = bolt q20 Bacon path (1..3홉) 48 rows = neo4j = bolt 24/24 queries agree on all three paths Node 역직렬화 · 필드 순서 · 파라미터 · 트랜잭션 커밋/롤백 · 실패 후 RESET · 동시 세션 8개 ✓
이 실행이 잡아낸 것

샘플을 그대로 돌리지 않았다면 못 봤을 다섯 가지

공식 샘플은 우리가 쓰지 않는 방식으로 Cypher를 씁니다. 그래서 회귀 스위트가 통과하는 동안에도 아래가 살아 있었고, 이 실행에서 드러나 고쳐졌습니다.

1. 리스트 리터럴이 타입 없이 to_jsonb()로 → 컴파일 실패 ['Neo'] 같은 배열 프로퍼티가 있는 MERGE 전부 2. 리스트 ↔ 배열 컬럼 비교가 text[] = jsonb → 배열 컬럼이 힌트면 ARRAY[...]::text[] 로 컴파일 3. MERGE가 이미 바인딩된 끝점을 무시 MERGE (a)-[:DIRECTED]->(m) 이 그래프의 아무 DIRECTED나 찾아서 두 번째 간선을 만들지 않음 → 44개가 1개로 4. 관계 유일성(isomorphism) 미적용 (a)-[:ACTED_IN]->(m)<-[:ACTED_IN]-(b) 에서 a가 자기 자신의 공동출연자로 → 51행(정답 39행) 5. 트랜잭션 안의 RUN 은 db 를 다시 보내지 않는다 → 게이트웨이가 기본 그래프로 되돌려 커밋이 엉뚱한 그래프로. 드라이버가 아니었으면 못 봤을 버그
직접 재현하기

하네스도, 샘플 앱도 저장소에 있습니다

tests/neo4j-movies/. 데이터셋은 첫 실행에 내려받아 캐시하고, 어느 포트가 Bolt를 말하는지는 주장하지 않고 실제 핸드셰이크로 확인합니다. Neo4j가 떠 있으면 세 경로를 대조하고, 없으면 그 단계만 건너뜁니다. sample_app.py 는 공식 샘플 앱 원본에서 URI 두 줄만 바꾼 것입니다.

# PostgreSQL 경로 + Bolt 경로
python3 tests/neo4j-movies/run.py

# Neo4j까지 세 경로 대조
docker run -d --name bench-neo4j -p 27687:7687 \
  -e NEO4J_AUTH=neo4j/benchpass123 neo4j:5
python3 tests/neo4j-movies/run.py \
  --neo4j bolt://localhost:27687

# 공식 샘플 앱 그대로
python3 tests/neo4j-movies/sample_app.py
09 · 구현 현황

열네 개 명세와 제안 하나. 부분 구현은 부분 구현이라고 씁니다.

거버넌스는 저장소의 헌법(constitution) 문서에 있고, 모든 이탈은 해당 plan.md의 Complexity Tracking에 기록됩니다 — 그중 가장 중요한 두 가지도 포함해서.

001네이티브 그래프 저장 엔진인접 세그먼트 · 타입 속성 테이블 · 벌크 적재 · 온라인 재구성 · 무결성 검사기working
002온톨로지 타입 시스템 · 상속 인덱싱entity/relation/attribute · 다중 상속 · 역할 · 제약 · 구간 라벨working
003네이티브 Cypher 엔진렉서 · 파서 · SQL 컴파일러 · 읽기 + 쓰기 경로 · WITH · 리스트 컴프리헨션 · CALLworking
004벡터 · 하이브리드 의미 검색노드 및 관계 임베딩 · 필터 푸시다운 · 하이브리드 RRF · 재현율 하네스working
005PostgreSQL / Supabase 상호운용관계형 뷰 · RLS 헬퍼 · PostgREST RPC · 테이블 매핑working
006RDF / OWL / SPARQL / SHACLRDF 적재·덤프 · OWL→타입 계층 매핑 · 오버플로 충실도. SPARQL 미구현partial
007분산 클러스터읽기 복제본만 동작. 샤딩은 설계 완료, 구현 전 — 이유는 plan.md에read replicas
008에이전트 네이티브 인터페이스예산에 맞춘 스키마 · 실행 없는 오류 검사 · 0행 진단 · 드라이런 추정 · 이력 · 감사. 없는 라벨은 오류가 아님 · 역할의 max_rows·read_only 미동작working · partial
009벤치마크 · 적합성 하네스정확성 게이트가 붙은 AGE/CTE 비교 · 무결성 검사 · 회귀 비교working
010TypeQL 질의 표면TypeDB 3.x define/insert/put/match/fetch/delete/update · TypeDB 서점 예제가 수정 없이 실행. 사용자 정의 함수는 평가하지 않음partial
011Bolt 프로토콜 게이트웨이Bolt 4.4 · Node/Relationship · 트랜잭션 · 인증은 PostgreSQL 역할 · Neo4j MCP 서버가 수정 없이 동작. 5.x·Path·TLS 미지원working
012스키마 진화expand → migrate(배치·재개) → validate → contract · 스키마 버전별 카탈로그 스냅샷. 타입 분할/병합 · TypeQL redefine 미구현working · partial
013이중 시간(bitemporal) 그래프타입별 유효 시간 · 시스템 시간 · 어느 시점이든 같은 Cypher, 같은 SQL. 가변 길이 경로 · TypeQL은 시점 질의를 거부 · 보존 정책 미구현working · partial
014쓰기 시점 보안 컨텍스트타입·연산·프로퍼티별 쓰기 권한 · 레지스트리 RLS · 위조 불가 스탬프 · Action · 감사. 레지스트리 읽기는 아직 무제한working · partial
—가상 패브릭 (ADR-029)외부 PostgreSQL·MySQL 테이블을 복사 없이 노드로 · 가상 관계 · 원천 읽기 전용 · Studio/Bolt/SQL 게이트웨이. 제안 단계 — 가변 길이 · 원천 쓰기 미지원proposed
밝혀두는 설계상의 타협 1

이 릴리스는 Table Access Method가 아닙니다

AM을 구현한다는 건 튜플 직렬화, 가시성, vacuum, WAL 리소스 매니저를 직접 쓴다는 뜻입니다. 인접 세그먼트와 타입 컬럼이 이미 성능 의도를 담고 있으므로 AM은 v2 항목이고, og_expand()가 그 변경을 넘어 살아남을 안정적 인터페이스입니다.

밝혀두는 설계상의 타협 2

Cypher는 여전히 함수 호출로 들어옵니다

PostgreSQL 16에는 커널을 패치하지 않고 최상위 파서를 대체할 훅이 없습니다. 헌법 원칙 I("절대 포크하지 않는다")이 원칙 II보다 상위이므로 이번 라운드는 원칙 II가 집니다 — 그리고 plan.md에 그렇게 적혀 있습니다. 실제로 중요한 부분인 옵티마이저 통합은 진짜입니다.

10 · 시작하기

Docker 하나면 전부 들어 있습니다

엔진, PostgreSQL 16, pgvector, 데모 데이터셋, 그리고 Studio까지.

1 · 설치
# 컨테이너 빌드 후 확장 설치 + 데모 데이터
docker build -f docker/Dockerfile.dev -t ontological-dev .
docker run -d --name og -v "$PWD":/work \
  -p 28816:28816 -w /work ontological-dev sleep infinity

cargo pgrx install --features pg16
psql -c "CREATE EXTENSION ontological CASCADE"
psql -f examples/demo.sql
2 · Studio 실행
# Neo4j Browser 스타일 콘솔
cd portal && npm install
PGHOST=127.0.0.1 PGPORT=28816 \
PGDATABASE=og PGUSER=dev npm start

# → http://localhost:7474
#   질의 스트림 · 力 지향 그래프 · Table / JSON
#   그리고 Cypher가 컴파일된 SQL을 보여주는 SQL 탭
-- 처음 30초 안에 해볼 만한 세 가지

-- 1. 라벨 하나, 계층 전체가 답한다
SELECT og_cypher('default', $$ MATCH (w:Work) RETURN w.title, labels(w) $$);

-- 2. 관계 위의 의미 검색
SELECT score, entity->>'note' FROM og_similar('default', <edge_id>, 'context', 3);

-- 3. 컴파일된 SQL을 직접 본다
SELECT og_cypher_sql('default',
  $$ MATCH (p:Person)-[:ACTED_IN]->(w:Work) WHERE p.born > 1960 RETURN w.title $$);
문서내용
docs/architecture.md다이어그램과 함께, 조각들이 어떻게 맞물리는지
docs/cypher.md지원 문법 — 그리고 지원하지 않는 것까지 정확하게
docs/api.md모든 SQL 함수
docs/agents.mdLLM 에이전트에서 사용하기 (워크드 프롬프트 포함)
bench/README.md벤치마크 방법과 재현 절차
tests/neo4j-movies/README.mdNeo4j Movie 샘플을 그대로 돌린 결과 — 세 경로 대조
bolt/README.mdBolt 게이트웨이 — 운영 방법과 지원 매트릭스

수치를 믿지 마십시오. 직접 돌려 보십시오.

하네스는 스스로의 결론에 적대적으로 설계되어 있고, 회귀 게이트는 20% 이상 느려지면 non-zero로 종료합니다. 기준선 파일은 저장소에 커밋되어 있습니다.

git clone … && python3 bench/harness.py --scale 50000 --degree 20