WITH RECURSIVE로는 답답했던 질문들: 내 노트 그래프에 Datalog 얹기
by Justin Kim
지난 글 Datalog, 일상의 도구가 될 수 있을까의 마지막에 이렇게 적어두었습니다. “다음에는 이 고민을 코드로 옮겨볼 생각입니다. 가벼운 Datalog 엔진을 실제 일상적인 문제에 적용해보는 실험을 해보려 합니다.” 이번 글은 그 문장을 실제 코드로 옮겨본 기록입니다.
그때 일상적 유즈케이스 후보로 네 가지를 꼽았습니다. 노트 간 관계, 의존성 분석, 권한 추론, 설정 검증이었습니다. 이 중에서 가장 먼저 손이 간 것은 개인 지식 관리(PKM) 노트 그래프였습니다. 이유는 단순합니다. 별도의 데이터를 새로 만들 필요가 없었기 때문입니다. Obsidian이든 Logseq든, 이미 노트를 쓰고 있다면 링크와 태그가 쌓여 있습니다. 그 자체가 작은 그래프입니다.
엔진은 그동안 여러 글에서 슬쩍슬쩍 언급만 했던 wirelog를 씁니다. 직접 만들고 있는 Datalog 엔진입니다. 마침 Python 바인딩인 pyrewire도 나왔습니다. 지난 글에서 “가벼운 임베디드 Datalog 엔진을 Python에서 한 줄로 부르는” 형태가 가장 그럴듯하다고 적었는데, 적어도 실험 수준에서는 그 그림을 실제로 해볼 수 있게 된 셈입니다.
왜 노트 그래프인가: SQL이 답답해지는 바로 그 지점
지난 글에서 저는 스스로를 한 번 반박했습니다. “재귀적 추론이 필요한 일상적 상황이 드물다”고요. Datalog가 아무리 보기 좋고, SQL의 WITH RECURSIVE가 아무리 답답해도, 정작 그런 질문을 자주 던지지 않는다면 도구를 바꿀 이유가 없습니다. 불편함이 있어도 그 불편함을 느끼는 순간이 드물면, 결국 익숙한 도구가 이깁니다.
그런데 노트 그래프는 그 드문 예외에 꽤 정확히 걸립니다. Obsidian이나 Logseq에서 [[링크]]로 노트를 잇다 보면, 처음에는 한 단계 백링크만 봐도 충분합니다. 이 글이 어디에서 참조되는지, 이 노트가 어디를 가리키는지 정도는 기본 UI로도 잘 보입니다. 문제는 노트가 조금 쌓인 뒤입니다. 그때부터 궁금해지는 질문은 대개 한 단계 링크가 아니라 전이적(transitive) 관계입니다.
- “이 노트에서 출발해 링크를 따라가면 닿는 노트가 전부 뭐지?”
- “A와 B는 몇 단계 만에 연결되지?”
- “어디서도 참조되지 않는 고아 노트는?”
첫 번째 질문을 SQL로 옮기면 대략 이렇게 됩니다.
WITH RECURSIVE reach(src, dst) AS (
SELECT src, dst FROM link
UNION
SELECT r.src, l.dst
FROM reach r JOIN link l ON r.dst = l.src
)
SELECT dst FROM reach WHERE src = 'Datalog 소개';
틀린 코드는 아닙니다. 실제로도 잘 동작합니다. 다만 이 코드를 매번 쓰고 싶지는 않습니다. WITH RECURSIVE의 앵커 항과 재귀 항을 나누어 적어야 하고, 어디서 재귀가 멈추는지, 중복은 어떻게 제거되는지, UNION을 쓸지 UNION ALL을 쓸지 계속 신경 써야 합니다. 여기에 조건이 하나 더 붙거나 관계가 두세 개로 늘어나면, 쿼리는 금방 “내가 지금 무엇을 묻고 있지?”라는 느낌을 줍니다.
Datalog에서는 같은 의미가 규칙 두 줄입니다.
related(X, Y) :- link(X, Y).
related(X, Y) :- link(X, Z), related(Z, Y).
“링크가 있으면 관련 있다. 그리고 한 다리 건너 관련된 것도 관련 있다.” 거의 말 그대로입니다. 여기서 제가 보고 싶었던 차이는 성능보다 표현 방식이었습니다. SQL에서는 재귀를 쿼리 안에 끼워 넣는 느낌이 강한 반면, Datalog에서는 재귀 관계 자체를 규칙으로 선언합니다. 종료는 고정점(fixed-point)에 도달했을 때 엔진이 판단합니다. 사람이 루프를 돌리는 것이 아니라, “이 규칙으로 더 이상 새 사실이 나오지 않을 때까지 적용하라”고 맡기는 쪽에 가깝습니다.
이번 실험의 핵심은 바로 이 차이를 실제 노트 데이터 위에서 확인하는 것이었습니다.
실험 셋업: 노트 볼트를 Datalog 사실(Fact)로 바꾸기
먼저 노트 볼트를 스캔해 Datalog가 다룰 사실로 바꿉니다. 여기서는 일부러 복잡한 파서를 쓰지 않았습니다. 실험의 목적이 마크다운 파싱 자체가 아니라, 노트 그래프를 Datalog로 다뤄보는 데 있었기 때문입니다.
마크다운에서 뽑을 것은 두 가지뿐입니다. [[위키링크]]와 태그입니다. 위키링크는 노트 사이의 간선이 되고, 태그는 노트를 느슨하게 묶는 속성이 됩니다.
import re
from pathlib import Path
LINK = re.compile(r"\[\[([^\]|#]+)") # [[제목]], [[제목|별칭]], [[제목#헤딩]] 모두 제목만
TAG = re.compile(r"(?:^|\s)#([A-Za-z0-9_\-/가-힣]+)")
def scan_vault(vault: Path):
links, tags = [], []
for md in vault.rglob("*.md"):
title = md.stem
text = md.read_text(encoding="utf-8")
for dst in LINK.findall(text):
links.append((title, dst.strip()))
for t in TAG.findall(text):
tags.append((title, t))
return links, tags
links, tags = scan_vault(Path("~/Notes").expanduser())
print(f"notes linked: {len(links)} edges, {len(tags)} tag assignments")
# notes linked: 4123 edges, 1880 tag assignments
제 볼트는 노트 약 1,200개, 링크 4,123개, 태그 1,880건 규모였습니다. 아주 큰 그래프라고 하기는 어렵습니다. 하지만 사람이 백링크 패널을 열어 눈으로 따라가기에는 이미 충분히 큽니다. 직접 링크된 노트는 보이지만, 두 단계나 세 단계 뒤에 무엇이 있는지는 잘 보이지 않습니다. 딱 이 지점부터 “조회”보다 “도출”이 조금씩 의미를 갖기 시작합니다.
규칙과 질의: pyrewire로 추론 돌리기
pyrewire는 wirelog를 Python에서 쓰기 위한 바인딩입니다. Datalog 규칙을 문자열로 전달하고, Python 값으로 사실을 등록한 뒤, 추론 결과를 다시 Python 객체로 읽어올 수 있게 해줍니다. 이번 실험처럼 마크다운 파일을 Python으로 스캔하고, 그 결과를 Datalog 엔진에 전달한 다음, 다시 Python에서 후처리하는 흐름에는 이 형태가 잘 맞습니다.
이제 pip install pyrewire로 패키지를 설치한 뒤, 스키마와 규칙을 전달하고 위에서 추출한 사실을 등록합니다. 여기서는 EasySession을 사용했습니다. 이름 그대로 복잡한 런타임 설정을 직접 만지지 않고, 사실을 하나씩 insert하고 결과를 snapshot으로 읽는 형태입니다.
노트 제목에는 공백도 있고 한글도 있습니다. 이런 문자열을 Datalog 소스 문자열 안에 직접 이어 붙이면 따옴표와 이스케이프를 계속 신경 써야 합니다. 여기서는 Python 리스트로 값을 넘기기 때문에 그 문제를 피할 수 있습니다. 이 작은 편의가 실제 노트 데이터를 넣어볼 때는 꽤 중요합니다.
from pyrewire import EasySession
SRC = """
.decl link(src: symbol, dst: symbol)
.decl related(src: symbol, dst: symbol)
related(X, Y) :- link(X, Y).
related(X, Y) :- link(X, Z), related(Z, Y).
"""
with EasySession(SRC) as s:
for src, dst in links:
s.insert("link", [src, dst])
reach = s.snapshot("related")
# "Datalog 소개"에서 도달 가능한 모든 노트
seeds = sorted(y for (x, y) in reach if x == "Datalog 소개")
print(seeds)
# ['ASP', 'Prolog', 'RDF', '고정점', '논리 프로그래밍', '재귀', ...]
실제로 실행하면 결과는 이런 식으로 나옵니다.
['ASP', 'Prolog', 'RDF', '고정점', '논리 프로그래밍', '재귀', '추론', ...]
여기서 엔진에 직접 넣은 것은 link뿐입니다. 그런데 snapshot("related")를 읽어보면 직접 링크되지 않은 노트까지 들어옵니다. “Datalog 소개”에서 “논리 프로그래밍”으로 가고, 거기서 “Prolog”로 가고, 다시 “고정점”으로 이어지는 식의 사슬을 엔진이 바닥부터(bottom-up) 조립해 낸 것입니다.
이것이 Datalog에서 ‘사실’이라고 부르는 이유에서 이야기한 “도출(derivation)”입니다. 단순히 저장된 값을 꺼내는 조회가 아닙니다. 이미 알고 있는 사실과 규칙을 결합해서, 명시적으로 넣지 않았던 새 사실을 만들어 내는 일입니다. 노트 그래프에서는 related("Datalog 소개", "고정점") 같은 관계가 그렇게 생겨납니다.
전체 파이프라인을 그림으로 보면 이렇습니다.
graph LR
A[Obsidian 볼트<br/>*.md] -->|regex 스캔| B[Facts<br/>link/2, tag/2]
B -->|EasySession.insert| C[wirelog 엔진<br/>고정점 평가]
C -->|snapshot / step| D[추론된 관계<br/>related, cotag]
D --> E[Python에서<br/>후처리·질의]
style C fill:#e6f3ff,stroke:#0066cc,stroke-width:1px
같은 태그로 묶인 노트 클러스터
링크만 보면 주로 “한 노트에서 다른 노트로 갈 수 있는가”를 묻게 됩니다. 여기에 태그를 사실로 더하면 조금 다른 것도 볼 수 있습니다. 예를 들어 “직접 링크하지 않았지만 같은 주제를 공유하는 노트는 무엇인가” 같은 것입니다.
이 경우에는 재귀가 필요하지 않습니다. 같은 태그를 가진 두 노트를 묶으면 됩니다.
SRC = """
.decl tag(note: symbol, t: symbol)
.decl cotag(a: symbol, b: symbol)
cotag(A, B) :- tag(A, T), tag(B, T).
"""
with EasySession(SRC) as s:
for note, t in tags:
s.insert("tag", [note, t])
pairs = [(a, b) for (a, b) in s.snapshot("cotag") if a < b] # 자기 자신·중복쌍 제거
print(pairs[:5])
실행 결과는 대략 이런 모양입니다.
[('Datalog 소개', '논리 프로그래밍'),
('Datalog 소개', 'Prolog와 Datalog'),
('RDF 그래프', '시맨틱 태깅'),
('증분 계산', 'Differential Dataflow'),
('TBox와 ABox', 'LLM 추출 파이프라인')]
결과의 각 튜플은 같은 태그를 공유하는 노트 쌍입니다. 두 노트가 직접 링크되어 있다는 뜻은 아닙니다. 같은 태그를 통해 느슨하게 같은 주제권에 들어온다는 뜻에 가깝습니다.
여기서 a < b 필터는 일부러 Datalog가 아니라 Python에서 했습니다. cotag(A, B) 규칙만 쓰면 같은 노트끼리의 쌍(A = A)도 나오고, (A, B)와 (B, A)가 함께 나옵니다. 이걸 Datalog 안에서 더 정교하게 처리할 수도 있겠지만, 여기서는 그럴 필요를 느끼지 못했습니다. 결과를 받아온 뒤 Python에서 한 줄로 걸러내는 편이 더 읽기 쉽습니다.
이 대목이 의외로 중요했습니다. Datalog를 쓰기 시작하면 모든 문제를 규칙으로 옮기고 싶어집니다. 하지만 그 순간 도구가 목적을 앞서기 쉽습니다. 이번 실험에서 제가 얻은 감각은 “어디까지 Datalog로 두고, 어디부터 Python으로 빼야 하는가”에 가까웠습니다.
고아 노트는 Datalog로 풀지 않았다
“어디서도 참조되지 않는 노트”도 처음에는 Datalog로 풀어볼까 생각했습니다. 하지만 이 질문은 부정(negation)이 필요합니다. 전체 노트 집합에서, 링크의 목적지로 등장한 노트를 빼면 됩니다.
그리고 다시 생각해보면, 이건 재귀도 아니고 전이도 아닙니다. 단순한 집합 차집합입니다. 그래서 그냥 Python에 맡겼습니다.
all_notes = {md.stem for md in Path("~/Notes").expanduser().rglob("*.md")}
linked_to = {dst for (_src, dst) in links}
orphans = sorted(all_notes - linked_to)
print(f"orphans: {len(orphans)}")
print(orphans[:10])
제 볼트에서는 이런 식의 목록이 나왔습니다.
orphans: 137
['2026년 읽을 논문', 'Askitect 메모', 'Datalog 실험 노트', 'GStreamer TODO',
'Magic Sets 초안', 'PKM 정리', 'RDF 용어', '논문 아이디어', '블로그 초안',
'회의 메모']
여기서 고아 노트는 “어디에서도 링크의 목적지로 등장하지 않은 노트”입니다. 노트 자체가 쓸모없다는 뜻은 아닙니다. 아직 다른 노트에서 참조하지 않았거나, 제목이 바뀌면서 링크가 끊겼거나, 단순히 독립적인 메모일 수 있습니다.
지난 글에서 “도구가 주는 가치가 전환 비용을 넘지 못하면 채택은 일어나지 않는다”고 적었는데, 그 기준을 스스로에게 적용한 셈입니다. 차집합 한 줄로 끝나는 일에 엔진을 부르는 건 과합니다. 이 정도 작업은 Python의 집합 연산이 더 직접적이고, 더 읽기 쉽습니다.
제 결론은 조금 좁습니다. Datalog는 재귀·전이가 등장하는 순간에만 제값을 합니다. 그 밖의 일까지 억지로 끌어안기 시작하면, 오히려 일상 도구로 쓰기 어려워집니다.
wirelog가 일반 Datalog와 다른 점: 증분
여기까지는 사실 어떤 Datalog 엔진으로도 됩니다. 링크를 넣고, 전이 폐쇄를 구하고, 결과를 읽는 정도라면 특별히 wirelog여야 할 이유는 없습니다. 제가 wirelog를 굳이 만들고 있는 이유는 증분 계산 쪽에 있습니다.
노트는 한 번에 완성되는 데이터가 아닙니다. 오늘 한 장 쓰고, 내일 한 장 고치고, 어떤 날은 링크 하나만 추가합니다. 이런 변화는 전형적인 “사실 하나 추가” 또는 “사실 하나 제거” 상황입니다. 나이브한 엔진이라면 이때 전체 추론을 처음부터 다시 돌립니다. 그래프가 작을 때는 괜찮지만, 데이터가 계속 자라면 금방 부담이 됩니다.
wirelog는 timely-differential dataflow 위에서 바뀐 부분만 전파합니다.
step()은 그 전파되는 델타를 (relation, row, diff)로 그대로 보여줍니다. diff = +1은 새로 도출된 사실이고, -1은 철회된 사실입니다. 전체 결과를 다시 덤프하는 것이 아니라, 이번 변경 때문에 무엇이 새로 생겼고 무엇이 사라졌는지를 보는 방식입니다.
with EasySession(SRC_RELATED) as s:
for src, dst in links:
s.insert("link", [src, dst])
s.snapshot("related") # 초기 그래프 확정
# 새 노트 한 장: "Magic Sets" → "Datalog 소개" 로 링크
s.insert("link", ["Magic Sets", "Datalog 소개"])
for relation, row, diff in s.step():
print(relation, row, diff)
# related ('Magic Sets', 'Datalog 소개') 1
# related ('Magic Sets', 'Prolog') 1
# related ('Magic Sets', '고정점') 1
# ... (Magic Sets에서 새로 닿게 된 노트만 +1로 출력)
출력은 전체 related 관계가 아니라, 이번 변경으로 새로 생긴 사실만 보여줍니다.
related ('Magic Sets', 'Datalog 소개') 1
related ('Magic Sets', 'ASP') 1
related ('Magic Sets', 'Prolog') 1
related ('Magic Sets', 'RDF') 1
related ('Magic Sets', '고정점') 1
related ('Magic Sets', '논리 프로그래밍') 1
related ('Magic Sets', '재귀') 1
이 예제에서는 새 노트 “Magic Sets”가 “Datalog 소개”를 가리키는 링크 하나를 추가했습니다. 그러면 “Magic Sets”에서 직접 닿는 노트뿐 아니라, “Datalog 소개”를 거쳐 닿을 수 있는 노트들이 related 사실로 새로 생깁니다.
중요한 점은 기존 4천여 개의 관계를 다시 계산하지 않는다는 것입니다. “Magic Sets”가 새로 닿게 된 노트들만 증분으로 튀어나옵니다. 노트를 한 장 쓸 때마다 그래프 전체를 재평가하지 않아도 된다는 것. PKM처럼 끊임없이 조금씩 자라는 데이터에서는 이 성질이 특히 잘 맞습니다.
한계와 남은 과제
여기까지 해보고 나니, Datalog를 노트 도구 위에 얹을 때의 경계도 어느 정도 보였습니다. 노트 전체를 Datalog로 다루겠다는 접근은 과합니다. 직접 링크와 단순 검색은 기존 도구로 충분하고, Datalog가 필요한 지점은 생각보다 좁았습니다.
- 대부분의 질문에는 여전히 백링크 패널과
grep이면 충분합니다. 특정 노트가 어디에서 언급되는지, 특정 단어가 어느 파일에 들어 있는지 확인하는 일은 기존 도구가 더 빠릅니다. Datalog가 이기는 구간은 “전이적 관계”와 “조금씩 갱신되는 그래프”로 꽤 좁습니다. 그 밖에서는 전환 비용이 가치를 넘지 못한다는 지난 글의 결론이 그대로 유효했습니다. - 사실 추출의 품질이 전부입니다. 이번 실험에서는 정규식으로
[[ ]]와 태그를 뽑았습니다. 간단해서 좋지만, 코드블록 안의[[ ]]나 별칭·헤딩 링크에서 어긋나기 쉽습니다. 결국 시맨틱 태깅에서 이야기한 “문자열이 아닌 개념으로 잇기” 문제로 되돌아옵니다. - 부정·집계는 의도적으로 Python에 남겼습니다. 이번 글에서 검증한 것은 재귀·전이까지입니다. 고아 노트 찾기나 클러스터 후처리까지 엔진 안으로 옮길 수도 있지만, 아직은 그게 더 좋은 선택인지 확신하지 못했습니다. 이 부분은 다음 과제로 둡니다.
- 엔진 성숙도.
wirelog는 아직 0.x이고,pyrewire도 막 1.0.0이 나온 참입니다. 일상 도구라기보다 “일상 도구가 될 수 있는지 확인하는 실험대”에 가깝습니다.
마치며
“특수한 도메인에서만 빛난다”던 Datalog가, 적어도 제 노트 위에서는 일상의 도구에 한 발 다가왔습니다. 다만 그 방식은 거창하지 않았습니다. Obsidian을 대체하거나, 노트 시스템 전체를 논리 프로그래밍으로 다시 만드는 쪽이 아니었습니다.
핵심은 Datalog로 모든 걸 하려 들지 않는 것이었습니다. 재귀와 전이가 나오는 자리에만 규칙 두 줄을 얹고, 나머지는 Python에 맡겼습니다. 그렇게 하니 지난 글에서 그렸던 “기존 워크플로우에 가볍게 얹히는 형태”가 그럭저럭 모양을 갖췄습니다.
다음에는 같은 엔진을 두 번째 후보였던 의존성/영향 분석에 적용해 보려 합니다. “이 모듈을 고치면 무엇이 깨지나”라는 질문은 노트 그래프보다 더 노골적으로 전이 관계를 요구합니다. 그리고 그동안 배경으로만 등장하던 wirelog의 내부 구조도 한 번 제대로 풀어볼 생각입니다. Datalog를 어떻게 nanoarrow 컬럼과 실행 계획으로 컴파일하는지에 대한 이야기입니다.
더 알아보기
- pyrewire:
pip install pyrewire(Python 3.11+) - wirelog (엔진 본체): semantic-reasoning/wirelog
관련 글
Subscribe via RSS
Comments