지난 RDF Reification 글에서 김철수의 이직 기록을 예제로 들었습니다.

김철수는 2020년부터 2022년까지 삼성전자에서 근무했고, 2023년부터 현재까지 네이버에서 근무하고 있다.

기본 RDF 트리플로는 이렇게 됩니다.

ex:김철수 ex:worksAt ex:삼성전자 .
ex:김철수 ex:worksAt ex:네이버 .

이 두 줄만 보면 너무 많은 것이 빠져 있습니다. 삼성전자에는 언제부터 언제까지 다녔는지, 네이버에는 지금도 다니는지, 이 정보가 어디에서 온 것인지 알 수 없습니다.

그래서 당시에는 RDF 1.1의 전통적인 Reification을 사용했습니다.

ex:stmt1 a rdf:Statement ;
    rdf:subject   ex:김철수 ;
    rdf:predicate ex:worksAt ;
    rdf:object    ex:삼성전자 ;
    ex:startDate  "2020-01-01"^^xsd:date ;
    ex:endDate    "2022-12-31"^^xsd:date ;
    ex:source     ex:HR_Database .

의미는 분명합니다. ex:stmt1은 “김철수가 삼성전자에서 근무한다”라는 말 자체를 가리키는 자원입니다. 이제 그 자원에 시작일, 종료일, 출처를 붙일 수 있습니다.

다만 문법은 꽤 무겁습니다. 원래 한 줄이면 충분했던 사실 하나가 rdf:subject, rdf:predicate, rdf:object를 포함한 여러 줄로 늘어납니다. “트리플에 대해 말하고 싶다”는 요구는 자연스러운데, 표현은 그렇지 않았습니다.

이번 글에서는 그 지점을 다시 보려 합니다. RDF 1.2에서는 표현이 어떻게 바뀌었는지 보고, 같은 생각을 wirelog의 compound term으로 옮기면 Datalog 코드가 어떻게 달라지는지도 보겠습니다.

RDF 1.2: 트리플이 term이 된다

RDF 1.2[1]에는 triple term이 들어옵니다. 이름 그대로 RDF 트리플을 하나의 term처럼 다루는 방식입니다. 트리플을 다른 트리플의 object 자리에 놓을 수 있습니다.

RDF 1.1의 reification은 트리플을 네 조각으로 분해했습니다.

ex:stmt1 a rdf:Statement ;
    rdf:subject   ex:김철수 ;
    rdf:predicate ex:worksAt ;
    rdf:object    ex:삼성전자 .

RDF 1.2에서는 같은 내용을 이렇게 씁니다.

PREFIX rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#>
PREFIX ex:  <http://example.org/>

ex:stmt1 rdf:reifies <<( ex:김철수 ex:worksAt ex:삼성전자 )>> .

여기서 <<( ... )>> 안에 들어간 것이 triple term입니다. ex:stmt1은 이 트리플을 가리키는 reifier입니다. rdf:reifies는 둘을 이어 줍니다. RDF 1.2 Concepts 문서도 reifying triple을 이렇게 설명합니다. predicate는 rdf:reifies이고, object는 triple term인 트리플입니다.

이제 시간과 출처는 reifier에 붙입니다.

ex:stmt1 rdf:reifies <<( ex:김철수 ex:worksAt ex:삼성전자 )>> ;
    ex:startDate "2020-01-01"^^xsd:date ;
    ex:endDate   "2022-12-31"^^xsd:date ;
    ex:source    ex:HR_Database .

ex:stmt2 rdf:reifies <<( ex:김철수 ex:worksAt ex:네이버 )>> ;
    ex:startDate "2023-03-01"^^xsd:date ;
    ex:source    ex:HR_Database .

겉으로는 문법 하나가 추가된 정도로 보입니다. 하지만 써보면 차이가 큽니다. 더 이상 트리플을 rdf:subject, rdf:predicate, rdf:object로 풀어헤치지 않아도 됩니다. 원래 트리플 모양을 거의 그대로 들고 갑니다.

Turtle 1.2[2]에는 annotation syntax도 있습니다. 원래 트리플을 쓰고, 그 옆에 바로 메타데이터를 붙입니다.

