22 분 소요

Summary

회사 S3 버킷을 열어보면 이런 게 잔뜩 쌓여 있죠. 증권사 리서치 PDF 수만 건, 종목별 컨센서스 스냅샷 CSV, 뉴스 크롤링 JSON, 일별 가격·수급 시계열, 테마/업종 분류 마스터. 데이터는 다 있는데, 서로 이어져 있지 않아요. “이번 분기에 HBM 테마가 왜 떴는지, 누가 먼저 말했고, 어떤 종목으로 번졌는지”를 물으면 아무도 한 방에 답을 못 합니다. 사람이 폴더를 뒤져서 손으로 잇고 있죠.

이 글은 그걸 지식그래프(Knowledge Graph) 로 옮기는 이야기예요. 저장소는 Amazon Neptune (openCypher 를 쓰니까 Neo4j 문법 그대로 쓸 수 있어요)을 기준으로 잡았습니다.

그런데 이 작업의 진짜 어려움은 “그래프 DB 를 띄우는 것”이 아니에요. 진짜 어려움은 셋입니다.

  1. 시간 — 금융 데이터는 “지금 참인 값”이 아니라 “그 시점에 우리가 알고 있던 값” 이 중요해요. 이걸 놓치면 백테스트가 통째로 거짓말이 됩니다.
  2. 경계 — 뭘 그래프에 넣고 뭘 넣지 말아야 하는지. 시계열 원본까지 다 노드로 만들면 6개월 뒤에 아무도 못 씁니다.
  3. 중요도 — 그래프는 쌓기만 하면 반드시 쓰레기가 돼요. 뭘 남기고 뭘 죽일지를 파이프라인 안에 규칙으로 박아 넣어야 합니다.

이 셋을 중심으로, S3 원본에서 Neptune 질의까지 전 구간을 순서대로 깔아볼게요.

💡 이 글에서 다루는 것

  • 왜 RDB·벡터DB 가 아니라 그래프인가 (그리고 언제는 아닌가)
  • Neptune 은 Neo4j 가 아니다 — 미리 알아야 할 실무 차이
  • 시간 모델링 4가지 패턴과 이중시간(bitemporal) 설계
  • 온톨로지 설계 — 노드/엣지 카탈로그와 식별자 전략
  • S3 → 파싱 → 추출 → 엔티티 해상도 → 적재 파이프라인 전체
  • 테마·업종·컨센서스·뉴스·시계열을 각각 어떻게 매핑하나
  • 중요도 가중치와 승격/아카이브 규칙 (쓰레기 방지)
  • 실제 질의 예시와 운영 체크리스트, 0→1 로드맵



1. 왜 지식그래프인가 — 문제는 조인이 아니라 맥락이다

먼저 솔직하게 짚고 갈게요. 그래프 DB 가 만능이 아니에요. 지금 RDB 로 잘 돌아가는 걸 굳이 옮기면 손해입니다. 그래프가 이기는 건 딱 한 가지 상황이에요.

“관계를 몇 단계 타고 들어가야 답이 나오고, 그 단계 수를 미리 모를 때”

금융 리서치 도메인이 정확히 그렇습니다. 예를 들어 이런 질문들이요.

  • “이 종목에 간접적으로 영향을 주는 공급망 상대는 누구지? 2차 협력사까지.”
  • “지금 뜨는 테마에 붙은 종목 중, 아직 리포트가 안 나온 종목은?”
  • “이 애널리스트가 목표주가를 올린 다음, 같은 업종 다른 애널리스트들이 따라 올렸나?
  • “이 뉴스가 언급한 회사와, 그 회사가 속한 테마의 다른 회사들은 그 뒤 어떻게 됐나?”

이걸 RDB 로 하면 조인 5~6단이 되고, 단계 수가 질문마다 달라지니 쿼리를 매번 새로 짜야 해요. 그래프에선 -[:SUPPLIES_TO*1..3]-> 한 줄입니다.

세 저장소를 비교하면 이렇게 정리돼요.

저장소 잘하는 것 못하는 것
RDB 정형 집계
트랜잭션
가변 깊이
관계 탐색
벡터DB 의미 유사
문서 검색
정확한 연결
근거 추적
그래프 다단계 관계
경로·설명
대량 시계열
수치 집계

그래서 현실적인 그림은 셋을 다 쓰되 역할을 나누는 것이에요. 시계열 숫자는 S3+Athena 나 Timestream 에, 문서 본문은 벡터 인덱스에, 그리고 “무엇이 무엇과 어떻게 연결되어 있는가”만 Neptune 에 둡니다. 그래프는 얇고 의미 있게 유지하는 게 핵심이에요. 이 원칙은 뒤에서 계속 반복됩니다.

⚠️ 흔한 실패 1번: “일단 다 넣자”. 일봉 5년치를 노드로 만들면 노드가 수억 개가 되고, 그 순간 그래프의 장점(탐색 속도, 사람이 읽을 수 있는 구조)이 둘 다 죽습니다.



2. 먼저 못 박고 갈 것 — Neptune 은 Neo4j 가 아니다

사용자분들이 “넵튠 Neo4j” 라고 묶어 부르시는데, Neptune 은 Neo4j 의 질의 언어(openCypher)를 지원하는 다른 엔진이에요. 문법은 거의 그대로인데 운영 감각이 꽤 다릅니다. 설계를 시작하기 전에 이걸 모르면 나중에 갈아엎게 돼요.

항목 Neo4j 감각 Neptune 현실
접속 Bolt 드라이버 HTTPS
/openCypher
인덱스 직접 생성 자동 인덱싱
(생성 불가)
유니크 제약 제약조건 없음 →
ID 로 보장
알고리즘 GDS · APOC Neptune
Analytics
변경 감지 CDC 플러그인 Neptune
Streams
접근 공개 가능 VPC 전용
+ IAM 서명

실무에 바로 영향을 주는 건 이 넷이에요.

(1) 유니크 제약이 없다 → ID 로 대신한다. Neo4j 에서 쓰던 CREATE CONSTRAINT ... IS UNIQUE 가 없어요. 그럼 MERGE 로 중복을 막는데, 동시에 두 워커가 같은 노드를 MERGE 하면 중복이 생길 수 있죠. 해결책은 노드 ID 를 우리가 직접, 결정적으로 지정하는 것입니다. Neptune 은 ~id 를 직접 줄 수 있고 이 ID 는 엔진이 유일성을 보장해요.

