본문으로 건너뛰기

온톨로지 · 그래프 — 회의를 넘는 맥락

갱신일 2026-08-12 · 기준: 확장 개요 · 도메인 모델 · 현재 판 ERD 이 문서가 갖는 것: 그래프에 무엇을 노드로 두고 무엇을 두지 않는가, 관계는 무엇이며 누가 만드는가, 어떤 Cypher가 돌고 어디서 잘리는가. 여기 없는 것: 저장소 소유권 · 원본/파생 경계(DA §1) · 물리 스키마(ERD) · Neo4j 운용(CA).

⚠︎ 이건 계획이지 현황이 아니다. 지금 그래프는 없다. 도메인 용어의 정본은 도메인 모델이 갖는다.

1. 그래프를 두는 이유는 검색이 아니라 연결이다

벡터 검색은 비슷한 것을 찾는다. 그래프는 이어진 것을 찾는다. 이 둘은 대체재가 아니다.

질문벡터그래프
"인증 관련 회의 찾아줘"
"이 결정이 뭘 뒤집었나"
"이 할 일은 어느 결정에서 나왔나"
"비슷한 논의가 전에 있었나"
"김민수가 맡은 것 중 막힌 것"

뒤집힘·유래·담당은 관계다. 텍스트 유사도로는 절대 안 나온다. 그게 Neo4j를 두는 이유 전부이고, 그 외의 것에 그래프를 쓰면 비싼 조인이 된다.

두 저장소는 순서대로 쓴다 — 벡터가 후보를 만들고 그래프가 그 후보의 관계를 판정한다(§7 (a)). 그래프가 후보를 만들게 하면 전체 순회가 되고, 벡터가 관계를 판정하게 하면 틀린다.

2. 노드 — 여덟 개, 더 늘리지 않는다

노드왜 노드인가
Organization · Workspace · Project순회 스코프를 자르는 울타리Postgres id
Meeting모든 사실이 착지하는 곳noteId
Person담당·참석의 주체userId
Decision뒤집힘 이력이 관계로만 표현된다decisions.id
ActionItem유래·의존·담당이 전부 관계다action_items.id
ExternalItem외부 도구와의 접점 (Linear 이슈 · GitHub PR)provider + 외부 id

정본은 두 테이블이다decisionsaction_items(ERD §5). 노드 키가 그 PK 라서 재분석을 해도 안 바뀐다. meeting_items(kind) 에 계속 매달았으면 재분석마다 키가 갈리고 사람이 확인해 둔 SUPERSEDES 사슬이 그때마다 끊긴다 — 그 이유로 ERD가 두 테이블로 올렸다.

노드 속성 — Postgres 어느 칼럼에서 오나

노드속성타입Postgres
모두idstring(13)각 테이블 PK (TSID). 그래프 자체 id를 만들지 않는다 — 만드는 순간 매핑 표가 생긴다
workspaceIdstring(13)조상에서 비정규화. 스코프를 순회가 아니라 술어로 자른다(§7)
sourceUpdatedAt · projectedAtdatetime<table>.updated_at · 투영 시각. 덮어쓰기 가드(§6)와 드리프트 검사
Meetingtitle · statusstringnotes.title · notes.meeting_status
projectIdstring(13)notes.project_id비정규화. URL에 없는 축이지만 그래프 필터에는 있다
endedAtdatetime?transcription_sessions.ended_at 의 최댓값
PersondisplayNamestringusers.name. 이메일은 안 넣는다 — 그래프에 개인정보를 늘리지 않는다
Decision
ActionItem
noteIdstring(13)decisions.note_id · action_items.note_id
statusstringdecisions.status (PROPOSED·CONFIRMED·REJECTED) · action_items.status (+ DONE)
sourcestringdecisions.origin (AI·USER)
evidenceSegmentIdslist<string>evidence.transcript_segment_id · 최대 3 (position CHECK 0..2)
statedAtMsintdecisions.stated_at_ms · ActionItemevidence 가 가리키는 transcript_segments.started_at_ms 최솟값
confidencefloat?decisions.confidenceorigin = 'AI' 일 때만
ExternalItemprovider · externalId · urlstringtool_connections.provider + 외부 시스템

content 를 그래프에 두지 않는다. 순회는 id만 돌려주고 본문은 Postgres에서 WHERE id IN (...) 한 번으로 붙인다. 버린 대안 둘 — 본문 캐시는 사람이 교정하면 두 곳을 고쳐야 하고, 화자 이름을 항목에 박으면 S1의 화자 4 → 최유진 교정마다 그래프를 다시 써야 한다.