ex:김철수 ex:worksAt ex:삼성전자 {| 
    ex:startDate "2020-01-01"^^xsd:date ;
    ex:endDate   "2022-12-31"^^xsd:date ;
    ex:source    ex:HR_Database
|} .

이쪽이 사람이 읽기에는 훨씬 편합니다. 기본 사실과 그 사실의 맥락이 붙어 있기 때문입니다. 다만 모델 차원에서는 여전히 reifier가 있고, 그 reifier가 triple term을 rdf:reifies로 가리키며, 메타데이터는 reifier에 붙는 구조입니다.

비교하면 이렇습니다.

구분 RDF 1.1 Reification RDF 1.2 Triple Term
트리플 참조 방식 rdf:subject, rdf:predicate, rdf:object로 분해 트리플 자체를 term으로 사용
reifier 연결 rdf:Statement 관용구 rdf:reifies
문법 크기 장황함 짧음
메타데이터 위치 statement 자원 reifier

여기까지가 RDF 쪽 이야기입니다. 이제 같은 문제를 Datalog 쪽에서 보겠습니다.

Datalog로 옮기면 무엇이 불편한가

RDF reification을 단순한 relation으로 옮기면 보통 이렇게 됩니다.

.decl reifies(id: symbol, s: symbol, p: symbol, o: symbol)
.decl context(id: symbol, start: int64, end: int64, source: symbol)

Datalog의 사실(fact)은 이렇게 넣습니다.

reifies("stmt1", "김철수", "worksAt", "삼성전자").
context("stmt1", 20200101, 20221231, "HR_Database").

쿼리도 그렇게 어렵지는 않습니다.

.decl employment_at(person: symbol, company: symbol, start: int64, end: int64, source: symbol)

employment_at(P, C, Start, End, Source) :-
    reifies(ID, P, "worksAt", C),
    context(ID, Start, End, Source).

하지만 이 방식은 RDF 1.1 reification과 비슷한 맛이 납니다. 트리플 자체가 하나의 값이 아니라 네 개의 컬럼으로 펼쳐져 있습니다. 메타데이터도 별도 relation에 있으니 ID로 다시 조인해야 합니다.

작은 예제에서는 별 문제가 아닙니다. 그런데 “트리플에 대한 진술”이 계속 나오면 ID를 따라다니는 relation이 늘어납니다. 제가 쓰고 싶은 모양은 사실 이쪽에 가깝습니다.

statement(
  stmt(김철수, worksAt, 삼성전자),
  ctx(20200101, 20221231, HR_Database)
)

원래 트리플은 stmt(...)라는 값으로 두고 싶습니다. 시간과 출처는 ctx(...)라는 값으로 두고 싶습니다. RDF 1.2의 triple term과 꽤 비슷한 감각입니다.

Wirelog compound term으로 표현하기

wirelog의 compound term은 여러 값을 하나로 묶습니다. 예를 들어 stmt(P, Pred, C)는 이름이 stmt이고 인자가 세 개인 term입니다. ctx(Start, End, Source)도 같은 방식입니다.

이번 예제에서는 employment relation 하나에 reifier id, triple term, context term을 같이 넣겠습니다.

.decl employment(id: symbol, st: stmt/3 side, ctx: ctx/3 side)

st 컬럼은 stmt/3 compound입니다.

stmt(person, predicate, company)

ctx 컬럼은 ctx/3 compound입니다.

ctx(start_date, end_date, source)

그러면 “김철수의 근무 이력”을 꺼내는 규칙은 이렇게 쓸 수 있습니다.

.decl employment_at(person: symbol, company: symbol, start: int64, end: int64, source: symbol)

employment_at(P, C, Start, End, Source) :-
    employment(_, stmt(P, "worksAt", C), ctx(Start, End, Source)).

여기서 볼 부분은 두 번째 줄입니다.

employment의 두 번째 컬럼을 stmt(P, "worksAt", C) 패턴으로 구조분해하고, 세 번째 컬럼을 ctx(Start, End, Source) 패턴으로 구조분해합니다. 별도의 reifies relation도, context relation도, ID 조인도 없습니다.