// 나쁨: 속성으로만 매칭 → 동시성에서 중복 가능
MERGE (c:Company {corp_code: '00126380'})

// 좋음: 결정적 ID 를 직접 부여 → 엔진이 유일성 보장
MERGE (c:Company {`~id`: 'company:kr:00126380'})
  ON CREATE SET c.corp_code = '00126380', c.name = '삼성전자'

ID 규칙은 <타입>:<네임스페이스>:<키> 처럼 사람이 읽을 수 있게 정해두세요. 이게 나중에 멱등성(같은 걸 여러 번 넣어도 결과가 같음)의 뿌리가 됩니다.

(2) 쓰기 충돌이 난다. Neptune 은 동시에 같은 영역을 고치면 ConcurrentModificationException 을 던져요. 그래서 적재 워커는 지수 백오프 재시도가 기본이고, 가능하면 같은 종목은 같은 파티션(워커)로 몰아서 처리하는 게 좋아요. SQS FIFO 의 MessageGroupId 를 종목 ID 로 잡는 식이죠.

(3) 대량 적재는 Bulk Loader. 초기 마스터 데이터 수백만 건을 openCypher MERGE 로 넣으면 며칠 걸립니다. Neptune 은 S3 의 CSV 를 직접 빨아들이는 Bulk Loader 가 있고, 이게 몇 자릿수 빠릅니다. 그래서 적재 경로를 두 갈래로 설계해요.

  • 초기 적재·대량 백필 → Bulk Loader (S3 CSV)
  • 실시간 증분 → openCypher upsert (Lambda)

Bulk Loader 용 CSV 헤더는 이런 모양이에요.

# nodes/company.csv
:ID,:LABEL,name:String,corp_code:String,country:String

# edges/belongs_to.csv
:START_ID,:END_ID,:TYPE,valid_from:Date,valid_to:Date,source:String

(4) 그래프 알고리즘은 별도. PageRank·커뮤니티 탐지 같은 걸 돌리려면 Neptune Database 가 아니라 Neptune Analytics 쪽을 씁니다. 스냅샷을 Analytics 그래프로 올려서 계산하고, 결과 점수만 다시 본 그래프에 속성으로 적어 넣는 패턴이 깔끔해요. (이건 7장에서 다시 다룹니다.)



3. 시간을 어떻게 넣을 것인가 — 이 글의 심장

여기가 제일 중요합니다. 사용자분이 “시간에 따라 중요 데이터가 되도록” 이라고 하신 게 바로 이 얘기예요.

3.1 덮어쓰면 왜 안 되나 — 미래 정보 누출

가장 흔한 설계가 이겁니다. “종목 노드에 target_price 속성을 두고, 새 리포트가 나오면 갱신한다.” 편하죠. 그런데 이러면 2025년 3월 시점에 우리가 뭘 알고 있었는지를 영원히 잃습니다.

이게 왜 치명적이냐면, 금융에서 하는 거의 모든 검증이 “과거 시점으로 돌아가서 그때 판단이 맞았나”를 보는 일이기 때문이에요. 지금 값으로 과거를 평가하면 미래를 이미 알고 있는 상태로 시험을 보는 것(look-ahead bias)이 됩니다. 백테스트 성과가 실제보다 훨씬 좋게 나오고, 실전에서 그대로 깨집니다.

그리고 금융 데이터는 뒤늦게 고쳐지는 일이 굉장히 많아요.

  • 컨센서스는 하루에도 여러 번 리비전됩니다.
  • 재무제표는 정정공시로 과거 숫자가 바뀝니다.
  • 업종/테마 분류는 주기적으로 재편됩니다.
  • 지수 편입/편출은 발표일과 적용일이 다릅니다.

3.2 두 개의 시간 — 유효시간과 기록시간

그래서 금융 지식그래프는 시간을 두 개 답니다. 이걸 이중시간(bitemporal) 이라고 해요. 어렵게 들리지만 뜻은 단순합니다.

시간 예시
유효시간
valid time
사실이
참인 기간
“3분기
영업이익”
기록시간
transaction time
우리가
안 시점
“11/14
DB 에 들어옴”

실제 속성 이름으로는 보통 이렇게 씁니다.

  • valid_from / valid_to — 이 사실이 현실에서 유효한 구간
  • recorded_at — 우리 시스템에 들어온 순간
  • superseded_at — 이 레코드가 다른 값으로 대체된 순간 (없으면 아직 유효)

이 넷만 모든 “변하는 관계”에 달아두면, 나중에 이 두 질문을 둘 다 할 수 있게 됩니다.

  • “2025-03-15 현재 기준으로 이 회사의 3분기 실적은?” → 정정 반영된 최신값
  • “2025-03-15 당시 우리가 알던 3분기 실적은?” → 그때 화면에 떠 있던 값

두 번째 질문에 답할 수 있는 그래프가 백테스트에 쓸 수 있는 그래프예요. 이게 안 되면 그래프는 예쁜 시각화 장난감에서 끝납니다.

💡 처음부터 완벽한 이중시간을 다 깔 필요는 없어요. 다만 recorded_at 만은 1일차부터 모든 노드·엣지에 무조건 넣으세요. 이것만 있어도 나중에 상당 부분을 복구할 수 있는데, 없으면 영원히 못 만듭니다.

3.3 시간 모델링 4가지 패턴

시간을 그래프에 넣는 방법은 크게 네 가지고, 데이터 성격에 따라 골라 씁니다. 하나로 통일하려 하면 반드시 어딘가 터져요.

패턴 A — 속성 타임스탬프 (가장 가벼움)

거의 안 변하는 사실에 씁니다. 설립일, 상장일처럼요.

(:Company {name:'...', listed_at: date('2010-05-11')})

쓸 곳: 정적 마스터. 한계: 이력 없음.

패턴 B — 관계에 유효구간 (가장 많이 씀)

“이 종목이 이 테마에 속한다” 같은 분류가 시간에 따라 변하는 것에 씁니다. 지식그래프 시간축의 주력이에요.

(s:Security)-[:BELONGS_TO {
   valid_from: date('2024-01-02'),
   valid_to:   date('9999-12-31'),
   recorded_at: datetime('2024-01-02T09:10:00Z'),
   source: 'theme-master-v3',
   weight: 0.72
}]->(t:Theme)

분류가 바뀌면 기존 엣지를 지우지 않고 valid_to 를 어제 날짜로 닫고, 새 엣지를 하나 더 만듭니다. 그러면 같은 두 노드 사이에 엣지가 여러 개 쌓이는데, 그게 정상이에요. 그 자체가 이력입니다.

