설계 문서 · 구현 진행 중

edisco-graph

eDiscovery 커뮤니케이션 관계망을 온톨로지 기반 지식그래프로 재구성하고, 실무자가 탐색할 수 있게 만드는 도구.

최종 수정 2026-09-07 코퍼스 Enron · 150 커스토디언 · 517,401 파일 스택 Python · RDF/OWL · Cytoscape.js 진행 10개 중 7개 · Task 8 진행 중

00 확정된 것

브레인스토밍에서 결정된 전제. 뒤집으려면 아래 설계도 함께 바뀝니다.

항목결정
목적실무자가 실제로 쓰는 제품. 논문용 벤치마크가 아님
공략 지점인물·조직 관계망 분석 — 엔티티 해소와 커뮤니케이션 네트워크
입력 데이터Enron 공개 코퍼스로 시작. 실데이터 연동은 이후
핵심 동작그래프 시각적 탐색
온톨로지 수준하이브리드 — RDF/OWL로 정의, property graph로 투영
v1 범위수직 슬라이스. 커스토디언 상위 N명을 끝까지 관통 — N은 아래 실측을 보고 확정 중

01 코퍼스 실측 측정 완료

설계 단계의 추정치를 실제 데이터로 대체한 결과. 하나는 크게 틀렸습니다.

항목
배포본enron_mail_20150507.tar.gz · 443,254,787 bytes
커스토디언150명
전체 파일517,401
상위 15명 합계240,577 (전체의 46.5%)

스펙의 v1 규모 추정이 5배 틀렸습니다. 설계 문서에 "커스토디언 15~20명 ≈ 5만 통"이라 적었지만, 실제 상위 15명은 24만 파일입니다. 검증 없이 넣은 숫자였습니다. 상위 5명이 약 12.5만, 상위 3명이 약 8.2만, 상위 2명이 약 5.7만입니다. N은 15로 확정했습니다 — 추론기가 상한을 정하는 구조였는데, owlrl 을 버리면서 그 제약이 사라졌습니다 (아래 06 의 P2).

파일 수 상위 15 커스토디언

#커스토디언파일
1kaminski-v28,465
2dasovich-j28,234
3kean-s25,351
4mann-k23,381
5jones-t19,950
6shackleton-s18,687
7taylor-m13,875
8farmer-d13,032
9germany-c12,436
10beck-s11,830
11symes-k10,827
12nemec-g10,655
13scott-s8,022
14rogers-b8,009
15bass-e7,823

상위권이 임원이 아니라 리서치·대외협력·법무 담당이라는 점은 짚어둘 만합니다. 메일 수가 많은 사서함과 사건의 중심 인물은 다릅니다. 관계망 탐색 데모로는 문제없지만, "핵심 인물 자동 랭킹" 같은 기능을 나중에 붙일 때는 통신량만으로 중요도를 매길 수 없다는 근거가 됩니다.

02 아키텍처 확정

7단계 독립 모듈. 각 단계가 결과를 디스크에 남기므로 한 단계를 고쳐도 앞 단계를 다시 돌리지 않습니다.

Enron maildir
   │
   ├─[1] ingest    파싱 → 정규화된 Message 레코드          → SQLite
   ├─[2] extract   주소·표시이름·서명에서 Actor 후보 추출   → SQLite
   ├─[3] resolve   Actor 후보 → Person 클러스터            → SQLite
   ├─[4] ontology  RDF 트리플 생성 + OWL 추론 (owlrl)      → graph.ttl
   ├─[5] project   RDF → property graph 투영 + 지표 계산    → graph.pkl
   ├─[6] serve     FastAPI: ego-network 서브그래프 JSON
   └─[7] ui        Cytoscape.js 렌더링, 클릭 시 확장

하이브리드가 실현되는 지점은 [4]→[5]입니다. RDF/OWL이 온톨로지의 단일 진실 원천이고 추론도 여기서 일어납니다. property graph는 그 결과를 탐색용으로 투영한 것입니다. 단방향이라 두 모델을 양방향 동기화하는 문제가 생기지 않습니다.

Neo4j를 넣지 않는 이유

v1 규모(약 2만 노드 / 20만 엣지)는 networkx 인메모리로 충분합니다. Neo4j를 넣는 순간 Docker·드라이버·Cypher·RDF↔Neo4j 동기화가 따라옵니다. 성능이 실제로 아플 때 [5][6]만 교체하면 되도록 경계를 그어둡니다.