관계 속성

관계속성타입정본만드는 주체
CONTAINS · ATTENDED · PRODUCED없음FK · note_participants · decisions.note_id·action_items.note_id자동
DERIVES_FROM없음action_items.derived_from_decision_id자동
SUPERSEDESstatusstringdecision_relations.status (PROPOSED·CONFIRMED·REJECTED) · type = 'SUPERSEDES'사람(§5)
decidedBy · decidedAtstring(13)? · datetime?decision_relations.decided_by_user_id · .decided_at
BLOCKSstatusstring미확인decision_relations.typeSUPERSEDES·RELATES_TO 둘뿐이다(§10)사람(§5)
ASSIGNED_TOdueDatedate?action_items.assignee_user_id · .due_at자동
FOLLOWSgapDaysint없다. 계산해서 만든다 — 같은 project_id 안에서 시각이 인접한 노트자동
LINKED_TOsyncedAtdatetimetool_connections + 외부 id자동

SUPERSEDES 의 정본은 decision_relations — 테이블·칼럼·상태 값 집합을 ERD §5가 정했다. 관계 상태는 boolean 둘이 아니라 status 하나이고, REJECTED 행은 지우지 않고 굳힌다(§5). BLOCKS 정본과 관계의 AI 확신도는 아직 없다(§10). 그래프에만 있는 관계를 만들면 D7이 깨진다.

노드로 만들지 않는 것 — 이게 더 중요하다

안 만드는 것
Utterance (전사 세그먼트)회의당 수백 개다. 노드로 만들면 수백만 개가 되고 순회가 즉시 터진다. 근거는 속성으로 붙인다(§4)
Topic§3
Summary회의의 속성이지 독립 개체가 아니다
Message (챗봇)그래프에 넣을 이유가 없다
게스트(S2 이정훈(외부))계정이 없다. 그 노트 안의 라벨일 뿐이라 Person 을 만들지 않는다

스키마를 늘려야 할 때 — 일곱 단계

"이걸로 어떤 질문에 답하나"를 먼저 묻는다. 관계로 표현되지 않는 질문이면 그건 Postgres 컬럼이지 노드가 아니다.

#단계통과 못 하면
1답하지 못하는 질의를 Cypher로 먼저 쓴다여기서 끝난다. 대부분 여기서 끝난다
2속성·라벨로 되는지 본다된다면 노드를 만들지 않는다
3Postgres에 정본을 먼저 만든다 (테이블·마이그레이션 → ERD)그래프부터 만들면 D7 위반이고 재생성이 죽는다
4투영 코드에 MERGE 를 더한다기존 노드의 투영은 건드리지 않는다
5백필은 재생성으로 한다백필 스크립트를 따로 쓰지 않는다. 재생성(§6)이 이미 있고 분기마다 돌려 본다
6질의에 스코프·상한을 붙이고 §7 표를 갱신한다상한 없는 질의가 하나라도 들어가면 §7이 주장이 된다
7롤백은 재생성이다그래프에는 마이그레이션이 없다. 파생이라 얻는 이득이 여기서 회수된다

Neo4j는 스키마리스라 속성 추가는 옛 코드가 무시하고 지나간다. 속성 제거는 옛 코드가 null 을 만난다 — 읽는 쪽을 coalesce 로 먼저 고치고 두 배포를 벌린다. 명시적으로 관리하는 것은 제약과 인덱스뿐이다(§7).

3. Topic은 v1에 두지 않는다

주제 노드는 그래프 온톨로지에서 제일 유혹적이고 제일 잘 망가진다.

LLM에게 주제를 자유롭게 붙이게 하면 이렇게 된다인증, 로그인 인증, 사용자 인증, auth, Auth 개선 이 전부 다른 노드가 된다. 3개월이면 주제 노드가 결정 노드보다 많아지고, 그 시점에 그래프는 아무것도 이어주지 못한다.

제대로 하려면 워크스페이스별 통제 어휘가 필요하다 — 새 주제는 임베딩 유사도로 병합을 먼저 시도하고, 임계 미달일 때만 만들고, 만들 때 관리자 검토 큐에 올린다. 기능 하나 분량의 일이다.

