Graph Rag를 개선하고 재측정하면서 느낀 것들

Tasker_Jang·2026년 5월 31일
post-thumbnail

개인 프로젝트 doc-graph-agent 이야기입니다. 문서를 지식그래프로 바꾸고, 그 위에서 질문에 답하는 GraphRAG를 만들었는데, 단순 어휘검색(BM25)이랑 같은 문제로 붙여보니 졌습니다. 이 글은 그다음에 한 일들의 기록인데, 솔직히 성공기는 아닙니다. 측정하고, 원인 까보고, 한 군데 고치고, 다시 측정하는 걸 여섯 번쯤 반복한 이야기에 가깝습니다.

먼저 개선과정을 한눈에 정리하면 이렇습니다.

단계뭘 했나결과
출발GraphRAG vs BM25, 80문항 측정완패
1망가진 추출 고치기정확도 2배, 그래도 BM25엔 짐
2답변 프롬프트·청크 랭킹 손보기둘 다 실패
3BM25를 그래프에 붙이기factual에서 거의 +1점
4PageRank로 멀티홉 뚫기+0.21
5BM25랑 PageRank 합치기세 방식 중 최고, faithfulness 100%

미리 말씀드리면, 이 글에서 제일 많이 한 건 지표를 의심하는 일이었습니다. faithfulness도 routing accuracy도 한 번씩 엉뚱한 걸 재고 있었는데, 평균값만 봤다면 매번 잘못된 결론을 냈을 겁니다. 그리고 마지막엔 그 지표가 "이제 그만해도 된다"고 알려줬습니다.

출발 — 그래프가 비어 있었습니다

평가셋에 두산밥캣 기업분석 리포트가 있습니다. 당연히 두산밥캣 질문에 답하려면 그래프에 두산밥캣이 있어야 하는데,

MATCH (e:Entity) WHERE e.name CONTAINS '두산' RETURN e;
// → 0건

없었습니다. 청크 텍스트엔 "두산밥캣의 1분기 매출액은 2조 1,676억원…" 같은 멀쩡한 문장이 다 들어 있는데도 엔티티가 하나도 안 잡혀 있었어요. GraphRAG가 BM25한테 진 이유는 방법론이 아니라 그냥 그래프가 비어서였던 겁니다. 방법이 틀렸나부터 의심했는데, 그 전에 그래프 자체를 봤더니 거기서 진짜 문제가 나온 거죠.

1. 그래프를 살리기

원인은 세가지였습니다.

하나, 토큰 상한이 응답을 잘랐습니다. 추출기가 청크마다 LLM에 JSON을 요청하는데, 출력 토큰이 1,500으로 묶여 있었어요. 수치가 빽빽한 금융 청크는 JSON이 길어서 중간에 잘리고, 잘린 JSON은 파싱에 실패합니다.

둘, 그 실패가 조용히 묻혔습니다. 파싱 실패도 빈 응답도 전부 빈 결과로 그냥 반환되고 있었어요. 그래서 0건짜리 깨진 그래프가 아무 경고 없이 적재됐습니다. 개인적으로 제일 무서운 종류의 버그라고 생각합니다. 시스템은 "정상 동작 중"이라고 말하고 있으니까요.

셋, 표 셀이 회사로 둔갑했습니다. 어느 보도자료의 표 데이터가 :Company로 잔뜩 잡혀서, 실재하지 않는 회사 노드가 154개나 쌓여 있었습니다.

처방은 세 가지였습니다. 추출 모델을 Kimi에서 GPT-5.2로 바꿨고(측정 모델이 이미 GPT-5.2였기도 했고, reasoning 쪽이라 분류성 추출이 더 안정적입니다), 프롬프트를 모델 특성에 맞게 다시 썼습니다. OpenAI 공식 가이드의 구조화 추출 패턴을 참고해서 출력 스키마를 JSON으로 못 박고, "없으면 비우되 명백한 건 놓치지 마라", 반환 직전에 빠뜨린 게 없는지 한 번 더 훑게 했습니다. 마지막으로 토큰 상한을 4,000으로 올리고, 조용한 실패를 시끄러운 실패로 바꿨습니다. 빈 응답이면 ERROR를 찍고, 문서 단위로 실패율이 30%를 넘으면 경고가 뜨도록요.