디렉터리

edisco-graph/
├── ontology/ediscovery.ttl    # OWL 스키마 — 사람이 읽고 고치는 파일
├── src/edisco/
│   ├── ingest.py  extract.py  resolve.py
│   └── ontology.py  project.py  api.py
├── web/index.html             # Cytoscape.js 단일 페이지
├── design/index.html          # 이 문서
├── data/                      # gitignore — 원본·중간산출물
└── tests/

프론트엔드는 React 없이 단일 HTML + Cytoscape.js. 빌드 도구도 없습니다. 필요해지면 그때 올립니다.

03 온톨로지와 엔티티 해소 확정

"온톨로지 썼습니다"가 장식이 되지 않으려면, 추론기가 없으면 직접 짜야 했을 코드를 대체해야 합니다.

추론이 밥값을 하는 네 지점

① 별칭 클러스터링 — 핵심, 그리고 측정이 뒤집은 곳

:sameActorAs a owl:SymmetricProperty, owl:TransitiveProperty .

엔티티 해소는 쌍 단위 판단만 내놓습니다. bsmith@enron.com ↔ bob.smith@enron.com, bob.smith@enron.com ↔ robert.smith@ect.enron.com. 이걸 사람 단위 클러스터로 묶는 일을 원래는 이 두 줄 선언으로 추론기에 맡길 계획이었습니다. 실측 결과 그 계획은 폐기했습니다 — 아래 06 섹션의 P2 를 보십시오.

폐포는 ontology.person_clusters 가 union-find 로 직접 계산하고, 결과는 쌍별 완전 그래프가 아니라 Person → resolvesTo → Actor 트리플로 저장합니다. 선언 두 줄은 온톨로지에 남깁니다 — 그게 sameActorAs 의 의미를 규정하는 명세이고, 추론기를 가진 외부 소비자가 우리 결과를 검증할 근거이기 때문입니다. 우리는 그 의미를 직접 구현합니다.

② 하위 속성으로 질의 단순화

:sentTo  rdfs:subPropertyOf :addressed .
:ccTo    rdfs:subPropertyOf :addressed .
:bccTo   rdfs:subPropertyOf :addressed .

"A가 B에게 보낸 모든 것"을 물을 때 세 속성을 OR로 묶지 않습니다. :replyTo가 추가돼도 질의는 그대로입니다.

③ 하위 클래스로 역할 계층:Executive ⊑ :Employee. "임원과 소통한 사람"에 CEO·CFO·VP가 자동 포함됩니다. :Counsel은 나중에 특권 문서 탐지로 확장할 때 재사용됩니다.

④ domain/range로 오류 검출:sentMessage의 domain이 :Person인데 :Organization이 들어오면 파이프라인 버그입니다. 일관성 검사가 무료 테스트가 됩니다.

핵심 클래스

클래스의미
:Actor원본에서 관찰된 신원 표현 — 이메일 주소 1개 + 표시이름들
:Person해소된 실제 사람. 여러 Actor가 하나로
:Organization조직·부서
:Message개별 이메일
:ThreadIn-Reply-To/References로 묶인 대화
:RoleExecutive / Trader / Counsel 등

ActorPerson을 분리하는 게 중요합니다. 원본에 있던 것(관찰)과 우리가 판단한 것(추론)을 섞으면, 나중에 "왜 이 둘을 같은 사람으로 봤나"에 답할 수 없습니다.

블로킹 — 전수 비교는 돌지 않는다

커스토디언 15명이면 actor 가 만 단위로 나옵니다. 모든 쌍을 비교하면 억 단위이고 쌍마다 SQL 을 때리므로 파이프라인이 끝나지 않습니다. 같은 블록에 든 쌍만 비교합니다.

블록 키는 표시이름의 성, 로컬파트 토큰, 그리고 로컬파트에서 첫 글자를 뗀 형태입니다. 마지막 것이 핵심입니다 — jskillingskilling 블록에 들어가서 표시이름 "Skilling, Jeff" 를 가진 jeff.skilling 과 만납니다. 60명을 넘는 블록은 버립니다 (enron 같은 토큰이 만드는 블록은 아무것도 구별해주지 않습니다).