현재 재직 회사를 묻는 규칙은 더 짧습니다. 여기서는 종료일이 0이면 현재 재직 중이라고 두겠습니다.

.decl current_company(person: symbol, company: symbol)

current_company(P, C) :- employment_at(P, C, _, 0, _).

출처별 근무 기록도 같은 방식으로 꺼냅니다.

.decl from_source(person: symbol, company: symbol, source: symbol)

from_source(P, C, Source) :- employment_at(P, C, _, _, Source).

전체 Datalog 프로그램은 다음과 같습니다.

.decl employment(id: symbol, st: stmt/3 side, ctx: ctx/3 side)
.decl employment_at(person: symbol, company: symbol, start: int64, end: int64, source: symbol)
.decl current_company(person: symbol, company: symbol)
.decl from_source(person: symbol, company: symbol, source: symbol)

employment_at(P, C, Start, End, Source) :-
    employment(_, stmt(P, "worksAt", C), ctx(Start, End, Source)).

current_company(P, C) :- employment_at(P, C, _, 0, _).
from_source(P, C, Source) :- employment_at(P, C, _, _, Source).

RDF 1.2에서는 triple term 덕분에 reification이 덜 장황해졌습니다. compound term을 쓰면 Datalog relation 안에서도 비슷한 식으로 구조를 유지할 수 있습니다.

pyrewire로 실행해보기

아래 코드는 로컬에서 pyrewire로 실행해 확인한 코드입니다. stmtctxside compound로 선언했습니다. 그래서 Python 쪽에서는 make_compound()로 compound 값을 만들고, employment relation에는 그 handle을 넣습니다.

from pyrewire import EasySession, ColumnType, CompoundArg

SRC = """
.decl employment(id: symbol, st: stmt/3 side, ctx: ctx/3 side)
.decl employment_at(person: symbol, company: symbol, start: int64, end: int64, source: symbol)
.decl current_company(person: symbol, company: symbol)
.decl from_source(person: symbol, company: symbol, source: symbol)

employment_at(P, C, Start, End, Source) :-
    employment(_, stmt(P, "worksAt", C), ctx(Start, End, Source)).

current_company(P, C) :- employment_at(P, C, _, 0, _).
from_source(P, C, Source) :- employment_at(P, C, _, _, Source).
"""

def load(session):
    works_at = session.intern("worksAt")
    hr = session.intern("HR_Database")
    person = session.intern("김철수")
    samsung = session.intern("삼성전자")
    naver = session.intern("네이버")

    stmt1 = session.make_compound("stmt", [
        CompoundArg(ColumnType.STRING, person),
        CompoundArg(ColumnType.STRING, works_at),
        CompoundArg(ColumnType.STRING, samsung),
    ])
    ctx1 = session.make_compound("ctx", [
        CompoundArg(ColumnType.INT64, 20200101),
        CompoundArg(ColumnType.INT64, 20221231),
        CompoundArg(ColumnType.STRING, hr),
    ])

    stmt2 = session.make_compound("stmt", [
        CompoundArg(ColumnType.STRING, person),
        CompoundArg(ColumnType.STRING, works_at),
        CompoundArg(ColumnType.STRING, naver),
    ])
    ctx2 = session.make_compound("ctx", [
        CompoundArg(ColumnType.INT64, 20230301),
        CompoundArg(ColumnType.INT64, 0),
        CompoundArg(ColumnType.STRING, hr),
    ])

    session.insert("employment", ["stmt1", stmt1.handle, ctx1.handle])
    session.insert("employment", ["stmt2", stmt2.handle, ctx2.handle])

with EasySession(SRC) as session:
    load(session)
    print("employment_at =", sorted(session.snapshot("employment_at")))
    print("current_company =", sorted(session.snapshot("current_company")))
    print("from_source =", sorted(session.snapshot("from_source")))

실행 결과는 다음과 같습니다.

employment_at = [
    ('김철수', '네이버', 20230301, 0, 'HR_Database'),
    ('김철수', '삼성전자', 20200101, 20221231, 'HR_Database')
]
current_company = [('김철수', '네이버')]
from_source = [
    ('김철수', '네이버', 'HR_Database'),
    ('김철수', '삼성전자', 'HR_Database')
]