그래서 v1 에는 안 넣는다. "비슷한 주제"는 벡터가 이미 잘 하고, 그래프는 §1의 오른쪽 열만 맡는다. 통제 어휘를 운영할 사람이 생겼을 때 다시 본다.

4. 모든 사실에는 근거가 붙는다

DecisionActionItem 의 필수 속성. 값의 출처는 §2 표에 있다.

속성왜 필수인가
noteId + evidenceSegmentIds어느 회의 몇 분 몇 초로 되돌아가지 못하는 항목은 만들지 않는다
source추론과 사실을 섞지 않는다 (IA §6)
status사람이 확인했나 (PROPOSEDCONFIRMED)
confidence화면에서 흐리게 표시할 근거
statedAtMs발화 시각. 회의 시각이 아니다. 2시간 회의 안에서도 순서가 있다

근거 없는 노드를 만들지 않는다는 규칙 하나가 이 그래프의 신뢰도를 전부 결정한다. 검증할 수 없는 것은 곧 안 쓰이게 된다. evidenceSegmentIds 는 Postgres를 가리키는 참조일 뿐이고, 그래프는 전사 본문을 갖지 않는다 — DA §1의 파생 규칙이 여기서도 적용된다.

⚠︎ statedAtMs세션 기준 오프셋이다. 노트에 전사 세션이 둘 이상이면 같은 값이 다른 지점을 가리킨다(APP-398 · 현재 판 ERD §6). 투영이 절대 시각으로 바꿔 넣을지는 §10.

5. SUPERSEDES는 사람이 확인한다

(Decision)-[:SUPERSEDES]->(Decision) 이 이 온톨로지에서 제일 값어치 있고 제일 위험하다.

값어치: "이 결정은 3주 전 결정을 뒤집는다"가 이 관계 하나에서 나온다. 결정 이력 화면과 충돌 감지가 전부 여기에 선다.

위험: LLM이 틀리게 이으면 이력이 오염된다. 틀린 요약은 다시 만들면 되지만, 틀린 뒤집힘은 조직이 "우리가 뭘 정했는지"를 잘못 알게 만든다.

규칙
AI는 후보만 제안한다status: 'PROPOSED' 로 만들고 화면에 "이 결정이 X를 뒤집는 것 같다 — 맞나요?"
확인 전에는 순회에 쓰지 않는다순회는 status = 'CONFIRMED' 만 본다. 미확인 관계로 브리핑을 만들면 잘못된 맥락이 퍼진다
부정도 기록한다사용자가 "아니다"라고 하면 status: 'REJECTED'. 행을 지우지 않고 굳혀서 다음 분석이 같은 쌍을 또 올리지 못하게 한다
순환은 투영이 막는다A → B → A 는 데이터 오류다. 확인 시점에 경로를 검사해서 거부한다

BLOCKS 도 같은 취급이다. DERIVES_FROM·ASSIGNED_TO·ATTENDED 는 추출이 훨씬 안전해서 자동으로 둔다.

6. 어떻게 만들어지나 — 추출과 투영

순서가 규칙이다. LLM이 후보를 내면 Postgres에 먼저 쓰고, 거기서 그래프로 투영한다. 그래프에 직접 쓰는 경로는 만들지 않는다 — 만드는 순간 그래프가 두 번째 정본이 되고 재생성이 불가능해진다.

투영 규칙 — 트리거·순서·멱등성

무엇규칙
트리거① 파이프라인 ⑤단계(AA §3) ② 사람의 확인·교정·삭제 ③ 재생성(아래)
경로같은 트랜잭션에서 decisions·action_items·decision_relationsprojection_outbox을 함께 커밋 → 워커가 읽어 Neo4j에 MERGE → 행 삭제
payload(entity_type, entity_id) 뿐이다. 값은 워커가 Postgres에서 다시 읽는다
순서노드 먼저, 관계 나중. 관계는 양끝을 MATCH 로 찾고 없으면 건너뛴다 — MERGE 가 빈 노드를 만들면 안 된다
주기비동기. 회의 종료 후 수 분 안. 실시간이 아니다. 투영이 늦어도 아무도 모른다

payload에 값을 싣지 않는 규칙이 순서 역전을 통째로 지운다 — 이벤트가 뒤바뀌어 도착해도 워커가 읽는 것은 언제나 현재 상태라 결국 수렴한다.

projection_outbox 의 정본은 ERD §9가 갖는다. 아래는 그 정의가 무엇을 지켜야 하는지다.