블로킹은 재현율을 깎습니다. 버려진 블록 말고는 아무 키도 공유하지 않는 참 쌍은 후보에 아예 들어오지 못하고, 그러면 점수화 규칙을 어떻게 고쳐도 잡히지 않습니다. 그래서 평가가 재현율을 두 조각으로 나눠 보고합니다 — blocked_out(블로킹이 후보로 만들지도 않은 쌍)과 나머지(점수화가 놓친 쌍)입니다. 섞어놓으면 어디를 고쳐야 하는지 알 수 없습니다.

해소 4단계

  1. 정규화 — 주소 소문자화, 서브도메인 통합(@ect.enron.com → @enron.com), 표시이름을 성, 이름으로
  2. 강한 증거 → 즉시 병합 — 동일 주소, 또는 표시이름 완전일치 + 동일 도메인
  3. 약한 증거 → 점수화 — 이름 유사도(Jaro-Winkler), 로컬파트↔이름 매칭(bsmith ↔ Bob Smith), 그리고 공통 연락처 중복도 — 같은 사람들과 소통하면 동일인 확률이 오릅니다. 그래프 구조를 해소에 되먹이는 부분이고, 순수 문자열 매칭 대비 개선을 보여줄 지점입니다
  4. 임계값 초과 → :sameActorAs 생성 → union-find 가 Person 클러스터로 닫음. 클러스터가 상한(MAX_CLUSTER_SIZE)을 넘으면 자동 수용하지 않고 과병합 의심으로 따로 보고합니다 — 이행 폐포는 잘못된 쌍 하나로 두 클러스터를 이어붙이므로, 상한이 없으면 "이 500개 계정이 다 같은 사람"이라는 주장을 조용히 받아들이게 됩니다

근거 보존 — 법률 도메인에서는 필수

:merge_0142 a prov:Activity ;
    prov:used   :actor_bsmith, :actor_robert_smith ;
    :rule       "localpart_name_match" ;
    :confidence 0.87 .

실무자가 "이 두 계정을 왜 동일인으로 묶었냐"고 물으면 답할 수 있어야 합니다. 답 못 하는 자동화는 법률 영역에서 안 쓰입니다. 그리고 이 기록이 있으면 신뢰도 낮은 병합만 골라 사람이 검토하는 화면을 만들 수 있습니다.

정확도 확인

수작업 라벨링한 골드셋 250쌍으로 precision/recall을 측정합니다. 표본은 세 갈래입니다 — 신뢰도 최하위 병합 100쌍(정밀도), 후보에는 들어왔지만 임계값을 못 넘은 100쌍(점수화 재현율), 그리고 블로킹 밖의 동일-성 50쌍(블로킹 재현율). 세 번째가 없으면 blocked_out 이 셀 대상 자체가 존재하지 않습니다.

픽스처의 precision 1.0 은 근거가 아닙니다. 미니 픽스처를 실제로 돌리면 후보 쌍이 1개(jeff.skilling ~ jskilling)이고, 골드셋의 음성 4쌍은 블로킹에서 걸러져 점수화 함수에 닿지도 않습니다. tp=1 · fp=0 의 산술일 뿐입니다. 픽스처가 확인하는 것은 배선이 이어졌는지이고, 실제 정밀도·재현율은 아직 측정되지 않았습니다 — Task 8 이 돌아야 나옵니다.

그리고 이 픽스처의 유일한 양성 쌍이 하필 가장 위험한 규칙의 사례입니다. {이름 첫 글자}{성} 형태를 0.90 으로 인정하는데(임계값 0.85), jsmith 는 John Smith 일 수도 Jane Smith 일 수도 있습니다. 규칙을 조이면 픽스처의 양성이 사라지므로, 조이기 전에 실데이터에서 이 규칙이 만드는 오탐 비율을 먼저 봐야 합니다. 오탐 경로 자체는 테스트로 못 박아두었습니다.

04 탐색 UI A 확정

노드를 그대로 다 그리면 아무것도 읽을 수 없는 털뭉치가 됩니다. 세 안을 놓고 A(앵커 + 점진 확장) 로 정했습니다.

++

A. 앵커 + 점진 확장  채택

인물을 검색해 하나 고르면 그 사람의 이웃만. 클릭할 때마다 한 겹씩 자람. 화면엔 사용자가 직접 펼친 것만.