직접 넣은 fact는 employment 두 건뿐입니다.

employment("stmt1", stmt(김철수, worksAt, 삼성전자), ctx(20200101, 20221231, HR_Database))
employment("stmt2", stmt(김철수, worksAt, 네이버),   ctx(20230301, 0,        HR_Database))

나머지 employment_at, current_company, from_source는 규칙으로 나온 결과입니다. 특히 current_companyctx(..., 0, ...) 패턴만 보고 현재 회사를 찾아냅니다.

변경분만 보고 싶으면 snapshot() 대신 step()을 씁니다. pyrewireEasySession은 query 모드와 incremental 모드를 같은 세션에서 섞지 않습니다. 그래서 아래처럼 별도 세션을 열어 확인하는 편이 깔끔합니다.

with EasySession(SRC) as session:
    load(session)
    for relation, row, diff in session.step():
        print(relation, row, diff)

출력은 다음과 같습니다.

employment_at ('김철수', '삼성전자', 20200101, 20221231, 'HR_Database') 1
employment_at ('김철수', '네이버', 20230301, 0, 'HR_Database') 1
current_company ('김철수', '네이버') 1
from_source ('김철수', '삼성전자', 'HR_Database') 1
from_source ('김철수', '네이버', 'HR_Database') 1

diff = 1은 새 fact가 생겼다는 뜻입니다. 나중에 employment fact를 지우면 관련 결과는 diff = -1로 나옵니다. 노트 그래프에 Datalog를 얹었던 글에서 봤던 변경분 출력과 같은 방향입니다.

왜 이 표현이 편한가

compound term을 쓰면 “트리플에 대한 메타데이터”를 다루는 Datalog 코드가 꽤 단순해집니다. 이유는 크게 세 가지입니다.

첫째, 원래 트리플 구조가 흩어지지 않습니다.

stmt(P, "worksAt", C)

이 term 하나가 RDF의 triple term에 해당합니다. P, predicate, C가 별도 relation으로 흩어지지 않습니다.

둘째, 쿼리가 구조분해 패턴으로 바뀝니다.

employment(_, stmt(P, "worksAt", C), ctx(Start, End, Source))

이 한 줄은 worksAt 진술만 골라서, 그 주어와 목적어와 맥락을 꺼낸다는 뜻입니다. RDF/SPARQL로 치면 triple term과 annotation을 한 번에 펼치는 쿼리에 가깝습니다.

셋째, 애플리케이션 내부 표현으로 가볍습니다.

RDF를 외부와 교환해야 한다면 Turtle 1.2나 RDF dataset을 쓰는 편이 맞습니다. 표준 도구, triple store, SPARQL 생태계와 이어지기 때문입니다. 반대로 애플리케이션 내부에서 “진술과 그 맥락”을 빠르게 검사하고 조금씩 갱신하고 싶다면, compound term 쪽이 가볍습니다.

이 관계를 그림으로 보면 이렇습니다.

graph LR
    A["RDF 1.1<br/>rdf:Statement"] --> B["RDF 1.2<br/>triple term"]
    B --> C["wirelog<br/>stmt(S, P, O)"]
    C --> D["Datalog pattern<br/>stmt(P, worksAt, C)"]

    A2["rdf:subject<br/>rdf:predicate<br/>rdf:object"] --> A
    B2["rdf:reifies<br/>&lt;&lt;(S P O)&gt;&gt;"] --> B
    C2["compound column<br/>stmt/3 side"] --> C

물론 둘이 같은 것은 아닙니다. RDF 1.2 triple term은 RDF abstract data model의 일부이고, 웹 표준입니다. wirelog compound term은 Datalog 엔진 안에서 쓰는 값입니다. 하나는 교환 포맷과 의미론의 문제이고, 다른 하나는 실행 엔진의 표현 문제입니다.

다만 이 예제에서는 비슷한 효과가 납니다. RDF 1.2에서는 ex:김철수 ex:worksAt ex:삼성전자라는 트리플을 rdf:subject, rdf:predicate, rdf:object 세 조각으로 풀지 않습니다. triple term 하나로 가리킵니다.