projection_outbox (entity_type CHECK(DECISION·ACTION_ITEM·RELATION·MEETING·PERSON), entity_id,
attempts, next_attempt_at, parked_at, UNIQUE(entity_type, entity_id))
projection_outboxdeletion_outbox(ERD §9)
언제 도나부모 행이 살아 있을 때부모 행이 사라진 뒤
payload없다 — id로 다시 읽는다S3 키처럼 부모가 갖고 있던 값의 사본
대상Neo4j 하나target CHECK('S3','NEO4J','VECTOR')
완료행 삭제completed_at

성격이 반대라 한 테이블에 못 넣는다. UNIQUE(entity_type, entity_id) 가 같은 엔티티의 미처리 행을 하나로 접는다 — 워커가 언제나 현재 상태를 다시 읽으므로 접어도 잃는 것이 없다. 둘이 만나는 자리는 노트 삭제 하나다: Neo4j 삭제는 deletion_outbox 가 하고, 남아 있던 projection_outbox 행은 워커가 Postgres를 다시 읽고 폐기한다(아래 실패 표).

대안왜 버렸나
트랜잭션 안에서 Neo4j 직접 호출2PC가 없다. Postgres 커밋 성공 + Neo4j 실패 = 조용한 유실. Neo4j 지연이 사용자 응답에 붙는 것은 덤이다
CDC (Debezium 등)인프라가 하나 는다. outbox는 이미 있는 Postgres와 PgSingleFlight advisory lock 패턴을 그대로 쓴다
회의 중 실시간 투영취소·삭제된 회의의 쓰레기가 남는다. C3는 읽기만 한다

멱등성은 MERGEsourceUpdatedAt 가드가 만든다.

MERGE (d:Decision {id: $id})
ON CREATE SET d.projectedAt = datetime()
WITH d WHERE coalesce(d.sourceUpdatedAt, datetime({epochMillis: 0})) <= $updatedAt
SET d += $props, d.sourceUpdatedAt = $updatedAt, d.projectedAt = datetime();

실패하면

실패증상처리
추출 실패요약은 나오고 항목만 없다사람이 직접 추가. 파이프라인은 성공으로 본다
outbox 쓰기 실패항목도 안 써진다(같은 트랜잭션)⑤단계 실패 → 재시도
Neo4j 다운outbox 적체지수 백오프 5회 → parked. 알람은 큐 깊이로 건다. 사용자 작업은 막지 않는다
부분 적용 (노드 O · 관계 X)순회가 이웃을 못 찾는다이벤트 재실행이 멱등이라 그냥 다시 돌린다
노트 삭제(S6) 후 갱신 이벤트유령 노드워커가 Postgres를 다시 읽고 행이 없으면 폐기. 삭제는 MATCH (m:Meeting {id})-[:PRODUCED]->(i) DETACH DELETE m, i
parked 가 쌓임그래프가 조용히 낡는다드리프트 검사(재생성 4단계)를 일 1회. 임계를 넘으면 그 워크스페이스만 재생성

S1을 그래프로 관통 — 10:23:14부터 확인까지

시각무슨 일그래프에 무슨 일
10:00회의 시작아무것도 안 생긴다. Meeting 노드는 ⑤단계에 생긴다
10:23:14이서연 "ElevenLabs로 다시 가시죠"싼 게이트 통과(실시간 §4) → pgvector top-8 → §7 (a) 질의 1회 → 07-22 결정 STT 는 Soniox 로 간다(근거 23:14)가 걸린다. 읽기만 하고 쓰지 않는다. 카드의 근거 링크는 그래프가 준 noteId + statedAtMs
10:45 → ~10:52종료 · 파이프라인 ①~⑤아래가 한 번에 생긴다
⑤단계가 만드는 것개수상태
Meeting1projectId = PRO-1
Person + ATTENDED4 + 4최유진 포함. voice print 미등록은 화자 라벨 문제이지 참석 사실이 아니다
Decision 2 · ActionItem 3 + PRODUCED5 + 5status = PROPOSED · source = AI
ASSIGNED_TO23건 중 1건은 담당자가 안 나왔다. 관계를 억지로 만들지 않는다
FOLLOWS1PRO-1의 직전 회의로
SUPERSEDES 후보1status = PROPOSED — 07-22 결정을 향한다
확인 전김민수가 "맞다"김민수가 "아니다"
Postgresdecision_relationsstatus = PROPOSEDstatus = CONFIRMED · decided_by_user_id·decided_at 채움status = REJECTED — 행은 남는다
Neo4j관계는 있지만 모든 질의가 {status: 'CONFIRMED'} 로 걸러 낸다status: 'CONFIRMED'status: 'REJECTED'
결정 이력 화면07-22 결정이 아직 "살아 있는 결정"체인 2단이 보인다변화 없음
다음 회의의 C307-22 결정이 또 후보로 뜬다§7 (a)의 newer IS NULL 에서 걸러져 안 뜬다같은 쌍은 다시 안 올린다