패턴 C — 상태 노드 (SCD Type 2 스타일)

값이 여러 개 묶여서 통째로 바뀌는 것에 씁니다. 컨센서스 스냅샷이 대표적이에요. 목표주가·영업이익추정·투자의견이 한 세트로 움직이니까, 관계 속성으로 흩뿌리지 말고 하나의 상태 노드로 묶습니다.

(sec:Security)-[:HAS_ESTIMATE]->(e:Estimate {
   `~id`: 'est:005930:FY2026:2026-07-28:brokerA',
   fiscal_period: 'FY2026',
   op_profit: 41200000000000,
   target_price: 105000,
   rating: 'BUY',
   valid_from: date('2026-07-28'),
   recorded_at: datetime('2026-07-28T07:31:00Z')
})
(e)-[:REVISES]->(prev:Estimate)   // 리비전 체인

REVISES 로 이전 추정치를 가리키게 해두면 “상향 → 상향 → 하향” 같은 흐름을 그래프 탐색만으로 뽑을 수 있어요. 이건 관계형으로 하면 자기조인 지옥인데 그래프에선 그냥 경로입니다.

패턴 D — 이벤트 노드 (시점 사건)

실적발표, 공시, 뉴스 게재, 지수 편입처럼 순간에 일어난 일은 이벤트 노드로 둡니다.

(:Event {
   `~id`: 'event:earnings:005930:2026Q2',
   type: 'EARNINGS_RELEASE',
   occurred_at: datetime('2026-07-31T07:30:00+09:00')
})

이벤트는 시간축의 앵커 역할을 합니다. “이 뉴스는 실적발표 이전인가 이후인가” 같은 판단이 여기서 나와요.

정리하면 이렇게 고릅니다.

데이터 성격 패턴
안 변하는 사실 A 속성
분류·소속이
변함
B 관계
유효구간
값 묶음이
통째로 갱신
C 상태
노드
순간 사건 D 이벤트
노드

3.4 as-of 질의 — 시간축을 실제로 태우기

시간을 넣었으면 꺼낼 때도 시간을 걸어야 의미가 있어요. 모든 조회 쿼리에 “기준 시점” 파라미터를 강제하는 습관을 들이세요.

// 2026-03-15 시점에 '반도체소재' 테마에 속해 있던 종목
WITH date('2026-03-15') AS asof
MATCH (t:Theme {`~id`:'theme:semiconductor-materials'})
      <-[r:BELONGS_TO]-(s:Security)
WHERE r.valid_from <= asof AND asof < r.valid_to
RETURN s.name, r.weight
ORDER BY r.weight DESC

여기서 한 걸음 더 나가면 “그때 우리가 알던 것만” 필터가 붙습니다. 백테스트용이죠.

WITH date('2026-03-15') AS asof,
     datetime('2026-03-15T15:30:00+09:00') AS known_by
MATCH (t:Theme)<-[r:BELONGS_TO]-(s:Security)
WHERE r.valid_from <= asof AND asof < r.valid_to
  AND r.recorded_at <= known_by                 // 미래 정보 차단
  AND (r.superseded_at IS NULL OR r.superseded_at > known_by)
RETURN s.name

✅ 팀 규칙으로 못 박길 권합니다. “파라미터 없이 현재값만 보는 쿼리는 대시보드 전용, 분석·백테스트 쿼리는 known_by 를 반드시 받는다.” 이 규칙 하나가 나중에 사고를 여러 번 막아줍니다.



4. 온톨로지 설계 — 무엇을 노드로 두고 무엇을 두지 않을까

4.1 식별자가 전부다

그래프에서 제일 먼저 망하는 지점이 “같은 회사인데 노드가 5개” 입니다. 삼성전자, 삼성전자(주), Samsung Electronics, 005930, KR7005930003 이 전부 따로 생기는 거죠. 그래서 설계 첫 단추는 온톨로지가 아니라 ID 정책이에요.

규칙 1 — 티커를 키로 쓰지 마세요. 티커는 재사용되고, 변경되고, 시장마다 겹칩니다. 상장폐지된 회사의 티커가 몇 년 뒤 다른 회사에 붙는 일이 실제로 있어요.

규칙 2 — 법인과 종목을 분리하세요. 이게 처음엔 과하게 느껴지는데, 안 하면 반드시 후회합니다.

(Company 법인) ─[:ISSUES]→ (Security 종목)
     │                          │
  법인등록번호·LEI          ISIN·거래소코드
  합병·분할로 변함        우선주/보통주 각각

한 법인이 보통주·우선주·해외 DR 을 동시에 발행하고, 합병하면 법인은 사라지는데 종목은 남기도 합니다. 분리해두면 이런 사건을 관계의 변화로 자연스럽게 표현할 수 있어요.

규칙 3 — 별칭은 노드가 아니라 속성/보조노드로. 뉴스에 나오는 “삼전”, “Samsung Elec” 같은 표기는 별칭 사전으로 관리하고, 최종적으로는 하나의 안정 ID 로 모읍니다.

(:Alias {text:'삼전', lang:'ko'})-[:REFERS_TO {confidence:0.97}]->(:Company)

4.2 노드 카탈로그

금융 리서치 도메인에서 실제로 쓸모 있는 최소 집합은 이 정도예요. 처음부터 다 만들지 말고, 1~2단계만 먼저 깔고 늘리세요.

그룹 노드 핵심 키
주체 Company
Security
LEI ·
ISIN
분류 Sector
Theme
분류체계
코드
사람 Analyst
Broker
사번 ·
기관코드
문서 Report
NewsArticle
S3 키
해시
추정 Estimate
Actual
종목+
회계기간
사건 Event 종목+
유형+시각
개념 Product
Region
표준
사전
지표 Metric
MacroSeries
시리즈
코드

Metric/MacroSeries시계열 그 자체가 아니라 “시계열의 신원증” 이에요. 이게 다음 절의 핵심입니다.

4.3 엣지 카탈로그

엣지 타입은 적을수록 좋습니다. 20개 넘어가면 아무도 못 외우고, 쿼리마다 빠뜨려요. 처음엔 이 정도로 시작하세요.

엣지 의미 시간
ISSUES 법인 →
종목
구간
BELONGS_TO 종목 →
업종/테마
구간
COVERS 애널리스트
→ 종목
구간
ABOUT 문서 →
주체
시점
MENTIONS 문서 →
개념
시점
HAS_ESTIMATE 종목 →
추정
시점
REVISES 추정 →
이전추정
시점
SUPPLIES_TO 회사 →
회사
구간
COMPETES_WITH 회사 ↔
회사
구간
CO_MOVES_WITH 종목 ↔
종목
구간

