2026년 4월 13일 | 프로젝트
profile_eval 출력에서 rank-shift라는 단어가 보이기 시작하면, 그다음에는 거의 반사적으로 trace diff를 열고 싶어진다. 문제는 그 사이가 은근히 길었다는 점이다. query 세트로 profile을 비교하고, 이상한 query를 찾고, 다시 같은 query로 trace를 따로 저장하고, before/after 파일을 맞춰 `trace_diff`를 돌리는 흐름이 계속 수동이었다. 며칠 동안은 이 정도도 괜찮다고 생각했는데, tuning 실험이 두세 번만 쌓여도 메모가 먼저 흐려졌다. 어떤 query가 흔들렸는지는 남는데, 그 흔들림을 바로 다시 펼쳐 보는 경로가 끊겨 있었다.
나는 이런 마지막 한 칸이 은근히 중요하다고 본다. GraphRAG 쪽은 지금 거대한 인프라를 붙이는 단계라기보다, retrieval 변화가 생겼을 때 왜 바뀌었는지를 더 빨리 읽는 판독면을 만드는 단계에 가깝다. 그래서 이번에는 새 가중치를 하나 더 얹기보다, profile 비교 결과와 trace snapshot을 한 묶음으로 남기는 쪽을 먼저 손봤다. 이름을 거창하게 붙이면 trace bundle이지만, 실제 감각은 "이상 신호를 본 자리에서 바로 deeper inspection으로 내려갈 수 있게 만든 연결부"에 더 가깝다.
Profile eval trace bundle flow
sample_queries.json
profile_eval
trace_path
trace_diff_path
profile 비교에서 이상 신호를 잡은 뒤, 같은 출력 안에서 바로 trace 파일과 diff 파일 경로까지 따라가게 만들었다.
1. 왜 이 단위를 먼저 붙였나
직전 단계까지도 `profile_eval`은 꽤 쓸 만했다. default 대비 top-1 agreement, stable top-k, avg overlap, top score delta, edge delta를 같이 보여 줬고, query별로 rank-shift가 있는지도 읽을 수 있었다. 그런데 실제로는 여기서 멈추기 쉬웠다. 예를 들어 partial query 하나가 흔들렸다는 걸 봐도, 그 다음에는 다시 단일 query CLI로 trace를 저장하고, diff 파일을 따로 만들어야 했다. 실험이 길어질수록 이런 한 칸짜리 번거로움이 기록 품질을 잡아먹는다.
나는 특히 이번 조정이 어디를 흔든 건지를 같은 세션 안에서 바로 확인하고 싶었다. top-k가 유지된 건지, 2위와 3위만 바뀐 건지, coverage를 포기하고 path를 더 강하게 탄 건지 같은 해석은 결국 trace까지 내려가야 또렷해진다. 그래서 이번 반복의 핵심은 retrieval 공식을 더 화려하게 만드는 게 아니라, 비교 요약과 trace inspection의 handoff를 코드 안으로 끌어오는 일이었다.
- profile마다 인덱스를 한 번만 build하고 query 세트를 순회한다.
- 각 query 결과에 trace_path를 남긴다.
- default 대비 비교 결과에는 trace_diff_path와 rank_shift_query_labels를 붙인다.
- trace 직렬화 로직은 별도 모듈로 분리해 단일 query CLI와 profile_eval이 같은 payload 형식을 재사용하게 했다.
2. 실제로 바꾼 구조
이번에 바꾼 건 크게 네 군데다. 첫째, `profile_eval`은 query마다 다시 pipeline을 세우지 않고 profile 단위로 인덱스를 한 번만 만든다. 지금 샘플 데이터셋은 작아서 체감이 크지 않지만, 반복 루프를 길게 가져갈수록 이런 구조가 더 자연스럽다. 둘째, retrieval 결과를 trace JSON으로 직렬화하는 부분을 `trace_payload.py`로 따로 뺐다. 예전에는 단일 query CLI 쪽 private 함수에 묶여 있었는데, 이제는 profile 비교도 같은 형식을 바로 쓴다.
셋째, `--trace-dir` 옵션을 주면 query별 결과가 `profiles/{profile}/{label}.json`으로 저장된다. 넷째, custom profile은 default profile과 같은 label을 기준으로 자동 비교해 `diffs/{profile}/{label}.json`까지 같이 만든다. 즉, profile 비교 결과를 보고 나서 내가 별도로 파일명을 정하고 trace diff를 다시 호출할 필요가 없다.
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_focus.json \
--top-k 3 \
--trace-dir traces/profile_eval_bundle_iter12
명령 자체는 단순하지만, 결과는 꽤 다르다. text 리포트에는 query별 trace 경로가 바로 보이고, rank-shift가 있는 경우에는 diff 경로까지 이어진다. JSON 요약에도 같은 정보가 들어가서 나중에 다른 스크립트로 후처리할 때도 흐름이 끊기지 않는다.
3. 샘플 profile 세 개를 같이 돌려 본 결과
이번에 같이 본 profile은 default, relation_weight_dense, path_bridge_focus 세 개다. dense profile은 top-k를 유지한 채 edge contribution을 조금 더 세게 읽는 안정형 기준선이고, path_bridge_focus는 coverage bonus를 낮추고 path/bridge 쪽을 더 강하게 봐서 일부 query를 의도적으로 흔들어 보는 실험용 기준선이다.
| profile | avg top score | avg top coverage | top-1 agreement | stable top-k | avg edge delta | 내가 읽은 의미 |
|---|---|---|---|---|---|---|
| default | 5.1701 | 100% | baseline | baseline | 0.0000 | 현재 비교 기준선. 세 query 모두 top-1은 Incident resolution이다. |
| relation_weight_dense | 5.2367 | 100% | 100% | 100% | +0.1500 | 랭킹은 유지하고 edge contribution만 더 강하게 읽는 안정형 profile. |
| path_bridge_focus | 4.1756 | 88.89% | 67% | 67% | -0.5667 | coverage를 덜 보고 bridge/path를 더 보면서 partial query에서 실제 rank shift를 만든다. |
개인적으로 재미있었던 건 dense profile보다 path_bridge_focus가 더 유용한 실험용 profile으로 남았다는 점이다. 품질이 좋아졌다는 뜻은 아니다. 오히려 반대에 가깝다. 하지만 이런 profile이 있어야 trace bundle이 진짜로 쓸모 있는지 검증할 수 있다. 실제로 `partial-coverage` query에서는 Retrieval planner가 3위에서 1위로 올라왔다. default에서는 full coverage를 잡은 Incident resolution이 1위였는데, path_bridge_focus에서는 coverage 보상이 줄어들면서 Root Cause Note 쪽 evidence와 경로 보상이 더 강하게 읽혔다.
partial-coverage query의 rank shift
이제는 rank shift가 생긴 query를 본 자리에서 바로 diff JSON까지 연결해 읽을 수 있다.
4. 이번 변화에서 내가 중요하게 본 포인트
`path_bridge_focus`의 결과 자체를 좋은 profile이라고 말하고 싶지는 않다. 오히려 이 profile은 coverage를 덜 보고도 path/bridge 신호가 랭킹을 얼마나 흔들 수 있는지를 보여 주는 실험용 profile에 가깝다. 그런데 그게 오히려 좋았다. retrieval 튜닝에서는 항상 성능이 올라가는 실험만 필요한 게 아니라, 특정 보정이 어떤 종류의 오판을 만드는지도 빨리 읽혀야 하기 때문이다.
이번에는 그 장면이 꽤 선명했다. Retrieval planner는 coverage를 하나 포기했는데도 `summarizes`와 path 보상을 강하게 받으면서 1위로 올라왔다. 반대로 Incident resolution은 여전히 full coverage를 잡고 있었지만, coverage bonus가 약해진 환경에서는 그 장점이 충분히 지배적이지 않았다. 이런 변화가 trace diff에 그대로 남아 있으니, 다음 실험에서 내가 손댈 축도 더 선명해진다. 예를 들면 coverage를 다시 조금 올릴지, bridge bonus를 쿼리 길이에 따라 다르게 줄지, partial query만 따로 다루는 reranking을 둘지 같은 질문이 바로 나온다.
- 이제는 rank shift가 난 query만 빠르게 모을 수 있다.
- summary에서 이상 신호를 본 뒤 trace 파일과 diff 파일로 바로 내려갈 수 있다.
- stable-top-k가 유지된 profile과 실제로 랭킹을 흔드는 profile을 같은 도구로 비교할 수 있다.
5. 다음에 바로 해볼 것
다음 단계는 두 갈래다. 하나는 이번에 남긴 trace bundle에서 rank shift가 생긴 query만 별도 JSON 리포트로 좁혀 저장하는 것, 다른 하나는 샘플 문서셋을 넘어 실제 문서셋용 query 세트를 만드는 일이다. 지금은 `traces/profile_eval_bundle_iter12/` 아래에 산출물이 잘 남지만, 반복이 더 길어지면 결국 바뀐 query만 먼저 보고 싶어질 가능성이 크다. 그래서 다음 반복에서는 이 묶음을 조금 더 압축해 보고 싶다.
이번 작업은 겉으로 보면 출력 포맷 보강처럼 보인다. 그런데 내 감각으로는 retrieval 튜닝을 감상 메모에서 재진입 가능한 실험 기록으로 조금 더 옮긴 단계에 가깝다. GraphRAG 쪽은 요즘 이런 작은 연결부를 붙일 때 가장 덜 허무하다. 새 점수식을 하나 더 넣는 일보다, 바뀐 결과를 잃어버리지 않는 구조를 먼저 만드는 쪽이 다음 삽질의 밀도를 훨씬 높여 주기 때문이다.
'[AI 실험실] > [개인 프로젝트] GraphRAG' 카테고리의 다른 글
| 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 |
| GraphRAG Relation-Weighted Scoring (0) | 2026.04.05 |
| GraphRAG MVP 1차: 그래프 추출·하이브리드 검색 (0) | 2026.04.02 |