화자 교정(화자 4 → 최유진)은 그래프를 바꾸지 않는다. 화자는 그래프에 없고 근거 세그먼트 id만 있다 — §2에서 화자를 안 넣기로 한 값이 여기서 회수된다.

재생성 — 스코프는 워크스페이스다

Postgres → Neo4j 전량 재구축 명령이 있어야 한다. 없으면 "파생"은 규칙이 아니라 주장이다.

#단계비고
1그 워크스페이스의 outbox 워커를 일시정지. 들어오는 변경은 큐에 쌓인다쓰기(Postgres)는 계속 받는다
2MATCH (n {workspaceId: $ws}) DETACH DELETE n — 배치로 끊어서Person 도 지운다. 다른 워크스페이스 소속이면 다시 만들어진다
3울타리 → Meeting·PersonDecision·ActionItem → 관계 순서로 적재관계 전에 노드가 다 있어야 한다
4검증 — MATCH (n) WHERE n.workspaceId = $ws RETURN labels(n)[0], count(*) vs SELECT 'Decision', count(*) FROM decisions UNION ALL SELECT 'ActionItem', count(*) FROM action_items불일치면 실패로 본다
5워커 재개. 밀린 큐 소진payload가 id 뿐이라 중복 적용이 안전하다

워크스페이스 하나는 제자리에서 한다 — 그 사이 그 워크스페이스의 그래프 화면만 빈다. 회의·전사·요약은 정상이다. 그래프가 파생이라 이게 성립한다. 전량은 별도 database에 만들고 alias를 스왑한다 — 무중단. 버린 대안: 섀도 라벨 후 교체는 Neo4j에서 원자적이지 않고, 전량 제자리는 몇십 분간 전 고객의 그래프를 비운다.

소요는 추정이다. 회의 1건 ≈ 노드 6 · 관계 10으로 잡으면(§10), 10만 규모 1년치 55,000건×12 → 노드 400만 · 관계 660만. 배치 MERGE 처리율을 1만/초로 가정하면 약 17분, 1만 규모 1년치는 약 2분. 처리율은 실측이 아니다.

분기에 한 번 스테이징에서 통째로 돌려본다(DA §10).

7. 조회 — 순회에 상한을 건다

규칙값 (초기)
최대 깊이3Decision → SUPERSEDES* → Decision 이 대부분 2단이면 끝난다
팬아웃 상한노드당 50큰 프로젝트에서 이웃이 폭발한다
스코프항상 workspace 이하조직 전체 순회를 허용하지 않는다
호출 횟수턴당 1회LLM이 순회를 반복하게 두면 왕복이 폭발한다 (SA §5)
미확인 관계제외§5

상한 없는 순회는 반드시 터진다. 개발 중에는 데이터가 작아서 안 터지고, 제일 큰 고객에게서 터진다. 스코프는 울타리 노드를 지나는 순회가 아니라 술어로 건다 — 한 홉을 아끼고 인덱스가 먹는다.

CREATE CONSTRAINT decision_id IF NOT EXISTS FOR (d:Decision) REQUIRE d.id IS UNIQUE;
CREATE INDEX decision_scope IF NOT EXISTS FOR (d:Decision) ON (d.workspaceId, d.statedAtMs);

(a) S1의 C3 — 10:23:14에 도는 질의. 회의 중에 도는 유일한 그래프 질의다.

MATCH (d:Decision)
WHERE d.id IN $candidateIds // pgvector top-8. 씨앗은 그래프가 만들지 않는다
AND d.workspaceId = $workspaceId // 스코프 술어 · 인덱스
AND d.projectId = $projectId // PRO-1 로 더 좁힌다
AND d.status <> 'REJECTED'
OPTIONAL MATCH (d)<-[:SUPERSEDES {status: 'CONFIRMED'}]-(newer:Decision)
WITH d, newer WHERE newer IS NULL // 이미 뒤집힌 결정은 충돌 대상이 아니다
RETURN d.id, d.noteId, d.statedAtMs
ORDER BY d.statedAtMs DESC
LIMIT 5; // LLM 게이트에 5개까지만 올린다