TradingLegalExec

B. 조직 집약 → 드릴다운

부서 덩어리 10~20개로 시작. 선 굵기가 통신량. 전체 구도를 먼저 잡고 조직을 열어 개인으로.

C. 리스트 주도 + 그래프 보조

왼쪽 검색·필터·인물 랭킹이 주인공. 오른쪽 그래프는 근거를 확인하는 창.

A를 고른 이유: 엔티티 해소의 가치가 화면에 바로 드러나고, B는 조직 소속 추론이 선행돼야 해서 v1 범위가 커지며, A가 가장 작습니다(API 2개). 다만 "실무자가 찾을 사람을 이미 알고 시작한다"는 전제는 검증되지 않은 추측입니다. 실사용 피드백에서 뒤집히면 B를 재검토합니다.

A안 상세 — 세 가지 설계 결정

① 이웃을 무엇으로 자를 것인가 — 1-hop 이웃이 수백 명일 때 상위 15명만 보여주는데, 기준이 단순 메시지 수면 안 됩니다. Enron 에서 그렇게 하면 상위권이 메일링리스트·자동알림·뉴스레터로 채워집니다. 기본값은 양방향 가중치 min(A→B, B→A) — 한쪽으로만 쏟아지는 브로드캐스트는 자동으로 밀려나고 실제로 주고받은 관계가 올라옵니다.

② 확장 폭발 방지 — 노드 상한 150개. 초과하면 확장을 막고 접기를 안내합니다. 확장 취소(undo)를 제공하고, 자동으로 접지 않습니다 — 무엇이 사라졌는지 모르면 혼란스럽습니다.

③ 비교 장치를 UI에 내장 — 우측 상단 온톨로지 토글. ON 이면 Person 단위(별칭 통합), OFF 면 Actor 단위(이메일 주소 그대로). 같은 인물을 검색한 채로 토글하면 노드가 합쳐지고 엣지가 굵어지는 게 눈앞에서 일어납니다. 성능 향상을 말로 설명할 필요가 없어지고, 동시에 엔티티 해소가 틀렸을 때 바로 보이는 디버깅 도구가 됩니다.

API 두 개

GET /api/search?q=skilling
    → [{ person_id, display_name, alias_count, msg_count }, …]

GET /api/ego?person=P0042&limit=15&resolved=true
    → { nodes: [...], edges: [...] }

전체 그래프는 클라이언트로 보내지 않습니다. 각 Person 의 상위 이웃 리스트는 [5] project 단계에서 미리 계산해두므로 요청 시엔 조회만 합니다.

05 구현 진행 상황

태스크마다 새 에이전트가 구현하고, 별도 리뷰어가 diff 를 검증합니다. 리뷰가 통과할 때까지 다음 태스크로 넘어가지 않습니다.

#태스크상태비고
1프로젝트 뼈대 · 경로✅ 완료수정 1라운드
2maildir 파싱✅ 완료수정 1라운드 · Critical 2건
3정규화 · Actor 추출✅ 완료수정 1라운드 · Critical 1건 · Important 1건
4엔티티 해소✅ 완료블로킹 · Jaro-Winkler · 골드셋 평가
5온톨로지 · 추론✅ 완료union-find 폐포 · Person 노드 · RDFS 는 선택적
6property graph 투영✅ 완료weight = min · 일방향은 합계로 살린다
7API✅ 완료ego 서브그래프 · 노드 상한 · 모드별 프로필
8실데이터 파이프라인🔄 진행 중파이프라인 완주 · 골드셋 라벨링 전
9탐색 UI대기
10문서 갱신 · 배포대기

현재 테스트 126개 통과 (경로 3 · 인제스트 22 · 정규화 8 · 추출 3 · 해소 32 · 온톨로지 8 · 투영 14 · API 12 · CLI 8 · 코퍼스 픽스처 8 · 골드셋 후보 8) + JS 30개.