결과적으로 핵심 문서가 살아났습니다.

지표BeforeAfter
두산밥캣 엔티티0건99 (관계 70)
그래프 Entity 노드178 (붕괴)약 1,100
:Company표 셀 노이즈 154실재 기업 158
엔티티/관계 타입편중·노이즈5종/4종 전부

재밌게도 추출이 제대로 되기 시작하니까 그동안 숨어 있던 버그가 하나 더 드러났습니다. 링킹 단계의 group_id가 문서 간에 전역 유일하지 않아서, 서로 다른 문서의 무관한 엔티티가 같은 ID로 병합되던 문제였어요. 네임스페이스를 prefix해서 고쳤습니다. 한 버그를 고치면 다음 버그가 보이는, 디버깅의 흔한 패턴이죠.

추출 의존 지표(Answer Correctness, Entity Coverage)가 거의 2배가 됐습니다. 그런데도 같은 80문항에서 여전히 BM25한테 졌어요. 추출이 좋아진 것과 답이 좋아진 건 다른 문제였던 겁니다. 여기서 배운 건 단순합니다. 그래프 품질은 retrieval보다 한참 앞단에서 결정된다, 그리고 실패는 일단 시끄럽게 만들어야 한다는 것.

2. 두 번의 실패

격차를 좁히려고 의심 가는 두 곳을 정석대로 고쳐봤는데, 둘 다 실패했습니다. 그런데 그 실패가 진짜 병목을 알려줬습니다.

먼저 답변 프롬프트를 손봤습니다. 답변에 HAS_METRIC이나 두산밥캣(247560), 청크 c0001 같은 내부 용어가 새어 나오고 서술이 장황했거든요. 그래서 금융 분석가 페르소나로 바꾸고, 내부 용어 금지하고, 2~4문장으로 줄이는 프롬프트를 만들었습니다. 그런데 노렸던 faithfulness가 오히려 7.5%p씩 떨어졌어요.

왜 거꾸로 갔는지 per-QA로 까보다가 좀 멈칫했습니다. faithfulness 판정기가 대조하는 "원문"이 검색된 청크가 아니라 정답(gold) 문자열이었던 거예요. 예를 들어 "두산밥캣의 목표주가는 80,000원입니다"는 정답(AC 5점)인데, gold가 딱 "80,000원"이다 보니 주어를 말한 것조차 "원문에 없는 내용"으로 깎였습니다. 답이 친절할수록 점수가 내려가는 구조였던 거죠. 환각을 재는 게 아니라 gold와의 겹침을 재고 있었던 셈입니다. 최적화하기 전에 이 지표가 뭘 재는지부터 봤어야 했는데. 이후로 faithfulness는 검색된 청크 기준으로 대조하도록 고쳤습니다.

두 번째로 청크 랭킹을 손봤습니다. local retriever가 엔티티에 붙은 청크를 사실상 아무거나 3개 가져오길래, 임베딩 코사인으로 질문에 가까운 top-3만 고르게 했어요. 결과는 거의 평탄했고 응답 시간만 4배(최대 44초)로 늘었습니다.

두 실패가 같은 곳을 가리켰습니다. 병목은 청크 랭킹도 답변 프롬프트도 아니었어요. 후보 풀 자체가 엔티티 → MENTIONS → 청크로 그래프에 묶여 있다는 것, 그리고 청크 내용이 OCR로 깨져 있다는 것이 문제였습니다. 회사명을 직접 안 적은 재무 청크는 MENTIONS 엣지가 없어서 후보에 아예 못 들어오고, 후보에 없는 청크는 어떤 랭킹으로도 살릴 수가 없으니까요. 정석대로 고쳤는데 효과가 없다는 건, 병목이 거기가 아니라는 걸 측정으로 증명한 거나 마찬가지였습니다.

3. BM25 — 그래프를 우회하기

진단이 후보 풀을 가리켰으니, 처방은 후보 풀을 그래프에서 떼어내는 것이었습니다. 엔티티나 MENTIONS를 거치지 않고 청크 텍스트를 어휘 매칭으로 바로 검색하면 됩니다. 그게 BM25(Neo4j fulltext)고요. 한국어에서 한 가지 함정은, 인덱스를 cjk analyzer로 만들어야 "두산밥캣의"와 "두산밥캣"이 매칭된다는 점이었습니다.