순회는 한 홉뿐이다. 제일 지연에 민감한 질의라 깊이를 아예 안 준다. LIMIT 5 는 팬아웃 상한이 아니라 비용 상한이다 — 여기 걸린 수가 곧 LLM 게이트 입력이다.

(b) 결정 이력 — SUPERSEDES 체인.

MATCH p = (root:Decision {id: $decisionId})-[:SUPERSEDES*0..3 {status: 'CONFIRMED'}]->(old:Decision)
WHERE root.workspaceId = $workspaceId
RETURN [n IN nodes(p) | n.id] AS chain, length(p) AS depth
ORDER BY depth
LIMIT 20;

*0..3 이 §7의 깊이 상한이고, {status: 'CONFIRMED'}경로의 모든 관계에 걸린다. 미확인이 하나 끼면 거기서 체인이 끊긴다 — 그게 의도다. 상한을 빼고 * 를 쓰면 이력이 긴 워크스페이스에서 경로 수가 지수로 는다.

(c) 김민수에게 밀려 있는 것.

MATCH (:Person {id: $userId})<-[:ASSIGNED_TO]-(a:ActionItem)
WHERE a.workspaceId = $workspaceId
AND a.status IN ['PROPOSED', 'CONFIRMED']
OPTIONAL MATCH (a)<-[:BLOCKS {status: 'CONFIRMED'}]-(b:ActionItem)
WHERE b.status <> 'DONE'
WITH a, collect(DISTINCT b.id)[0..5] AS blockers // 이웃 팬아웃 5
RETURN a.id, a.noteId, a.statedAtMs, blockers
ORDER BY a.statedAtMs DESC
LIMIT 50; // §7 팬아웃 상한

(d) 노트 맥락 배너. 노트를 열 때마다 도는 질의라 p95를 재는 대상이다.

MATCH (m:Meeting {id: $noteId})-[:PRODUCED]->(item)
WHERE item.workspaceId = $workspaceId
OPTIONAL MATCH (item)-[:SUPERSEDES {status: 'CONFIRMED'}]->(prev:Decision)
OPTIONAL MATCH (item)-[:DERIVES_FROM]->(src:Decision)
WITH item,
collect(DISTINCT prev.id)[0..3] AS supersedes,
collect(DISTINCT src.id)[0..3] AS derivedFrom
RETURN item.id, supersedes, derivedFrom
LIMIT 50;

2 홉을 넘지 않는다. 느려지면 배너를 끈다 — 배너가 없어도 노트는 열린다. 그래프 질의가 노트 열기의 임계 경로에 들어가면 안 된다.

안티패턴 — 이 그래프에서 하면 안 되는 질의

하면 안 되는 것대신
MATCH (d:Decision)-[*]-(x) RETURN x상한 없는 가변 길이. 개발 데이터에서는 빠르고 제일 큰 고객에게서 터진다깊이·라벨·방향을 전부 박는다
MERGE (t:Topic {name: $llmOutput})LLM 문자열을 MERGE 키로 쓰면 노드가 무한 분화한다(§3). 한번 퍼지면 되돌리는 방법이 재생성뿐이다유사도는 벡터가 한다
{status: 'CONFIRMED'} 없이 SUPERSEDES 순회미확인 후보가 브리핑·MCP 응답에 섞인다. 기본값에 기대지 말고 질의에 박는다술어를 코드 리뷰 체크리스트에 올린다
MATCH (u:Utterance) WHERE u.text CONTAINS ...전사 본문을 그래프에 두는 순간 노드가 수백만이 되고 Neo4j를 전문 검색기로 쓰게 된다Postgres · pgvector
workspaceId 술어 없는 질의워크스페이스를 넘는 누출. 상한보다 이게 더 위험하다모든 질의에 스코프. 파라미터 없이 부를 수 없게 래퍼를 하나만 둔다

8. 그래프가 여는 기능

그래프가 없으면 못 만드는 것만 적는다.