픽스처가 코퍼스를 대표하지 않으면 테스트는 아무것도 보장하지 않습니다. Task 1~7 이 전부 초록인 동안 파이프라인은 실데이터에서 0건을 넣고 0건을 해소했습니다. 미니 픽스처가 코퍼스와 두 번 멀어졌기 때문입니다 — 파일명에서 마침표를 지웠고, 코퍼스에 거의 없는 표시이름을 넣었습니다. 두 번 다 테스트를 통과시키려는 수정이었습니다. 지금은 코퍼스 성질을 재현하는 픽스처를 따로 두고 (마침표 파일 · 맨 주소 헤더 · 깨진 로컬파트 · 사본 · 함정 5종) 그 위에서 확인합니다. 전체 경위는 docs/reflexion/2026-09-08-process-reflexion.yaml 에 실패 12건 · 패턴 4개 · 조치 5개로 정리했습니다.

실데이터 (커스토디언 15명 · 240,577 파일)

세 번 돌렸습니다. 첫 실행은 해소가 0건이었고, 규칙을 고친 두 번째에 4,299쌍이 나왔고, 내용 해시 중복 제거를 넣은 세 번째가 현재 수치입니다.

항목1차현재
메시지240,577100,827 (사본 139,750 접음)
custody 행240,577240,577 (사본 전부 보존)
참여(participation)2,203,610843,040
actor36,28836,288
same_actor 쌍04,299
Person36,288 (= actor)32,840
person / actor 노드36,069 / 36,06932,643 / 36,069
가장 두꺼운 엣지5,4411,856
RDF 트리플2,626,470 · 180MB1,145,048 · 75MB
과병합 클러스터00
전체 소요약 27분약 13분

토글이 드디어 차이를 보여줍니다. person 노드 32,643 대 actor 노드 36,069 — 3,426개 차이입니다. 1차 실행에서는 두 그래프가 같은 크기여서, 이 제품의 핵심 주장이 실데이터에서 아무것도 보여주지 못했습니다. 해소가 무엇을 고쳐서 살아났는지는 아래 06 에 적었습니다.

아직 정밀도는 모릅니다. 4,299쌍이 나왔다는 것과 그것이 옳다는 것은 다른 문제입니다. 지금 믿을 수 있는 것은 과병합 클러스터가 0개이고 클러스터 최대 크기가 8명이라는 것 정도입니다.

골드셋 라벨링 후보 250쌍 — 층 설계를 측정으로 다시 잡았습니다

계획의 세 층 중 둘이 실데이터에서 비어 있었습니다.

뽑는 법
정밀도150규칙별 층화 — 신뢰도 낮은 순으로 뽑으면 0.85 규칙 하나로만 채워집니다
재현율100이름 형태 · 토큰 공유 · 미연결, 로컬파트 유사도 내림차순

재현율 층의 상위가 규칙의 빈 곳을 그대로 보여줍니다philip ~ phillip, pete ~ peter, stace ~ stacey, edyeland ~ eydeland. 전부 같은 사람인데, 지금 규칙은 오타와 단축형을 전혀 다루지 않습니다. 이게 다음에 손댈 곳입니다.

행마다 판정 근거를 함께 싣습니다 — 로컬파트 유사도, 공통 상대방 수, 양쪽의 발신·수신 수, 표시이름. 주소만 주면 사람도 규칙과 같은 추측을 하기 때문입니다. adel.rizkalla ~ rizkalla.adel 은 공통 상대방이 74명이고, aagrawal@cba.ua.edu ~ aagrawal@haas.berkeley.edu 는 0명입니다.

06 리뷰가 찾은 것 기록

구현을 시작한 뒤 드러난 결함들. 대부분 계획 문서 자체의 오류였고, 구현자가 계획을 충실히 옮긴 결과 그대로 코드에 들어왔습니다. 각각 판정하고 계획을 고쳤습니다.

사전 점검에서 (구현 시작 전)

Task 2 리뷰에서

eDiscovery 에서 custody 는 증거입니다. 누가 그 문서를 보관하고 있었는지는 사건에 관한 사실이고, 한 번 버리면 복원할 수 없습니다. message_custodian 테이블을 추가했습니다 — 지금 넣는 비용은 테이블 하나와 삽입 한 줄이고, Task 8 이 돈 뒤에 넣는 비용은 코퍼스 전체 재처리입니다.

Task 3 리뷰에서

Task 4 리뷰에서

Task 8 실행에서 — 해소가 0건이던 이유와 그 뒤

파이프라인이 끝까지 돌았지만 해소가 아무것도 찾지 못했습니다. 성능 문제가 아니라 설계가 없는 데이터를 전제하고 있었습니다.

