2026년 4월 24일 | 프로젝트
quality_history.json 파일 하나가 생기자 PASS, WARN, HARD-FAIL 세 칸이 처음으로 시간축을 갖기 시작했다. 최근 GraphRAG 쪽은 rank shift report, cluster/tag summary, expected title 기반 품질 지표, staged quality gate까지 차근차근 붙여 오면서 지금 이 profile이 어떤 상태인가는 꽤 빨리 읽을 수 있게 됐다. 그런데 막상 며칠 단위로 실험을 이어 가려니 또 다른 빈칸이 남아 있었다. 이 상태가 어제도 같았는지, warning scope가 늘어난 건지, 숫자는 그대로인데 실패 성격이 바뀐 건지를 보려면 결국 예전 summary를 다시 열어 손으로 대조해야 했다.
나는 이런 종류의 비교가 늦어질수록 실험 메모가 금방 흐려진다고 느낀다. GraphRAG처럼 path, edge, coverage가 같이 당기는 구조에서는 score를 더 예쁘게 만드는 것보다 언제부터 흔들리기 시작했는지를 먼저 남기는 편이 더 중요할 때가 많다. 그래서 이번에는 retrieval 공식을 더 만지기보다, staged gate 결과를 날짜별로 누적하는 quality history layer를 먼저 붙였다.
1. 현재 상태만 찍어 두면 아쉬웠던 이유
staged quality gate를 붙인 뒤에는 profile을 세 칸으로 나누는 일 자체는 쉬워졌다. `relation_weight_dense`는 PASS, `path_bridge_probe`는 WARN, `path_bridge_focus`는 HARD-FAIL처럼 한 줄만 봐도 어디를 다시 열어야 할지 감이 온다. 문제는 그 다음이었다. PASS는 계속 PASS였는지, WARN이 HARD-FAIL로 꺾인 건 오늘 처음인지, 혹은 status는 그대로인데 docs 계열 warning scope가 하나 더 붙은 건지를 보려면 결국 옛날 summary를 다시 꺼내야 했다.
이 비교는 할 수는 있는데, 늘 사람이 늦게 한다. 내가 실제로 보고 싶었던 건 거창한 대시보드보다도 현재 summary를 읽는 순간 바로 붙어 있는 바로 직전 좌표였다. 그래서 이번 작업의 중심은 per-query 결과를 더 불리는 쪽보다, 각 run에서 profile별 핵심 상태만 얇게 뽑아 시간축으로 쌓는 쪽에 있었다. trace bundle은 이미 충분히 두껍고, history는 그걸 대신하는 저장소라기보다 다시 열 순서를 정해 주는 인덱스에 더 가깝다.
- 현재 run의 `quality_gate=PASS/WARN/HARD-FAIL`
- 이전 run 대비 `history_transition`
- `top1_hitΔ`, `full_coverageΔ` 같은 얇은 변화량
- 필요할 때만 rank-shift report와 trace diff 재진입
2. 이번에 실제로 붙인 것
코드 쪽에서는 `profile_eval`에 history 관련 옵션 네 개를 추가했다. `--history-file`은 run snapshot을 누적할 파일이고, `--history-label`과 `--history-recorded-at`은 그 점에 이름과 시각을 붙인다. `--append-history`를 주면 현재 실행 결과를 파일 끝에 붙인 뒤, 바로 그 파일을 다시 읽어 이전 상태와 지금 상태의 차이를 summary에 같이 써 준다.
여기서 일부러 욕심을 줄인 부분이 있다. history 파일에는 per-query 전체 ranking이나 trace payload를 다시 복제하지 않았다. 그런 건 이미 trace bundle 쪽이 훨씬 잘 들고 있다. 대신 profile 단위로 `quality_metrics`, `quality_gate.status`, warning/hard-fail scope 목록, 그리고 default 대비 요약치 중 history에서 바로 쓰는 얇은 필드만 남겼다. 이 정도만 있어도 다음 run에서 상태가 유지됐는지, 완만하게 악화됐는지, 경계선을 넘었는지를 바로 읽기에는 충분했다.
PYTHONPATH=src:. python -m graphrag_mvp.profile_eval --dataset data/sample_documents.json --queries-file data/sample_queries.json --profile configs/relation_weight_dense.json --profile configs/path_bridge_probe.json --profile configs/path_bridge_focus.json --quality-gate configs/quality_gate_baseline.json --history-file traces/profile_eval_bundle_iter19_quality_history/quality_history.json --history-label iter19 --append-history
텍스트 출력도 같이 바꿨다. profile 헤더에 `history_runs`, `history_transition`, `top1_hitΔ`, `full_coverageΔ`가 붙고, 본문에는 `quality-history:` 경로와 `history_sequence=...`가 따로 찍힌다. 그래서 읽는 순서가 자연스럽게 바뀐다. 예전에는 `현재 gate 상태 → cluster/tag summary → trace diff`였다면, 이제는 `현재 gate 상태 → 이전 run 대비 변화 → 그래도 더 봐야 하면 trace diff` 순서로 한 단계 더 짧아졌다.
3. iter18을 seed로 넣고 iter19를 붙인 이유
이번 history는 빈 파일에서 시작하지 않았다. 기존에 남겨 둔 `traces/profile_eval_bundle_iter18_staged_quality_gate/summary.json`을 seed로 삼아 `iter18` snapshot을 먼저 만들고, 그다음 현재 코드로 `iter19`를 append했다. 이 방식이 좋았던 이유는 단순하다. 기능을 붙인 날 바로 이전 기준선 하나와 현재 기준선 하나를 같은 파일 안에 넣어 볼 수 있기 때문이다. history는 run이 쌓일수록 더 유용해지지만, 첫날부터 비교점이 하나 있어야 읽는 감각이 잡힌다.
Figure 1. iter18과 iter19 두 run에서 dense / probe / focus profile의 PASS, WARN, HARD-FAIL 상태를 같은 축에 올린 history chart.
재미있는 점은, 이번 run에서 status가 극적으로 바뀌지는 않았다는 것이다. `relation_weight_dense`는 PASS에서 PASS로, `path_bridge_probe`는 WARN에서 WARN으로, `path_bridge_focus`는 HARD-FAIL에서 HARD-FAIL로 남았다. 겉으로 보면 새 소식이 없는 것처럼 보일 수도 있다. 그런데 나는 오히려 이 flat line이 중요하다고 느꼈다. 실험 프로젝트에서는 나빠진 날만 눈에 잘 띄는데, 실제로는 계속 PASS인 기준선, 계속 WARN에 묶인 탐색용 profile, 계속 버려야 하는 HARD-FAIL 후보를 날짜 순으로 남겨 두는 것만으로도 다음 판단이 훨씬 빨라진다.
예를 들어 다음 run에서 probe가 WARN에서 HARD-FAIL로 꺾인다면, 그때는 trace bundle을 바로 다시 열면 된다. 반대로 focus가 갑자기 WARN으로 올라오면 scoring profile보다 gate 기준이나 query set이 바뀐 것부터 먼저 의심할 수 있다. 지금은 변화량이 `+0%`여도, 그 0이 history 파일 안에 들어가는 순간부터 다음 변화는 훨씬 또렷하게 보인다.
4. 숫자 두 줄을 시간축에 놓고 보니 달라진 것
status만 history로 남기면 너무 거칠어질 수 있어서, `top1_hit`과 `full_coverage`도 같이 붙였다. dense는 두 run 모두 100%로 그대로 버티고, probe는 83% / 83%, focus는 67% / 67%에서 그대로 눌려 있다. 이 정도 정보만 있어도 status가 같을 때 그 안쪽의 결이 어느 정도 보인다. 예를 들어 둘 다 WARN이라도 한쪽은 89%에서 버티고 다른 한쪽은 83%에서 미끄러지고 있다면, 다음에 먼저 볼 대상이 달라진다.
Figure 2. dense / probe / focus profile의 top-1 hit rate와 full coverage rate를 run별로 누적한 metrics history.
이번에는 숫자 자체가 그대로라서 `top1_hitΔ=+0%`, `full_coverageΔ=+0%`로 찍혔다. 그런데 이 출력이 생기자 summary를 읽는 감각이 꽤 달라졌다. 예전에는 probe가 WARN인 걸 본 뒤에도 '이게 전보다 더 나빠진 WARN인지, 그냥 같은 WARN인지'를 다시 찾아야 했다. 이제는 헤더에서 바로 `history_transition=WARN->WARN`과 `top1_hitΔ=+0%`를 보고, 그다음에만 cluster/tag summary를 열면 된다. 작은 차이인데, 실제로는 trace를 여는 횟수를 꽤 줄여 준다.
5. 테스트와 커밋 메모
이번 작업은 retrieval 로직을 직접 바꾸는 단계가 아니라 history layer를 덧대는 작업에 가까워서 더 조심했다. 현재 score를 건드리지 않은 채 history만 붙어야 했기 때문에, 기존 `tests.test_pipeline`, `tests.test_profile_eval`를 다시 돌려 회귀가 없는지 먼저 확인했고, history 파일 append와 parser option을 따로 검사하는 테스트도 추가했다. 결과적으로 24개 테스트가 전부 통과했다.
로컬에서 남긴 커밋은 아래 한 줄이다. 이름 그대로, 이번 변화의 핵심은 timeline이다. score를 더 뾰족하게 만든 쪽보다 quality gate 결과를 시간축 위에 올려놓는 최소 기준선을 만든 쪽에 더 가깝다.
commit: 1af84e3
message: Add GraphRAG quality history timeline
tests: 24 passed
artifacts: iteration-19-quality-history-status.png / iteration-19-quality-history-metrics.png
6. 다음으로 보고 싶은 것
이제 다음 질문은 조금 더 또렷하다. 첫째, history 파일에 query set 버전과 gate 버전을 같이 남겨서 서로 다른 기준선이 섞였을 때 자동으로 경고하게 만들고 싶다. 둘째, warning이나 hard-fail scope가 늘어난 profile만 따로 inspection queue의 위로 올리면 trace bundle을 여는 순서를 더 줄일 수 있을 것 같다. 셋째, 지금은 샘플 query 세트 기준인데, 실제 문서셋에서도 같은 포맷을 남겨 샘플 기준선과 운영 기준선을 따로 비교해 보고 싶다.
결국 이번에 붙인 건 화려한 기능이라기보다 상태를 잊지 않기 위한 얇은 메모리에 가깝다. 그런데 GraphRAG 같은 프로젝트는 이런 층이 없으면 금방 하루 단위의 인상평으로 굴러간다. 적어도 지금부터는, PASS와 WARN과 HARD-FAIL이 그날그날의 기분이 아니라 어느 run에서부터 어떤 모양으로 유지되거나 꺾였는지를 같은 파일 안에서 볼 수 있게 됐다. 나는 이게 다음 실험을 덜 막막하게 만들어 주는 변화라고 본다.
'[AI 실험실] > [개인 프로젝트] GraphRAG' 카테고리의 다른 글
| GraphRAG | History mismatch 이유별 섹션 (0) | 2026.04.29 |
|---|---|
| GraphRAG | History mismatch 리포트 추가 (0) | 2026.04.27 |
| GraphRAG | Query Cluster 집계 추가 (0) | 2026.04.16 |
| GraphRAG | Rank Shift Reason Labels (0) | 2026.04.15 |
| GraphRAG | Rank Shift Focus Report (0) | 2026.04.15 |