여기서 ABOUTMENTIONS 를 나눈 게 포인트예요. 리포트가 다루는 대상(제목·커버 종목)과 본문에 스쳐 지나간 언급은 무게가 완전히 다릅니다. 이걸 한 타입으로 뭉개면 나중에 “언급만 됐는데 관련 종목으로 뜨는” 노이즈를 못 걷어냅니다.

모든 엣지에 공통으로 붙일 속성을 미리 정해두세요. 이게 나중에 품질과 신뢰의 근거가 됩니다.

valid_from / valid_to     — 유효 구간
recorded_at               — 기록 시점
source_uri                — 근거 S3 키 (필수!)
extractor                 — 어떤 버전이 뽑았나
confidence                — 0.0 ~ 1.0
weight                    — 중요도 (7장에서 계산)

source_uriextractor타협하지 마세요. “이 관계는 어느 문서 몇 페이지에서 나왔는가”를 못 대는 그래프는 금융에서 못 씁니다. 사람이 못 믿거든요.

4.4 그래프에 넣지 말아야 할 것 — 시계열 원본

사용자분이 시계열 데이터를 언급하셨으니 이 부분을 분명히 할게요. 일별 가격·거래량·수급 원본은 그래프에 넣지 마세요.

대신 이 패턴을 씁니다.

(s:Security)-[:HAS_SERIES]->(m:Metric {
   `~id`:'metric:price:005930:1d',
   kind: 'CLOSE_PRICE',
   freq: '1d',
   store: 's3://mkt-lake/ohlcv/sym=005930/',
   table: 'athena.market.ohlcv_1d'
})

즉 그래프에는 “이런 시계열이 존재하고, 어디에 있다”는 포인터만 둡니다. 실제 숫자는 Parquet 로 S3 에 두고 Athena 로 읽고요.

그럼 시계열이 그래프에 기여하는 건 뭐냐 — 파생된 관계입니다. 원본이 아니라 결론만 승격시켜요.

  • 60일 상관계수가 0.8 을 넘긴 종목쌍 → CO_MOVES_WITH 엣지 (유효구간과 함께)
  • 특정 테마 편입 종목들의 동조 상승 → Theme 노드의 momentum 속성 갱신
  • 실적 서프라이즈 → Actual 노드와 SURPRISED 엣지

이렇게 하면 그래프가 “숫자 창고”가 아니라 “판단의 지도” 로 유지됩니다. 노드 수가 수억이 아니라 수백만에서 멈추고요.

⚠️ 흔한 실패 2번: 상관계수 엣지를 유효구간 없이 넣기. 상관은 계속 변하는데 엣지가 안 닫히면, 1년 뒤 그래프는 “모든 종목이 모든 종목과 연결된” 완전그래프가 됩니다. 실제로 자주 봅니다. 승격 규칙에는 반드시 만료 규칙이 짝으로 있어야 해요.



5. 파이프라인 아키텍처 — S3 에서 Neptune 까지

이제 물류를 깔아봅시다. 전체 그림은 이래요.

S3 raw ─→ EventBridge ─→ SQS ─→ Step Functions
                                     │
     ┌───────────────┬───────────────┼───────────────┐
     ▼               ▼               ▼               ▼
   파싱           추출            엔티티          검증
 (Textract      (Bedrock/       해상도         (스키마·
  · 파서)         규칙)         (블로킹·      시간중복·
     │               │           랭킹)         고아노드)
     ▼               ▼               ▼               ▼
 S3 parsed ─→ S3 extracted ─→ S3 graph-ready ─→ 적재
                                                 │
                                    ┌────────────┴────────┐
                                    ▼                     ▼
                              Bulk Loader           openCypher
                              (대량·백필)            (증분·실시간)
                                    └──────→ Neptune ←────┘
                                                 │
                                          Neptune Streams
                                                 │
                                    ▼            ▼            ▼
                                 검색색인      캐시        알림

한 층씩 볼게요.

5.1 S3 레이아웃 — 원본은 절대 안 건드린다

메달리온(bronze/silver/gold) 구조를 그대로 씁니다. 중요한 건 각 단계 산출물을 전부 남기는 것이에요. 추출 로직을 고쳤을 때 처음부터 다시 안 돌려도 되게요.

