S3 에 쌓인 금융 리포트를 Neptune 지식그래프로 — 시간축 위에 의미를 쌓는 파이프라인
Summary
회사 S3 버킷을 열어보면 이런 게 잔뜩 쌓여 있죠. 증권사 리서치 PDF 수만 건, 종목별 컨센서스 스냅샷 CSV, 뉴스 크롤링 JSON, 일별 가격·수급 시계열, 테마/업종 분류 마스터. 데이터는 다 있는데, 서로 이어져 있지 않아요. “이번 분기에 HBM 테마가 왜 떴는지, 누가 먼저 말했고, 어떤 종목으로 번졌는지”를 물으면 아무도 한 방에 답을 못 합니다. 사람이 폴더를 뒤져서 손으로 잇고 있죠.
이 글은 그걸 지식그래프(Knowledge Graph) 로 옮기는 이야기예요. 저장소는 Amazon Neptune (openCypher 를 쓰니까 Neo4j 문법 그대로 쓸 수 있어요)을 기준으로 잡았습니다.
그런데 이 작업의 진짜 어려움은 “그래프 DB 를 띄우는 것”이 아니에요. 진짜 어려움은 셋입니다.
- 시간 — 금융 데이터는 “지금 참인 값”이 아니라 “그 시점에 우리가 알고 있던 값” 이 중요해요. 이걸 놓치면 백테스트가 통째로 거짓말이 됩니다.
- 경계 — 뭘 그래프에 넣고 뭘 넣지 말아야 하는지. 시계열 원본까지 다 노드로 만들면 6개월 뒤에 아무도 못 씁니다.
- 중요도 — 그래프는 쌓기만 하면 반드시 쓰레기가 돼요. 뭘 남기고 뭘 죽일지를 파이프라인 안에 규칙으로 박아 넣어야 합니다.
이 셋을 중심으로, 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 |
종목 ↔ 종목 |
구간 |
여기서 ABOUT 과 MENTIONS 를 나눈 게 포인트예요. 리포트가 다루는 대상(제목·커버 종목)과 본문에 스쳐 지나간 언급은 무게가 완전히 다릅니다. 이걸 한 타입으로 뭉개면 나중에 “언급만 됐는데 관련 종목으로 뜨는” 노이즈를 못 걷어냅니다.
모든 엣지에 공통으로 붙일 속성을 미리 정해두세요. 이게 나중에 품질과 신뢰의 근거가 됩니다.
valid_from / valid_to — 유효 구간
recorded_at — 기록 시점
source_uri — 근거 S3 키 (필수!)
extractor — 어떤 버전이 뽑았나
confidence — 0.0 ~ 1.0
weight — 중요도 (7장에서 계산)
source_uri 와 extractor 는 타협하지 마세요. “이 관계는 어느 문서 몇 페이지에서 나왔는가”를 못 대는 그래프는 금융에서 못 씁니다. 사람이 못 믿거든요.
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단 구조는 이래요.
- 정확 매칭 — 종목코드·ISIN·법인번호가 텍스트에 있으면 끝. 가장 싸고 정확.
- 블로킹 + 후보 랭킹 — 정확 매칭이 안 되면, 이름 정규화(공백·(주)·법인격 제거) 후 후보를 좁히고, 문맥 점수로 순위를 매깁니다. 문맥 점수에 그래프를 쓰는 게 재밌는 부분이에요. 같은 문서에 이미 확정된 엔티티들과 그래프상 가까운 후보에 가점을 줍니다. “HBM, 반도체, SK 가 나온 문서에서 ‘A社’는 반도체 소재 회사일 가능성이 높다” 는 식이죠.
- 애매하면 보류 — 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
})
stdev 와 n_analysts 를 꼭 저장하세요. 다음 장에서 놀람도(surprise) 계산에 쓰입니다. 평균만 저장하면 “얼마나 의외인가”를 못 잽니다.
6.3 테마·업종
둘은 성격이 달라서 분리하는 걸 권합니다.
| 축 | 성격 | 모델링 |
|---|---|---|
| 업종 Sector |
공식 분류 계층적 |
트리 + 유효구간 |
| 테마 Theme |
시장 서사 중첩·부침 |
가중 다대다 |
업종은 (:Sector)-[:PARENT_OF]->(:Sector) 로 계층을 만들고, 종목은 리프에 붙입니다. 분류체계가 바뀌면 유효구간으로 갈아끼우면 되고요.
테마는 다릅니다. 한 종목이 여러 테마에 정도 차이를 두고 속하고, 테마 자체가 태어나고 죽어요. 그래서 BELONGS_TO 에 weight 를 두고, 테마 노드에 생애주기 속성을 답니다.
(: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 |
EventSURPRISE |
이벤트라 영구 |
| 테마 편입주 동조 상승 |
Theme momentum ↑ |
30일 감쇠 |
| 거래량 이상 급증 |
EventVOL_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 로 옮기기”가 아니에요. 세 가지 결정을 내리는 일입니다.
- 시간을 어디에 붙일 것인가 — 유효시간과 기록시간을 나누고, 관계마다 알맞은 패턴(속성 / 유효구간 / 상태노드 / 이벤트)을 고르는 것.
- 무엇을 그래프에 두지 않을 것인가 — 시계열 원본은 밖에 두고 포인터만. 그래프는 얇고 의미 있게.
- 무엇을 잊을 것인가 — 가중치·감쇠·아카이브 규칙을 파이프라인 안에 박아 넣어서, 쌓일수록 좋아지게 만드는 것.
그리고 이 셋을 관통하는 한 가지 습관이 있어요. 모든 노드와 엣지가 “너 어디서 왔니”에 답할 수 있게 하는 것. source_uri, recorded_at, extractor, evidence. 이 네 개만 빠짐없이 지키면, 그래프가 커져도 신뢰가 같이 커집니다. 반대로 이걸 빼먹고 시작하면, 데이터가 늘어날수록 아무도 못 믿는 덩어리가 되고요.
일단 오늘은 여기까지…..
다음에 기회가 되면 2단계의 PDF 추출 파이프라인을 실제 코드로 좀 더 깊게 파보거나, GraphRAG 쪽을 따로 한 편 써볼게요.