고친 결과: 주소 구조로 판단하는 규칙 5개를 넣고 (localpart_punctuation 0.95 · same_localpart_multi_token 0.93 · localpart_reversed 0.92 · initial_plus_last 0.88 · same_localpart_single_token 0.85) 4,299쌍이 나왔습니다. 가드가 먼저 필요했습니다 — 로컬파트가 깨진 actor 가 1,903명(5.2%) 있고, 가드 없이 돌리면 .aaron@enron.com 이 서로 다른 세 명의 Aaron 을 한 사람으로 묶습니다.

shared_correspondents 의 이름 게이트는 떼지 않았습니다. 떼자는 안을 검토했다가 측정 결과로 철회했습니다 — douglas.clifford ~ douglas.dunn, alison.keogh ~ alison.moss 가 병합됩니다. 최소 상대방 수를 걸어도 남습니다. 동료는 필연적으로 상대방을 공유하므로, 구조 신호만으로는 "같은 사람"과 "같은 팀"이 구별되지 않습니다.

중복 제거 — 전제가 이중으로 틀렸습니다

키는 내용 해시로 정했습니다 — blake2b(date, from, to, cc, subject, body). message_idsource_path 는 뺍니다. 사본마다 다르고, 그게 이 키가 필요한 이유입니다. 후보 네 개가 58.1~58.6% 로 수렴하므로 더 접는 쪽이 아니라 덜 틀리는 쪽을 골랐습니다 — 본문만으로 접으면 1,214건을 더 접는데, 그건 내용이 같지만 다른 시점에 보낸 메일까지 합치는 것입니다.

접되 custody 는 사본마다 남깁니다. message 100,827행에 custody 240,577행 — 원본 파일 수와 같습니다. 그래야 message_custodian 이 비로소 계획이 의도한 일을 합니다: "Organizational Changes 라는 이 한 통을 12명이 35개 사본으로 보관하고 있었다". 지금까지는 메시지당 정확히 1행이라 아무 일도 하지 않았습니다. 그리고 사본이 접히면서 가장 두꺼운 엣지가 5,441 → 1,856 로 떨어졌습니다 — 부풀림이 실재했다는 증거입니다.

Task 6·7 구현에서

계획 감사에서

Task 5 재설계가 Task 6·7·8 에 온전히 반영됐는지, 그리고 계획에 적힌 숫자가 맞는지 픽스처에서 재계산했습니다.

실측이 뒤집은 것

07 환경 제약

개발 머신이 기관 프록시 뒤에 있어 도구가 기본 설정으로 동작하지 않습니다.

증상대응
pipSSLCertVerificationError 로 죽음 --trusted-host pypi.org --trusted-host files.pythonhosted.org --trusted-host pypi.python.org
curl 이 exit 35 (SSL) 로 실패 PowerShell Invoke-WebRequest — Windows 인증서 저장소를 씀
파일명 끝 마침표를 pathlib 이 못 봄 열거는 os.walk, 열기는 확장 경로 (src/edisco/fsutil.py)

프록시가 TLS 를 가로채기 때문입니다. CI 로 옮기면 이 우회는 불필요해집니다. 매번 플래그를 붙이는 대신 pip.ini 에 기록하거나 기관 CA 인증서를 PIP_CERT 로 지정하는 편이 낫습니다.

마침표 문제는 픽스처 관례가 아니라 코퍼스 전체의 문제였습니다. Enron 코퍼스의 메일 파일은 전부 1. 처럼 마침표로 끝납니다 (allen-p 한 명만 봐도 628/628). Windows 는 경로를 정규화하면서 끝의 마침표를 떼기 때문에 Path(...).is_file() 이 False 가 되고, rglob("*") 로 훑으면 파일이 하나도 안 잡힙니다. 예외가 나지 않는다는 것이 이 문제의 핵심입니다 — 파이프라인이 0 건을 넣고 성공한 것처럼 끝납니다. 실제로 첫 실행에서 커스토디언 파일 수가 전부 0 으로 나왔고, 동점이 이름 역순으로 갈려 상위 15명이 zufferli-j, zipper-a, … 로 뽑혔습니다. 조용히 틀린 답을 내는 종류의 결함입니다.

08 남은 논의

닫힌 것

열려 있는 것