s3://fin-lake/
  raw/       report/dt=2026-07-28/broker=A/xxx.pdf   # 불변, 절대 수정 금지
  parsed/    report/dt=2026-07-28/xxx.json           # 텍스트+레이아웃+표
  extracted/ report/dt=2026-07-28/xxx.json           # 엔티티·관계 후보
  resolved/  report/dt=2026-07-28/xxx.json           # 안정 ID 로 정규화됨
  graph/     nodes/dt=2026-07-28/*.csv               # Bulk Loader 입력
             edges/dt=2026-07-28/*.csv
  quarantine/ ...                                    # 실패·검수 대기
  • 파티션은 처리일(dt) 로 자르되, 문서의 발행일은 메타데이터로 따로 관리하세요. 둘을 섞으면 지각 도착 데이터(late arrival)에서 반드시 꼬입니다.
  • raw/버전 관리 + 객체 잠금을 켜두세요. 원본이 곧 감사 증거입니다.
  • 모든 산출물에 extractor_version 을 박아두면, 나중에 “v3 로 뽑은 것만 다시 처리” 가 가능해집니다.

5.2 트리거와 오케스트레이션

  • S3 Event → EventBridge → SQS → Step Functions 가 기본 조합이에요. Lambda 를 S3 에 직접 붙이면 재처리·백프레셔 제어가 어려워지니 큐를 한 겹 끼우는 걸 권합니다.
  • SQS FIFO 의 MessageGroupId 를 종목 ID(또는 문서군)로 잡으면, 같은 대상에 대한 쓰기가 직렬화돼서 Neptune 쓰기 충돌이 크게 줄어요.
  • 문서 한 건 처리가 몇 분 걸릴 수 있으니(대형 PDF, LLM 호출), Lambda 15분 한계를 넘길 것 같으면 AWS Batch / ECS Task 로 빼세요.
  • DLQ 는 필수고, DLQ 에 쌓인 건 quarantine/ 으로 옮겨 사람이 볼 수 있게 합니다. 조용히 사라지는 문서가 가장 무섭습니다.

5.3 파싱 — PDF 가 제일 어렵다

리서치 리포트는 대부분 PDF 고, 여기가 실질적으로 공수의 절반입니다.

  • 텍스트 레이어가 있는 PDF 는 파서로 충분해요. 빠르고 쌉니다.
  • 스캔본·이미지 는 Textract 같은 OCR 이 필요합니다.
  • 표가 진짜 문제예요. 컨센서스 표, 실적 추정 표가 리포트 가치의 핵심인데 셀 병합·다단 헤더가 흔합니다. Textract 의 표 추출을 쓰되, 추출한 표는 반드시 원본 좌표(페이지·bbox)를 같이 저장하세요. 나중에 사람이 확인할 때 그 좌표가 있어야 검증이 됩니다.
  • 차트 이미지 안의 숫자는 포기하는 게 낫습니다. 억지로 뽑으면 틀린 값이 그래프에 들어가고, 틀린 값은 없는 값보다 훨씬 나쁩니다.

파싱 결과는 이런 구조로 저장해두면 다음 단계가 편해요.

{
  "doc_id": "report:brokerA:2026-07-28:0031",
  "published_at": "2026-07-28T07:10:00+09:00",
  "blocks": [
    {"page": 1, "type": "title", "text": "...", "bbox": [..]},
    {"page": 2, "type": "table", "cells": [[..]], "bbox": [..]}
  ]
}

5.4 추출 — 규칙과 LLM 을 섞는다

엔티티·관계를 뽑는 단계입니다. 전부 LLM 으로 하려 하지 마세요. 비용도 비용이고, 확정적인 건 규칙이 훨씬 정확합니다.

대상 방법
종목코드 ·
ISIN
정규식 +
마스터 대조
회사명 ·
별칭
사전 매칭
(Aho-Corasick)
숫자 표
(컨센서스)
표 파서 +
단위 정규화
관계 ·
인과 서술
LLM
구조화 추출
감성 ·
논조
분류 모델

LLM 을 쓸 때는 반드시 스키마를 강제하고, 근거 스팬을 같이 뱉게 하세요. 이게 환각 방어의 핵심입니다.

{
  "relations": [{
    "type": "SUPPLIES_TO",
    "from": {"mention": "A社", "span": [1420, 1423]},
    "to":   {"mention": "삼성전자", "span": [1440, 1444]},
    "evidence": "A社는 삼성전자향 HBM 기판을 2026년부터 납품할 예정",
    "confidence": 0.81
  }]
}

✅ 원칙: 근거 문장을 못 대는 관계는 그래프에 넣지 않는다. 확신도가 아무리 높아도요. 이 규칙 하나로 “그럴듯한데 사실이 아닌” 엣지의 대부분이 걸러집니다. 그리고 이 evidence 는 나중에 사용자에게 “왜 이런 결론이 나왔는지” 보여줄 때 그대로 재활용됩니다.

5.5 엔티티 해상도 — 파이프라인에서 제일 티 안 나는 핵심

추출된 “삼전” 이라는 표기를 실제 company:kr:00126380 으로 연결하는 단계예요. 이게 무너지면 위에서 한 모든 게 무의미해집니다.

실무에서 쓰는 3단 구조는 이래요.

  1. 정확 매칭 — 종목코드·ISIN·법인번호가 텍스트에 있으면 끝. 가장 싸고 정확.
  2. 블로킹 + 후보 랭킹 — 정확 매칭이 안 되면, 이름 정규화(공백·(주)·법인격 제거) 후 후보를 좁히고, 문맥 점수로 순위를 매깁니다. 문맥 점수에 그래프를 쓰는 게 재밌는 부분이에요. 같은 문서에 이미 확정된 엔티티들과 그래프상 가까운 후보에 가점을 줍니다. “HBM, 반도체, SK 가 나온 문서에서 ‘A社’는 반도체 소재 회사일 가능성이 높다” 는 식이죠.
  3. 애매하면 보류 — 1등과 2등 점수 차가 작으면 강제로 찍지 말고 quarantine/ 으로 보내 사람 검수 큐에 올립니다.

⚠️ 흔한 실패 3번: 애매한 걸 억지로 연결. 잘못 연결된 엔티티는 틀린 정보를 자신 있게 퍼뜨립니다. 미연결로 남겨두면 그냥 데이터가 없는 것뿐이고요. 항상 후자가 낫습니다.

해상도 결과는 결정을 기록해두세요. 나중에 사전을 고쳐 재처리할 때 무엇이 어떻게 바뀌었는지 추적됩니다.

5.6 적재 — 멱등성이 생명

같은 문서가 두 번 들어와도, 파이프라인을 다시 돌려도 결과가 같아야 합니다. 그래야 마음 놓고 재처리할 수 있어요.

멱등키 설계가 전부입니다.

노드 ID : company:kr:00126380
          report:brokerA:2026-07-28:0031
          est:005930:FY2026:2026-07-28:brokerA

엣지 ID : mentions:<doc_id>:<entity_id>:<span_start>
          belongs:<sec_id>:<theme_id>:<valid_from>

엣지에도 ID 를 부여하는 게 포인트예요. Neptune 은 엣지에도 ~id 를 줄 수 있어서, 같은 걸 두 번 넣어도 하나로 수렴합니다.

증분 적재 쿼리는 이런 모양이 됩니다.

UNWIND $rows AS row
MERGE (d:Report {`~id`: row.doc_id})
  ON CREATE SET d.published_at = datetime(row.published_at),
                d.s3_uri = row.s3_uri
MERGE (c:Company {`~id`: row.company_id})
MERGE (d)-[m:MENTIONS {`~id`: row.edge_id}]->(c)
  ON CREATE SET m.recorded_at = datetime(),
                m.confidence  = row.confidence,
                m.evidence    = row.evidence,
                m.source_uri  = row.s3_uri,
                m.extractor   = row.extractor_version

운영 팁 몇 개.

  • 배치는 100~500행 단위UNWIND. 한 트랜잭션이 너무 커지면 충돌·타임아웃이 늘어요.
  • 재시도는 지수 백오프 + 지터. 충돌은 정상 동작이라고 생각하세요.
  • 분류 변경(패턴 B)은 “닫고 열기” 2단계로. 하나의 트랜잭션 안에서 처리해야 중간 상태가 노출되지 않습니다.
// 기존 소속 닫기
MATCH (s:Security {`~id`:$sec})-[r:BELONGS_TO]->(t:Theme {`~id`:$old})
WHERE r.valid_to > date($today)
SET r.valid_to = date($today), r.superseded_at = datetime()

5.7 Neptune Streams — 하류로 퍼뜨리기

그래프가 바뀌면 검색 인덱스, 캐시, 알림도 따라가야 하죠. Neptune Streams 를 켜면 변경 로그를 순서대로 읽을 수 있고, 이걸 소비해서 OpenSearch 색인이나 알림으로 보냅니다. 그래프를 진실의 원천으로 두고, 나머지는 파생으로 두는 구조가 오래갑니다.



6. 데이터 소스별 매핑 레시피

사용자분이 말씀하신 소스들을 하나씩 구체적으로 붙여볼게요.

6.1 리서치 리포트

(Report)-[:ABOUT]→(Security)          커버 종목 (강한 관계)
(Report)-[:MENTIONS]→(Company/Theme)  본문 언급 (약한 관계)
(Report)-[:WRITTEN_BY]→(Analyst)
(Analyst)-[:WORKS_AT]→(Broker)        유효구간 필수 (이직!)
(Report)-[:CONTAINS]→(Estimate)

애널리스트의 소속은 반드시 유효구간으로 두세요. 이직이 잦고, “이 리포트를 쓸 당시 어느 하우스 소속이었나”가 분석에서 자주 필요합니다.

6.2 컨센서스

패턴 C(상태 노드) + REVISES 체인이 정답이에요. 여기에 하나 더 붙이면 좋은 게 집계 노드입니다. 개별 하우스 추정과 별개로, “시장 컨센서스” 자체를 시점별 노드로 만들어 두는 거죠.

(Security)-[:HAS_CONSENSUS]→(Consensus {
    fiscal_period, as_of_date,
    mean, median, high, low, n_analysts, stdev
})

stdevn_analysts 를 꼭 저장하세요. 다음 장에서 놀람도(surprise) 계산에 쓰입니다. 평균만 저장하면 “얼마나 의외인가”를 못 잽니다.

6.3 테마·업종

둘은 성격이 달라서 분리하는 걸 권합니다.

성격 모델링
업종
Sector
공식 분류
계층적
트리 +
유효구간
테마
Theme
시장 서사
중첩·부침
가중
다대다

업종은 (:Sector)-[:PARENT_OF]->(:Sector) 로 계층을 만들고, 종목은 리프에 붙입니다. 분류체계가 바뀌면 유효구간으로 갈아끼우면 되고요.

테마는 다릅니다. 한 종목이 여러 테마에 정도 차이를 두고 속하고, 테마 자체가 태어나고 죽어요. 그래서 BELONGS_TOweight 를 두고, 테마 노드에 생애주기 속성을 답니다.

(:Theme {
   name:'HBM',
   status:'ACTIVE',            // EMERGING / ACTIVE / FADING / ARCHIVED
   first_seen: date('2023-05-02'),
   peak_at:    date('2024-03-11'),
   momentum:   0.42            // 주기적으로 재계산
})

테마 부상 탐지는 이 그래프의 킬러 기능 중 하나예요. “최근 30일 뉴스·리포트에서 함께 등장하는 개념 묶음 중, 기존 테마에 안 붙는 새 군집” 을 찾으면 신규 테마 후보가 됩니다. 이건 Neptune Analytics 의 커뮤니티 탐지로 돌리고, 결과를 사람이 확인해서 정식 테마로 승격시키는 흐름이 안전해요.

6.4 뉴스

뉴스는 양이 많고 품질이 낮습니다. 그래서 전부 넣으면 안 되고, 게이트를 둡니다.

게이트 1: 중복 제거 (같은 기사 재배포 → 클러스터로 묶기)
게이트 2: 종목 연결 성공 여부 (연결 안 되면 안 넣음)
게이트 3: 매체 신뢰도 × 언급 강도 임계값

그리고 뉴스는 개별 기사 노드보다 “뉴스 클러스터” 노드가 훨씬 유용합니다. 같은 사건을 다룬 기사 50건을 한 노드로 묶고, 기사 수를 intensity 로 두는 거죠. 그래프가 1/50 로 줄고 신호는 오히려 선명해집니다.

(:NewsCluster {
   `~id`:'newsc:2026-07-28:hbm-supply',
   headline:'...', article_count: 52,
   first_at: datetime('...'), peak_at: datetime('...')
})-[:ABOUT]->(:Security)

6.5 시계열 파생 신호

4.4 에서 말한 승격 규칙을 구체화하면 이렇습니다.

계산 승격 결과 만료
60일 상관
> 0.8
CO_MOVES_WITH 상관
< 0.6
실적 vs
컨센 z > 2
Event
SURPRISE
이벤트라
영구
테마 편입주
동조 상승
Theme
momentum ↑
30일
감쇠
거래량
이상 급증
Event
VOL_SPIKE
이벤트

배치는 야간에 Glue/EMR 로 돌리고, 결과 델타만 Neptune 에 반영합니다. 매일 전량 재계산해서 통째로 갈아끼우지 마세요. 그러면 이력이 날아갑니다.



7. “의미 있는 지식”으로 만드는 법 — 중요도와 승격

7.1 쌓기만 하면 반드시 쓰레기가 된다

이건 예외 없습니다. 6개월만 돌려도 뉴스 언급 엣지가 수천만 개가 되고, 그중 99%는 아무 의미가 없어요. 그래서 파이프라인 안에 “잊어버리는 규칙” 을 처음부터 넣어야 합니다. 사람 기억이 그러듯이요.

핵심은 모든 엣지에 가중치(weight) 를 두고, 그 가중치를 주기적으로 다시 계산하는 겁니다.

7.2 가중치 공식

제가 쓰는 기본 형태는 이래요.

\[w = s \cdot c \cdot e^{-\lambda \Delta t} \cdot \bigl(1 + \ln(1+n)\bigr)\]

수식이 낯설면 그냥 “곱셈 네 개” 로 읽으시면 됩니다.

  • $s$ — 출처 신뢰도. 정식 공시나 대형 하우스 리포트면 1.0 에 가깝고, 익명 커뮤니티면 0.2 같은 식이에요.
  • $c$ — 추출 확신도. 5.4 에서 LLM/규칙이 뱉은 값입니다. 애매하게 뽑은 관계는 애초에 약하게 시작해요.
  • $e^{-\lambda \Delta t}$ — 시간 감쇠. $\Delta t$ 는 “며칠 지났나”고, 시간이 갈수록 이 값이 1 → 0 으로 부드럽게 줄어듭니다. 오래된 언급은 저절로 약해진다는 뜻이에요.
  • $1 + \ln(1+n)$ — 반복 보너스. $n$ 은 같은 관계가 몇 번 반복 관측됐나입니다. 로그를 씌운 이유는, 100번 언급된 게 10번 언급된 것보다 중요하긴 하지만 10배로 중요하진 않기 때문이에요. 로그가 그 “체감 둔화”를 만들어줍니다.

감쇠 속도 $\lambda$ 는 반감기로 정하면 직관적이에요. 반감기란 “가중치가 절반이 되는 데 걸리는 시간”이고, 이렇게 변환합니다.

\[\lambda = \frac{\ln 2}{t_{1/2}}\]

즉 반감기를 30일로 잡고 싶으면 $\lambda = 0.693/30 \approx 0.023$ 을 쓰면 됩니다. 관계 종류마다 반감기를 다르게 주는 게 핵심이에요.

관계 반감기 감각
뉴스 언급 7 ~ 30일
리포트
커버리지
90 ~ 180일
테마 소속 180일 ~
공급망 관계 감쇠 없음
(사실)

사실(fact)에는 감쇠를 걸지 마세요. “A가 B에 납품한다”는 계약이 끝날 때까지 참입니다. 감쇠는 “관심·화제성” 에만 겁니다. 이 둘을 구분하는 게 설계 감각의 절반이에요.

7.3 놀람도 — 컨센서스가 있어서 가능한 것

컨센서스 데이터를 그래프에 넣는 진짜 이유가 이겁니다. “기대 대비 얼마나 빗나갔나” 를 계산할 수 있게 되거든요.

\[z = \frac{a - \mu}{\sigma}\]
  • $a$ — 실제 발표된 값
  • $\mu$ — 컨센서스 평균 (기대값)
  • $\sigma$ — 애널리스트들 추정치의 표준편차 (의견이 얼마나 갈렸나)

쉽게 말하면 “예상에서 얼마나 벗어났는지를, 원래 의견이 갈리던 정도로 나눈 것” 이에요. 다들 비슷하게 봤는데($\sigma$ 작음) 크게 빗나가면 $z$ 가 커지고, 원래부터 의견이 제각각이었으면($\sigma$ 큼) 좀 빗나가도 $z$ 는 작습니다. 그래서 단순히 “10% 초과 달성”보다 훨씬 의미 있는 숫자예요.

$ z > 2$ 같은 사건만 Event 로 승격시키면, 그래프에 정말 중요한 사건만 남습니다.

7.4 라이프사이클 — 승격·강등·아카이브

주기 배치로 이 사이클을 돌립니다.

[신규]  추출 → confidence 미달이면 quarantine
          ↓
[활성]  weight 재계산 (일 1회)
          ↓
[강등]  weight < 임계값 → 조회 기본 필터에서 제외
          ↓
[아카이브] 90일 이상 강등 상태 → S3 로 내보내고
           그래프에서 삭제 (원본은 남으니 복구 가능)

여기서 중요한 게 “삭제해도 안전한 이유” 예요. source_uri 로 원본이 S3 에 남아 있고, 파이프라인이 멱등적이니까, 필요하면 언제든 다시 만들 수 있습니다. 재현 가능하면 과감히 지울 수 있어요. 이게 5장에서 멱등성을 강조한 진짜 이유입니다.

7.5 그래프 알고리즘으로 중심 잡기

가중치가 붙었으면 이제 구조적 중요도를 계산할 수 있어요. Neptune Analytics 로 돌리고 결과만 속성으로 되받습니다.

  • PageRank — “이 테마/종목이 그래프에서 얼마나 중심인가”. 언급 수만 세는 것보다 훨씬 낫습니다. 중요한 곳에서 언급된 것을 더 쳐주거든요.
  • 커뮤니티 탐지 — 공식 분류와 다르게, 실제로 함께 언급되는 종목 군집을 찾아냅니다. 이게 공식 업종과 다르면 그 자체가 인사이트예요. “GICS 상 다른 업종인데 시장은 한 덩어리로 본다”.
  • 시간 창을 나눠서 반복 — 분기별로 각각 돌려서 중심성 변화를 보면 테마의 부상과 소멸이 숫자로 보입니다.



8. 실제로 던지는 질문들

만들었으면 써봐야죠. 이 그래프가 답할 수 있게 되는 질문들이에요.

컨센서스 리비전 흐름 — 누가 먼저 움직였나

MATCH (s:Security {`~id`:'sec:kr:KR7005930003'})
      -[:HAS_ESTIMATE]->(e:Estimate)-[:REVISES*0..5]->(prev:Estimate)
WHERE e.recorded_at > datetime('2026-04-01T00:00:00Z')
RETURN e.broker, prev.target_price AS before,
       e.target_price AS after, e.recorded_at
ORDER BY e.recorded_at

전파 경로 — 이 뉴스가 어디까지 번질 수 있나

MATCH (n:NewsCluster {`~id`:$cluster})-[:ABOUT]->(s:Security)
MATCH path = (s)<-[:ISSUES]-(c:Company)
             -[:SUPPLIES_TO*1..2]-(peer:Company)
WITH peer, length(path) AS hops
MATCH (peer)-[:ISSUES]->(ps:Security)
RETURN ps.name, hops
ORDER BY hops

아직 안 밟힌 땅 — 테마에 붙었는데 커버리지 없는 종목

WITH date('2026-07-28') AS asof
MATCH (t:Theme {name:'HBM'})<-[b:BELONGS_TO]-(s:Security)
WHERE b.valid_from <= asof AND asof < b.valid_to
OPTIONAL MATCH (r:Report)-[:ABOUT]->(s)
WHERE r.published_at > datetime('2026-04-28T00:00:00Z')
WITH s, b.weight AS w, count(r) AS reports
WHERE reports = 0
RETURN s.name, w ORDER BY w DESC

이런 질문에 답이 나오는 순간, 리서치 팀이 그래프를 “IT 가 만든 뭔가”가 아니라 자기 도구로 보기 시작합니다.

근거 되짚기 — 왜 이렇게 연결됐나

MATCH (a:Company {name:'A社'})-[r:SUPPLIES_TO]->(b:Company)
RETURN r.evidence, r.source_uri, r.confidence, r.recorded_at

이게 되면 사람이 그래프를 믿습니다. 안 되면 절대 안 믿어요.

GraphRAG 로 잇기. 요즘은 여기에 LLM 을 얹는 게 자연스러운데, 순서가 중요합니다. 벡터로 후보 문서를 찾고 → 거기 나온 엔티티를 그래프에서 확장하고 → 확장된 이웃의 사실들을 근거로 함께 넣어 답을 만들게 하세요. 벡터만 쓰면 “비슷한 말”만 찾고, 그래프를 얹으면 “연결된 사실” 이 따라옵니다. 그리고 답변에 source_uri 를 반드시 같이 내보내세요.



9. 운영 — 이 그래프를 오래 살리는 법

만드는 것보다 6개월 뒤에도 신뢰받는 상태로 유지하는 게 훨씬 어렵습니다.

품질 체크는 매일 자동으로. 이 정도를 야간에 돌려서 슬랙으로 던지세요.

체크 잡아내는 문제
고아 노드 연결 안 된
엔티티
시간 구간
겹침
닫기 실패
중복 후보 해상도
누락
source_uri
없는 엣지
규칙 위반
일일 증가량
이상치
파이프라인
폭주·정지

특히 “어제보다 엣지가 10배 늘었다” 는 경보를 꼭 넣으세요. 추출 로직 버그로 그래프가 오염되는 건 대부분 이 신호로 먼저 잡힙니다.

온톨로지 변경은 코드처럼 관리. 노드/엣지 타입 정의를 YAML 로 저장소에 두고 PR 로 바꾸세요. 적재기는 그 정의를 읽어서 검증하고, 정의에 없는 타입이 들어오면 거부합니다. 이거 없으면 반년 안에 엣지 타입이 60개가 됩니다. 진짜로요.

재현성. “그래프를 지우고 처음부터 다시 만들 수 있는가”를 분기마다 실제로 해보세요. 안 해보면 어느새 못 하게 돼 있고, 그러면 스키마를 못 고칩니다. 못 고치는 그래프는 죽은 그래프예요.

비용 감각. Neptune 은 인스턴스(또는 서버리스 NCU) + 스토리지 + I/O 로 청구돼요. 실무에서 비용이 튀는 지점은 대개 둘입니다 — (1) 노이즈 데이터를 다 넣어서 스토리지·I/O 가 커지는 것, (2) 개발 클러스터를 24시간 켜두는 것. 앞엣것은 7장의 승격/아카이브 규칙이 해결하고, 뒤엣것은 서버리스 최소 NCU 를 낮게 잡거나 야간 중지로 해결합니다.

보안. Neptune 은 VPC 안에서만 붙고 IAM 서명으로 인증하니 기본 안전선은 있는데, 금융이면 여기에 더 붙여야죠.

  • 원본 리포트가 유료 구독 자료면 라이선스 범위를 반드시 확인하세요. 그래프에 본문을 복제해두면 재배포로 해석될 수 있습니다. 본문은 넣지 말고 포인터와 짧은 근거 스팬만 두는 편이 안전해요.
  • 사람 이름(애널리스트) 은 개인정보입니다. 접근 통제와 보관 기간 정책을 미리 정하세요.
  • 미공개 정보(MNPI) 가 섞일 수 있는 소스는 별도 그래프로 분리하고 접근을 나누세요. 한 그래프에 섞으면 통제가 불가능해집니다.

LLM 환각 방어 3종. (1) 스키마 강제, (2) 근거 스팬 필수, (3) 신규 관계 유형은 사람 승인. 이 셋이면 실무에서 충분히 버팁니다.



10. 0 → 1 로드맵

한 번에 다 하려다 죽는 경우를 많이 봐서, 단계로 자릅니다. 각 단계 끝에 “쓸 수 있는 것”이 하나씩 나오게 잘랐어요.

1단계 (2~3주) — 뼈대 마스터 그래프 Company / Security / Sector 만. 출처는 정형 마스터 CSV. Bulk Loader 로 한 번에 적재. 여기서 ID 정책과 엔티티 해상도 사전을 완성하는 게 목적이에요. 화려하지 않지만 이 단계를 대충 하면 뒤가 전부 무너집니다. → 산출물: 신뢰할 수 있는 종목 마스터, 별칭 사전

2단계 (3~4주) — 문서 연결 Report / Analyst / Estimate 추가. PDF 파싱 + 추출 + 근거 스팬. ABOUT/MENTIONS 구분. 여기서 처음으로 “이 종목 관련 리포트 전부 + 누가 썼나”가 나옵니다. → 산출물: 커버리지 지도, 애널리스트별 이력

3단계 (3~4주) — 시간축 정착 모든 관계에 유효구간·기록시간. 컨센서스 리비전 체인. as-of 쿼리 표준화. 이 단계가 끝나야 백테스트에 쓸 수 있는 그래프가 됩니다. → 산출물: 시점 재현 질의, 리비전 흐름 분석

4단계 (계속) — 중요도와 확장 뉴스 클러스터, 테마 생애주기, 시계열 파생 승격, 가중치·감쇠·아카이브, 그래프 알고리즘, GraphRAG. → 산출물: 테마 부상 탐지, 전파 경로 분석, 근거 달린 Q&A



마무리

정리하면 이 작업의 본질은 “S3 에 있는 걸 그래프 DB 로 옮기기”가 아니에요. 세 가지 결정을 내리는 일입니다.

  1. 시간을 어디에 붙일 것인가 — 유효시간과 기록시간을 나누고, 관계마다 알맞은 패턴(속성 / 유효구간 / 상태노드 / 이벤트)을 고르는 것.
  2. 무엇을 그래프에 두지 않을 것인가 — 시계열 원본은 밖에 두고 포인터만. 그래프는 얇고 의미 있게.
  3. 무엇을 잊을 것인가 — 가중치·감쇠·아카이브 규칙을 파이프라인 안에 박아 넣어서, 쌓일수록 좋아지게 만드는 것.

그리고 이 셋을 관통하는 한 가지 습관이 있어요. 모든 노드와 엣지가 “너 어디서 왔니”에 답할 수 있게 하는 것. source_uri, recorded_at, extractor, evidence. 이 네 개만 빠짐없이 지키면, 그래프가 커져도 신뢰가 같이 커집니다. 반대로 이걸 빼먹고 시작하면, 데이터가 늘어날수록 아무도 못 믿는 덩어리가 되고요.

일단 오늘은 여기까지…..
다음에 기회가 되면 2단계의 PDF 추출 파이프라인을 실제 코드로 좀 더 깊게 파보거나, GraphRAG 쪽을 따로 한 편 써볼게요.