wirelog 쪽에서도 마찬가지입니다. 김철수, worksAt, 삼성전자를 별도 relation에 흩어 놓고 ID로 다시 조인하지 않습니다. stmt(김철수, worksAt, 삼성전자)라는 compound term 안에 함께 둡니다. 그래서 규칙에서는 stmt(P, "worksAt", C)처럼 바로 꺼내 쓸 수 있습니다.

언제 RDF 1.2를 쓰고, 언제 compound term을 쓰나

외부 지식 그래프와 주고받아야 한다면 RDF 1.2 표현이 자연스럽습니다. 여기서 “주고받는다”는 것은 다른 triple store, 다른 팀의 ontology, 또는 RDF를 입력으로 받는 분석 도구에 데이터를 넘긴다는 뜻입니다.

예를 들어 wirelog 안에서는 다음처럼 들고 있던 값을

employment("stmt1", stmt(김철수, worksAt, 삼성전자), ctx(20200101, 20221231, HR_Database))

외부로 내보낼 때는 RDF 1.2 Turtle로 바꿀 수 있습니다.

ex:stmt1 rdf:reifies <<( ex:김철수 ex:worksAt ex:삼성전자 )>> ;
    ex:startDate "2020-01-01"^^xsd:date ;
    ex:endDate   "2022-12-31"^^xsd:date ;
    ex:source    ex:HR_Database .

이 형태라면 RDF 도구는 ex:김철수, ex:worksAt, ex:삼성전자, ex:startDate를 모두 IRI로 이해합니다. 다른 그래프와 합치거나, SPARQL로 조회하거나, ontology에 정의된 ex:worksAt의 의미를 따라갈 수 있습니다. 반대로 외부 RDF에서 이 데이터를 읽어와 내부 규칙 엔진으로 넘길 때는 rdf:reifies의 triple term을 읽어 stmt(김철수, worksAt, 삼성전자)로, 나머지 속성을 ctx(...)로 옮기면 됩니다.

반대로 애플리케이션 내부에서 추론하고, 쿼리 결과를 변경분으로 받아보고, Python 값으로 바로 넣고 빼고 싶다면 compound term이 더 직접적입니다. 이 경우에는 매번 Turtle 문자열을 만들고 RDF 파서에 태우는 단계가 필요 없습니다.

employment_at(P, C, Start, End, Source) :-
    employment(_, stmt(P, "worksAt", C), ctx(Start, End, Source)).

두 표현은 경쟁한다기보다 쓰임새가 다릅니다.

목적 적합한 표현
표준 RDF 데이터 교환 RDF 1.2 Turtle / triple term
triple store와 SPARQL 쿼리 RDF 1.2 / SPARQL 1.2
애플리케이션 내부 규칙 평가 Datalog relation
구조가 있는 진술을 relation 안에서 다루기 wirelog compound term
변경분만 보고 반응하기 wirelog incremental step()

지난 글에서 Reification을 설명할 때의 결론은 “트리플 자체에 대해 말할 수 있어야 한다”였습니다. RDF 1.2는 이 요구를 표준 문법과 데이터 모델 안으로 더 가까이 끌어옵니다. wirelog의 compound term은 같은 요구를 Datalog 실행 모델 안에서 다룹니다.

문제는 결국 같습니다. 현실의 사실은 대개 벌거벗은 트리플 하나로 끝나지 않습니다. 언제, 누가, 어떤 근거로, 어느 기간 동안 참인지가 따라옵니다.

RDF 1.1에서는 그 맥락을 붙이려고 트리플을 분해했습니다. RDF 1.2에서는 트리플을 term으로 만들 수 있습니다. 그리고 wirelog에서는 그 term에 해당하는 값을 compound value로 넣고, Datalog 규칙에서 구조분해해 쿼리할 수 있습니다.

표현이 짧아졌다는 것은 단순히 글자 수가 줄었다는 뜻이 아닙니다. 사람이 생각하는 단위와 코드가 다루는 단위가 조금 가까워졌다는 뜻입니다. Reification이 어렵게 느껴졌던 이유도, 어쩌면 그 간격이 너무 컸기 때문인지 모릅니다.


참고

관련 글