지표 (VectorRAG QA)beforeafterΔ
Answer Correctness2.603.58+0.98
수치 정확도0.4240.634+0.21
평균 응답10.4s6.4s−4.0s

factual 40개 중 36개가 BM25로 가서 정확도가 거의 1점 뛰었습니다. BM25만 쓰는 baseline(AC 약 4.0)과의 격차도 1.4에서 0.42까지 좁혀졌고요. 그래프 시스템이 자기 약점을 BM25로 메운 셈입니다.

그런데 GraphRAG 쪽 routing accuracy가 85%에서 67.5%로 떨어졌습니다. 처음엔 이게 비용처럼 보였는데, per-QA를 까보니 얘기가 달랐어요. BM25로 "잘못" 간 9개의 평균 점수가 3.22, local로 "맞게" 간 24개가 2.08이었습니다. 잘못 갔다는 쪽이 더 잘 맞힌 거죠. expected_route 라벨이 "그래프 질문은 그래프로 가야 한다"는 가정을 박아뒀는데, 데이터가 그 가정을 반박한 겁니다. routing accuracy가 떨어진 것의 상당 부분은 BM25가 정답을 맞혀서 받은 페널티였어요. 또 한 번 지표가 거짓말을 한 셈입니다.

4. PageRank — 1-hop 병목 뚫기

BM25는 우회였습니다. 정작 그래프의 진짜 무기인 관계 추론(local retriever)은 여전히 "엔티티에서 딱 한 다리 건넌 청크"까지만 보는 병목에 갇혀 있었어요. "미래에셋 연금자산의 분기별 추이"처럼 답이 여러 청크에 흩어진 질문엔 무력했습니다.

그래서 이번엔 우회가 아니라 그래프 구조를 직접 검색에 썼습니다. HippoRAG식 Personalized PageRank인데, 질문에서 뽑은 엔티티를 seed로 두고 거기에 점프 확률을 몰아 PageRank를 돌립니다. 그러면 활성화가 관계망을 따라 퍼져서, 한 다리 너머의 청크까지 후보로 들어옵니다. 그래프가 작아서(1,100노드, 9,474엣지) GDS 같은 건 필요 없었고 networkx로 수 밀리초면 끝났습니다.

깨끗하게 격리한 24개 질문에서 local 2.04에서 PPR 2.25로 올랐습니다(6승 2패 16무). 멀티홉이나 인과 질문에서 이기고, 이미 한 다리로 깔끔하게 답하던 질문에선 살짝 손해를 봤어요. 딱 HippoRAG에서 말하는 거동이었습니다.

그런데 이번엔 faithfulness가 97%에서 84%로 떨어졌습니다. 6개를 하나씩 까봤더니, local의 97%는 사실 대부분 "현재 subgraph에 관계가 없어 확인할 수 없습니다"라며 답을 회피한 덕이었어요. 회피는 거짓말을 안 하니까 그냥 faithful로 잡힙니다. PPR은 실제로 답을 하기 때문에 그제야 충실도 리스크가 드러난 거고요. 정작 진짜 위험한 환각은 40개 중 딱 1건이었습니다. faithfulness 97%가 높았던 건 retriever가 훌륭해서가 아니라 답을 포기해서 나온 숫자였던 거죠.

5. 둘을 합치기 (RRF), 그리고 진짜 100%

여기까지 오니 그림이 분명해졌습니다. BM25는 factual에서, PageRank는 멀티홉에서 이깁니다. 서로 다른 질문에서요. 그런데 라우터는 둘 중 하나만 고르니까 매번 나머지의 강점을 버리고 있었던 거예요.

그래서 마지막엔 고르지 말고 합치기로 했습니다. 그래프로 라우팅된 질문에서 BM25 청크와 PageRank 청크를 Reciprocal Rank Fusion(Cormack 2009)으로 합쳤어요. 점수 스케일이 다른 두 랭킹(BM25는 Lucene 점수, PPR은 확률)을 순위만 가지고 합치는 방식이라 정규화가 필요 없습니다.