기능무엇쓰는 관계질의
회의 전 브리핑이 프로젝트에서 지난번에 뭘 정했고 뭐가 열려 있나FOLLOWS · PRODUCED · SUPERSEDES(b)+(c)
결정 충돌 감지방금 정한 게 예전 결정과 부딪힌다SUPERSEDES 후보 탐지(a)
액션 아이템 추적누가 뭘 맡았고 뭐가 막혔나ASSIGNED_TO · BLOCKS(c)
맥락 배너노트를 볼 때 관련 과거가 먼저 나타난다SUPERSEDES · DERIVES_FROM(d)
온보딩"이 프로젝트 지금까지 뭐가 있었나"전부(b)+(c)
외부 동기화액션 아이템 ↔ Linear 이슈 양방향LINKED_TO
결정의 계보이 코드가 왜 이런가 → 결정 → 회의 → 발화근거 속성(b)

결정 충돌 감지는 이미 도메인 모델에 있다. Event Assistant의 개입 조건 C3가 그것이고, 그래프 없이는 구현 방법이 없었다.

마지막 줄은 MCP의 킬러 기능이고 S3가 그 모양이다 — 박준호가 heymoa.search_decisions(query="STOMP 브로커") 를 부르면 벡터가 후보를 내고 (b)가 체인을 붙여서 인스턴스 1대 전제로 SimpleBroker 유지 · 2026-07-22 회의 31:07 까지 되짚어 준다. 근거가 필수 속성인 이유가 여기서 회수된다.

9. 설계 전제

  • 그래프는 관계 질문만 맡는다. 유사도 질문은 벡터가 한다. 둘을 섞으면 둘 다 못한다.
  • 노드 타입은 여덟이고, 늘리려면 §2의 일곱 단계를 지난다. 그래프는 id와 관계만 갖고 본문·화자·이메일은 Postgres에서 붙인다.
  • 근거 없는 노드는 만들지 않는다. 회의의 특정 시각으로 착지하지 못하면 그건 항목이 아니라 추측이다.
  • 뒤집힘은 사람이 확인한다. 이력 오염은 되돌리기가 요약 오류보다 훨씬 어렵다.
  • 그래프에 직접 쓰지 않는다. Postgres → outbox → 투영. payload 에는 id만 싣는다. 이 방향을 어기면 재생성이 죽는다.
  • 순회에는 언제나 상한이 있다. 깊이·팬아웃·스코프·횟수.
  • Topic은 통제 어휘를 운영할 사람이 생기면 넣는다. 그전에는 벡터가 대신한다.

10. 미확인

무엇지금 아는 것확인해야 할 것
BLOCKS 정본 · 관계 확신도ERD §5decisions·action_items·evidence·decision_relations 로 정본을 정했다. 남은 구멍은 decision_relations.typeSUPERSEDES·RELATES_TO 둘뿐이라는 것과, confidence·origindecisions 에만 있다는 것BLOCKStype 에 더할지 액션 아이템 관계 테이블을 따로 둘지 · action_itemsorigin·confidence 를 둘지 · AI 후보의 확신도를 관계에 실을지
결정·액션 아이템 추출 정확도도메인 모델이 MVP2·3로 잡아 뒀다나쁘면 §6에 사람 확인 단계가 하나 더 붙는다
SUPERSEDES 후보 탐지 방식(a)는 씨앗이 pgvector에서 온다고 전제한다임베딩 대상이 항목 한 줄인지 요약인지, top-k를 몇으로 둘지
회의당 노드 수 · MERGE 처리율없다. 노드 6 · 관계 10 · 1만/초 가정재생성 17분/2분 추정의 유일한 근거. 실측하면 Neo4j 크기도 같이 정해진다
statedAtMs 의 다세션 문제세션별 오프셋이라 노트에 세션이 둘이면 어긋난다(APP-398)투영이 절대 시각으로 바꿔 넣을지. 바꾸면 그래프가 계산을 갖게 된다
확인율없다사람이 안 확인하면 SUPERSEDES 가 영원히 미확인으로 쌓인다. 그러면 §5 규칙이 기능을 죽인다
게스트(S2)의 담당Person 을 만들지 않기로 했다게스트에게 액션 아이템이 배정되면 ASSIGNED_TO 를 만들 대상이 없다
Linear·GitHub 동기화 범위도구 연동은 이미 있다어디까지 밀어 쓸지
parked 재처리 경로아웃박스는 projection_outbox 한 테이블이고 server 소유다(§6). 이름·정리 주기는 §6이 정했다5회 실패로 parked 된 행을 누가 어떻게 다시 태우나. 화면은 안 만들 것 같은데 그러면 psql이 유일한 수단이다