# score(c) = Σ(검색기별) 1 / (k + rank), k = 60
def _rrf_fuse(ranked_lists, k=60, top_k=8):
    scores, meta = {}, {}
    for ranked in ranked_lists:
        for rank, ch in enumerate(ranked, start=1):
            cid = ch["chunk_id"]
            scores[cid] = scores.get(cid, 0.0) + 1.0 / (k + rank)
            meta.setdefault(cid, ch)
    return [meta[c] for c, _ in sorted(scores.items(), key=lambda kv: -kv[1])[:top_k]]

같은 24개 질문에서 세 방식을 비교한 결과입니다.

지표 (GraphRAG 40)localPPRHybrid
Answer Correctness2.582.732.90
전환된 24개 AC2.042.252.58
Faithfulness97% (36/37)84% (31/37)100% (39/39)
Conciseness3.624.054.10

세 지표 모두 hybrid가 제일 좋았습니다. 그리고 4편에서 회피 때문에 부풀었다던 그 faithfulness가, 이번엔 근거가 또렷한 BM25 청크가 섞이면서 진짜로 100%가 됐어요. 측정 가능한 분모도 37에서 39로 늘었습니다. 융합이 한쪽이 못 가져온 빈 구멍까지 메운 거죠. 이번 100%는 회피의 착시가 아닙니다. 정확도도 같이 최고였으니까요.

최종 80문항 측정은 이렇게 나왔습니다.

VectorRAG QA (40)GraphRAG QA (40)
Answer Correctness3.732.90
Faithfulness89.5%100%
수치 정확도0.6640.536
Conciseness4.344.10
라우팅 분포bm25 37hybrid 24 / bm25 9 / t2c 6

6. 그리고 멈췄습니다

여기서 더 짜낼 수도 있었습니다. 그런데 faithfulness가 100%인데 정확도가 2.9에 머물렀다는 조합이 멈출 자리를 알려줬어요.

faithfulness 100%라는 건 답이 검색된 청크 안에서는 완전히 충실하다는 뜻입니다. 그런데도 정확도가 안 오른다는 건, 검색이 못 가져온 게 아니라 코퍼스의 청크 자체가 정답을 충분히 담고 있지 않다는 얘기입니다. 어휘(BM25)랑 구조(PageRank)를 둘 다 던져서 합쳤는데도 못 채웠다면, 남은 벽은 검색 알고리즘이 아니라 데이터인 거죠.

실제로 까보면 두 가지가 천장이었습니다. 하나는 분기-값이 안 묶여 있다는 것. "연금자산 52.2조(+70.3%)" 같은 값은 있는데 이게 몇 분기 수치인지를 그래프가 모릅니다. 그러니 1분기부터 4분기까지 시계열로 재구성이 안 돼요. 다른 하나는 OCR 손상입니다. +3.396(원래 +3.3%인데 % 기호가 깨진 것)이나 2025.12.37 같은 불가능한 날짜가 엔티티로 그대로 들어가 있었습니다.

이건 엔티티 추출을 다시 하면서 수치에 분기를 묶고, OCR/VLM으로 재추출하는 영역입니다. retrieval 사이클이랑은 별개의 큰 작업이라, retrieval 개선은 여기서 닫기로 했습니다. (PageRank에 IDF 비슷한 node specificity를 더하는 미세조정도 만들어는 뒀는데, BM25 융합이 이미 곁가지 청크 문제를 더 크게 해결해버려서 측정 대기로 남겨뒀습니다.)

마치며

6주 동안 느낀 건, GraphRAG가 마케팅 문구처럼 "BM25보다 무조건 좋다"는 게 아니라는 점이었습니다. factual은 BM25가, 멀티홉은 그래프가, 그리고 둘을 합치면 양쪽 다 잘하는데, 그것도 데이터가 받쳐주는 곳에서만 그렇습니다. 사실 이번에 한 일 중에 새로운 알고리즘을 발명한 건 없습니다. 매번 측정하고, 지표를 한 번씩 의심하고, 다음에 뭘 할지를 느낌이 아니라 숫자로 정한 게 전부예요. 그리고 마지막엔 그 측정이 이제 그만해도 된다고 알려줬습니다.


doc-graph-agent: github.com/TaskerJang/doc-graph-agent

profile
ML Engineer 🧠 | AI 모델 개발과 최적화 경험을 기록하며 성장하는 개발자 🚀 The light that burns twice as bright burns half as long ✨

0개의 댓글