Engineering · Knowledge Graph

GraphRAG 검색 재설계: 순차 ReAct를 병렬 인덱스 시드와 단일 합성으로 바꾼 근거

단일 적응형 ReAct 검색이 기존 1턴 검색보다 느리고 부정확하다는 A/B 결과를 다시 읽어, 손실된 근거와 빈 그래프를 잰 하니스라는 두 원인을 찾았습니다. 탐색과 답변 합성을 분리한 병렬 인덱스 시드 구조로 바꿔 고정 100문항 정답성을 0.230에서 0.465로 올렸습니다.

기간
2026.06 – 2026.06
소속
플래티어 · XGEN
역할
설계·구현·A/B 평가

Key Results

0.230 → 0.465
정답성(고정 100문항)같은 로컬 스택·골든셋, gpt-4o LLM-as-a-judge 0/0.5/1 채점, 그래프 활성 상태에서 기존 검색 대비
34 vs 13
완전 정답 문항 수위와 같은 100문항 조건
1,700~4,500 → 40~200
질의당 근거 후보 청크무순위 scroll 5종을 키워드 필터+벡터 랭킹 조회 1회로 교체, 24문항 비교에서 정답성 0.562 동일

2026년 6월, XGEN 온톨로지 검색을 새 구조로 병합할지 정하는 A/B 평가를 했습니다. 새 검색은 질문 난이도를 입구에서 나누던 분류기를 걷어내고 단일 적응형 ReAct 루프로 통합한 상태였고, 병합 조건은 "기존 검색보다 확실히 정확하고 빠를 때만"이었습니다. 첫 결과는 반대였습니다. 새 검색은 지연 중앙값 20.4초에 정답성 0.25, 기존 1턴 검색은 4.5초에 0.417이었습니다.

숫자만 보면 되돌리면 되는 상황이었고, 실제로 첫 권고도 기존 빠른 경로를 복원하는 쪽이었습니다. 그런데 원인을 다시 파 보니 문제는 ReAct 자체가 아니라 답변이 잘린 근거 위에서 만들어지는 구조에 있었고, 더 나아가 평가 하네스가 비어 있는 그래프를 재고 있었습니다. 그래프를 제대로 연결해 다시 재자 이번에는 기존 검색이 무너졌습니다.

이 글은 그 일주일 동안의 판단을 순서대로 적습니다. 회귀의 원인을 어떻게 좁혔는지, 왜 라우터나 ReAct 보수 대신 탐색과 합성을 분리한 원샷 구조를 골랐는지, 관계 질의 하나를 0.3초짜리 SPARQL로 만들기까지 어떤 함정을 지났는지, 최종적으로 무엇을 측정했는지입니다.

병합 조건과 A/B 하네스

당시 검색은 세 저장소를 씁니다. 청크 벡터는 Qdrant, 지식그래프 트리플은 Apache Jena Fuseki, 스키마 정보는 PostgreSQL에 있습니다. 6월 초에 입구의 빠름/느림 분류기를 걷어낸 이유는 분류기가 틀렸을 때의 비용이었습니다. 쉬운 질문을 깊은 탐색으로 보내면 시간이 낭비되고, 어려운 질문을 빠른 경로로 보내면 얕은 답이 나왔습니다. 분류에 쓰던 키워드 목록도 하드코딩이었습니다. 그래서 하나의 ReAct 루프가 쉬우면 1턴, 어려우면 여러 턴을 쓰도록 바꿨고, 그래프에 실제로 있는 술어 라벨을 동적으로 뽑아 프롬프트에 넣었습니다.

그 구조를 병합하기 전에 공정한 비교부터 만들었습니다. 같은 로컬 스택(Qdrant, Fuseki, PostgreSQL)과 같은 공공기관 문서 컬렉션을 쓰고, 정답과 정답 청크가 달린 골든셋 100문항에서 복잡도로 층화한 24문항(단일 청크 8, 다중 청크 7, 다중 문서 6, 답 없음 3)을 뽑았습니다. 판정은 gpt-4o(temperature 0)가 정답 대비 0, 0.5, 1로 매기고, 판정이 갈린 5건은 직접 읽어 점수가 타당한지 확인했습니다.

기존 검색은 별도 환경이 아니라 같은 스택에 임시로 교체해 넣었습니다. 기존 모듈의 함수 시그니처, import, 외부 호출이 새 스택과 같은지 확인한 뒤 바꿔 끼우고, 측정이 끝나면 되돌렸습니다. 컨테이너 안에 표식을 두어 지금 어느 쪽이 실행 중인지도 검증했습니다. 저장소와 모델 조건을 완전히 같게 두고 검색 모듈만 바꾸는 것이 목표였습니다.

첫 결과는 회귀였습니다

지표 새 검색(단일 ReAct) 기존 검색
지연 중앙값 20.4 s 4.5 s
평균 턴 수 6.62 (2~8) 1.0
정답성 평균 0.25 0.417
정답 / 부분 / 오답 2 / 8 / 14 4 / 12 / 8

기존 검색은 24문항 전부를 1턴으로 처리했습니다. 벡터로 찾은 청크를 바로 읽고 답하니 사실형과 표형 질문에 깨끗했습니다. 새 검색은 대부분의 문항에서 2~8턴을 돌았고, 누적된 소스가 최대 4,341개까지 늘었습니다. 답변을 읽어 보니 숫자 환각이 눈에 띄었습니다. 정답이 50.6%인 응답률을 35.1%로, 1.6점인 평가 점수를 1.8점으로 답했습니다. 새 검색이 이긴 문항은 메뉴와 팝업을 함께 물은 관계형 질문 같은 소수였습니다.

이 시점의 판단은 "기존 빠른 경로를 기본값으로 복원하고, 답이 부족할 때만 여러 턴으로 올려 보내는 라우터를 두자"였습니다. 입구 분류를 없앨 것이 아니라 더 똑똑하게 만들자는 생각이었습니다. 다만 숫자 환각이라는 증상이 탐색 구조보다는 답변 입력 쪽 문제처럼 보여, 결론을 내리기 전에 원인을 한 번 더 보기로 했습니다.

회귀의 원인을 다시 좁힌 과정

첫 번째 원인은 답변이 손실된 근거 위에서 만들어지는 구조였습니다. 대화 길이를 관리하는 슬라이딩 윈도우가 도구 결과를 500자, 8줄로 잘라 요약하고 있었고, 강제 답변 경로도 청크를 300자로 잘랐습니다. 표가 중간에서 끊기면 LLM은 빈 셀을 그럴듯한 숫자로 채웁니다. 응답률 35.1%가 그렇게 나온 값이었습니다.

두 번째 원인은 랭킹 신호의 유실이었습니다. 턴마다 누적된 소스를 정렬하는 코드가 벡터 유사도를 버리고 키워드 적중 수만 쓰고 있었습니다. 의미는 맞지만 질문 단어가 적게 들어간 정답 청크가 뒤로 밀렸습니다.

두 가지를 고쳤습니다. 질문 관련 상위 K개 청크를 자르지 않은 전문으로 모은 근거 블록을 매 턴 대화 앞에 넣고(v3b), 그 순위를 벡터 유사도 0.7과 형태소 키워드 적중 0.3의 하이브리드로 매겼습니다(v3c). 24문항 정답성은 0.396, 0.521로 올라 기존 검색의 0.417을 넘었고, 지연 중앙값도 9.4초로 줄었습니다.

그런데 이 승리를 정리하다가 더 큰 문제를 찾았습니다. 하니스가 그래프를 지정할 때 컬렉션의 표시 이름을 넘기고 있었고, 그래프 식별자가 빈 그래프를 가리켰습니다. 실제 그래프는 고유 접미사가 포함된 다른 식별자에 17만여 트리플로 있었습니다. 제품 UI는 올바른 식별자를 넘기고 있어서 측정만 그래프를 타지 못한 것입니다. 지금까지의 모든 수치는 양쪽 다 벡터 전용으로 잰 값이었습니다.

그래프를 살리기 전에 그래프 검색 자체도 점검했습니다. Fuseki는 텍스트 인덱스 없이 운영되고 있어 라벨 검색이 CONTAINS 필터로 전체를 훑고 있었습니다. Lucene 기반 jena-text 인덱스(CJK 바이그램 분석기)를 설정하고 기존 트리플을 한 번 색인했습니다. 같은 주에 확인한 것으로, 다섯 패턴 조인짜리 클래스 관계 질의가 180초 넘게 걸리던 원인은 TDB2의 통계 파일(stats.opt) 부재였고, 통계를 만든 뒤 콜드 5.98초가 됐습니다.

그래프를 연결해 같은 24문항을 다시 재자 그림이 뒤집혔습니다.

그래프 활성 정답성 정답 / 부분 / 오답 지연 중앙값
새 검색(v3c) 0.500 9 / 6 / 9 12.4 s
기존 검색 0.229 2 / 7 / 15 6.1 s

기존 검색이 0.417에서 0.229로 무너졌습니다. 1턴 경로가 그래프 트리플을 단일 답변 컨텍스트에 함께 넣자, 트리플이 정답 청크를 밀어냈습니다. 정확한 제목을 답하던 문항이 오답이 되고, 맞히던 문항이 "찾을 수 없음"으로 바뀌었습니다. 기존 검색의 빠름은 그래프가 없는 조건에서의 착시였던 셈입니다. 처음 권고였던 "기존 빠른 경로 복원"도 함께 폐기했습니다.

인덱스가 순위를 매기게 했습니다

그래프가 살아난 뒤의 개선은 대부분 "후보를 많이 가져와 파이썬에서 다시 정렬하던 곳을 인덱스 조회로 바꾸는 일"이었습니다.

라벨 시드를 CONTAINS 스캔에서 jena-text 조회로 바꾸고, 벡터 시드와 그래프 라벨 시드를 asyncio.gather로 동시에 실행했습니다(v3d). 정답성은 0.521로 같고 지연이 7.6초로 38% 줄었습니다. 이어 엔티티 링킹에 연결성 가지치기(SG-KBQA 방식)를 넣었습니다. 인덱스로 찾은 후보 중 주어와 목적어가 둘 다 질문에 매칭되는 관계만 남기고, 비면 한쪽 끝만 맞는 넓은 검색으로 물러납니다. 기관명의 일부 글자에 부분 일치해 엉뚱한 노드가 끌려오던 노이즈가 그래프 연결로 걸러졌고, 정답성 0.583, 완전 오답 8개에서 5개가 됐습니다(v3e).

다음 문제는 과다 회수였습니다. 근거 블록을 만들려고 검색마다 청크 500개를 가져와, 누적 소스 1,700~4,500개를 매 턴 파이썬으로 재정렬하고 있었습니다. 먼저 조회 상한을 100으로 줄이고 키워드 scroll도 12개로 묶었더니(v3f) 소스는 90개 안팎이 됐지만 정답성이 0.562에서 0.479로 떨어졌습니다. 순서 없이 가져오는 scroll이 정답 청크를 잘라 먹은 것입니다. 과다 회수가 사실은 랭킹의 약점을 가리고 있었다는 뜻이었습니다.

그래서 상한을 조이는 대신 순위를 인덱스에 맡겼습니다. 구문, AND, OR, 파일명 AND·OR로 나뉘어 있던 무순위 scroll 5종을 키워드 필터와 벡터 랭킹을 함께 거는 query_points 조회 하나로 바꿨습니다(v3g). 정확한 용어를 담은 청크를 벡터 유사도 순으로 상위 K개만 가져오니, 정답성 0.562를 유지하면서 후보 청크가 40~200개로 20~40배 줄고 지연은 7.1초가 됐습니다.

24문항은 회차 변동이 0.02 안팎(같은 코드로 0.562~0.583)이라, 이 판을 고정 100문항으로 다시 쟀습니다. 새 검색 0.465, 기존 검색 0.230으로 정확히 두 배였고 완전 정답은 34 대 13이었습니다. 지연 중앙값은 6.7초와 5.0초로 새 검색이 조금 느렸습니다.

라우터, ReAct 보수, 원샷 중 원샷을 고른 이유

남은 질문은 구조였습니다. 같은 실험 기간에 여러 턴을 실행하는 판과 한 번에 끝내는 판을 모두 만들어 봤기 때문에, 세 선택지를 측정값으로 비교할 수 있었습니다.

선택지 장점 단점 판단
기존 빠른 경로 + 부족할 때만 에스컬레이션하는 라우터 쉬운 질문이 빠름 입구 분류의 오판을 되살림, 빠른 경로는 그래프 활성 시 0.229 기각
ReAct 유지, 근거·랭킹 결함만 수정 집계 질문에서 루프가 +0.1(30문항 0.783) 순차 턴만큼 지연이 쌓임(6.8 s) 기각
병렬 인덱스 시드 + 단일 합성(원샷) 전부 1턴, p95 4.1 s, 30 s 초과 0건 순차 추론이 없어 검색 경로를 따로 보여 줘야 함 채택

ReAct가 집계 질문에서 앞선 0.1은 절반가량이 정답 표기가 모호한 문항(청크의 숫자와 그래프 집계 중 무엇이 정답인지)에서 나왔고, 나머지는 합성 단계에서 무엇을 먼저 보여 주느냐의 문제로 봤습니다. 분기를 만들 만한 능력 차이는 아니라고 판단했습니다.

원샷을 택한 근거는 두 가지였습니다. 첫째, 각 시드는 데이터가 없으면 스스로 빈손으로 빠집니다. 질문 용어가 실제 클래스로 해소되지 않으면 클래스 시드가 비고, 술어 라벨과 맞지 않으면 관계 시드가 비어 있습니다. 키워드 규칙이나 분류기 없이 그래프에 실제로 있는 클래스와 술어가 어떤 시드를 쓸지 결정합니다.

둘째, 집계, 멤버십, 관계 같은 구조 질의는 임베딩 top-k로 원리적으로 풀 수 없습니다. 임베딩의 연산은 코사인 유사도뿐이고, top-k는 유사도 순 k개일 뿐 어떤 클래스의 구성원 전부가 아닙니다. 같은 클래스의 구성원이라도 임베딩 공간에서는 멀 수 있습니다. 집계에는 완전 집합이 필요하고, 멤버십은 이산 술어이며, 관계는 두 개체를 잇는 구조라 점 사이 거리로는 따라갈 수 없습니다. 그래서 그래프 질의를 벡터 검색과 나란한 별도 경로로 둬야 했습니다.

같은 판단을 측정으로도 확인했습니다. 원샷 판에서 그래프 근거만 끄고 켜 비교하자, 집계·열거 30문항은 벡터 전용 0.033 대 그래프 0.683이었고 단일 사실 100문항은 0.46 대 0.45로 차이가 없었습니다. 다만 관계형 24문항도 0.292 대 0.312로 차이가 없었습니다. 관계 질문에 넓은 연결성 시드만으로는 부족하다는 뜻이었고, 다음 작업이 여기서 정해졌습니다.

관계 시드의 SPARQL 한 문장을 찾기까지

관계 시드의 목표는 "질문의 엔티티와 관계어로 목표 트리플을 정조준하는 것"이었습니다(KBQA 방식). 정확도와 속도를 함께 맞추기까지 다섯 번을 고쳤습니다.

판 관계형 정답성 단일 사실 지연 무엇을 바꿨고 무엇이 문제였나
v4 0.31 2.4 s 넓은 연결성 시드만, 관계형이 벡터와 비슷
v5 0.48 5 s 단일 질의(text:query + CONTAINS), 정확하지만 매 질의 2.5 s가 병렬 시드 전체를 지배
v6 0.44 5 s 넓은 시드를 빼도 그대로, 범인은 관계 질의
v7 미측정 5 s 부분 문자열 게이트, 술어 2,899개라 단일 사실 질문의 87%에서 오발동
v8·v9 0.31 2.5 s 주어와 술어를 각각 VALUES로 묶은 2단계, 빠르지만 정확도 추락
v10 0.52 2.5 s text:query가 주어를 먼저 바인딩하는 단일 질의

v5가 느린 이유를 쪼개 보니 text:query 단독은 124 ms로 빨랐습니다. 느린 쪽은 술어 변수를 먼저 찾는 패턴이었습니다. 술어를 VALUES로 나열하거나 술어 라벨에 FILTER를 걸면 술어 인덱스 전수 스캔이 일어나 2~4초짜리 나쁜 계획이 나왔습니다. v8·v9는 이를 피하려 엔티티 URI를 직접 질의에 직렬화했는데, 라벨에 괄호와 공백이 섞인 엔티티에서 IRI 직렬화가 실패해 질의 전체가 실패했습니다. 속도가 돌아온 대신 관계형이 0.31로 떨어진 이유였습니다.

피한 계획 1  술어 변수를 먼저 탐색(VALUES ?p, 술어 라벨 FILTER)   → 술어 인덱스 전수 스캔, 2~4 s
피한 계획 2  엔티티 URI를 질의에 직렬화(VALUES ?s)                → 괄호·공백 라벨에서 IRI 깨짐
채택        text:query(질문 용어 15개)가 ?s를 먼저 바인딩
            + 매칭된 술어 라벨만 FILTER IN                          → 약 0.3 s, URI 직렬화 없음

v10에서 관계형 정답성은 0.521로 벡터 전용 0.292보다 78% 높았고, 단일 사실 0.47, 집계 0.65를 유지했습니다. 지연 중앙값은 단일 사실 2.56초, 관계형 2.29초, 집계 2.14초였고 30초는 물론 15초를 넘은 질의도 없었습니다. 정방향과 역방향 관계를 함께 조회하는 양방향 확장은 로컬 판에서 이 위에 얹었습니다.

최종 원샷의 흐름은 다음과 같습니다.

질문 → 병렬 시드 4종 (asyncio.gather, 벽시계 = 가장 느린 하나)
        ① 벡터 검색      키워드 필터 + 벡터 랭킹 조회, 점수 분포로 동적 top-k
        ② 그래프 라벨    jena-text 엔티티 링킹 + 연결성 가지치기
        ③ 클래스 전수    질문이 클래스로 해소되면 인스턴스 목록과 개수
        ④ 양방향 관계    주어 선바인딩 단일 SPARQL, 술어가 맞을 때만
     → 근거 블록(청크 전문, 벡터 0.7 + 키워드 0.3) + 관계 블록 + 클래스 목록
     → 합성 1회

합성을 별도 단계로 둔 데도 이유가 있었습니다. 루프 안에서 답을 쓰게 하면 "자세한 내용은 원문을 참고하라"는 식으로 답을 떠넘기는 경우가 있었습니다. 처음에는 합성 프롬프트에 그런 문구를 금지어로 나열했는데, 문구 목록은 하드코딩이라 판단해 걷어냈습니다. 대신 전체 근거 위에서 한 번만 합성하게 하자 떠넘김이 구조적으로 사라졌습니다. 이때 합성 입력이 근거와 관계만 보고 대화에 있던 클래스 목록을 놓쳐 집계 점수가 0으로 떨어진 일이 있어, 클래스 목록을 합성 입력에 명시적으로 포함했습니다.

결과가 0건인 SPARQL에는 안전망을 하나 뒀습니다. 질의가 참조한 술어와 클래스가 스키마에 실제로 있는지 대조해, 없는 항목이면 모델이 지어낸 술어로 보고 실제 술어 목록과 함께 다시 시도하게 합니다. "데이터가 없다"와 "질의가 틀렸다"를 구분하기 위해서입니다. 원샷 전환으로 순차 턴이 사라지면서 비게 된 프론트엔드의 "검색 경로" 패널은, 각 시드가 무엇을 근거로 발동했고 무엇을 찾았는지를 백엔드에서 서술해 채웠습니다.

결과와 남은 과제

지표 이전 이후 측정 조건
정답성(고정 100문항) 0.230 0.465 그래프 활성, 같은 스택·골든셋, gpt-4o 0/0.5/1 판정, 기존 검색 대 v3g
완전 정답 수 13 34 위와 같은 100문항
질의당 근거 후보 청크 1,700~4,500 40~200 24문항, 무순위 scroll 대 query_points 1회, 정답성 0.562 동일
관계형 정답성 0.292 0.521 관계형 24문항, 벡터 전용 대 v10
지연 중앙값 20.4 s 2.1~2.6 s 이전은 첫 단일 ReAct 24문항, 이후는 v10 원샷 유형별 세트, 30 s 초과 0건

판정 모델에 대한 의심도 따로 확인했습니다. 마지막 통제 비교(로컬 최종판 코드에서 그래프 근거만 끄고 켬)에서 관대한 판정 모델(Qwen 27B 계열)과 엄격한 판정 모델(gpt-4o)로 각각 채점했고, 단일 사실 100문항은 그래프 0.53~0.54 대 벡터 전용 0.51~0.53으로 동률, 집계·전수 30문항은 0.73~0.77 대 0.00~0.12, 관계형 24문항은 0.604 대 0.229~0.312로 두 모델의 순위가 같았습니다. 채점기 편향으로 설명할 수 있는 차이는 아니었습니다.

가장 크게 배운 것은 결과가 한쪽으로 깔끔하게 나올수록 계측을 먼저 의심해야 한다는 점이었습니다. 첫 회귀도, 그것을 뒤집은 v3c의 승리도 같은 빈 그래프 위의 숫자였습니다. 또 하나는 상한을 조이는 방식의 최적화가 랭킹 약점을 드러낸다는 점입니다. v3f의 회귀가 없었다면 과다 회수가 가리던 무순위 scroll을 찾지 못했을 것입니다.

남은 것도 분명합니다. 골든셋이 사실과 표 위주라 그래프의 가치는 일부만 측정됩니다. 집계·관계 문항은 별도 세트로 보완했지만 문항 수가 30개, 24개로 작습니다. jena-text 인덱스와 TDB2 통계는 인프라 설정이라 코드 배포만으로는 배포 환경에 따라가지 않고, 통계는 대규모 재빌드 뒤 다시 만들어야 합니다. 원샷은 순차 추론이 없으므로 "A를 보고 B를 고르는" 다단계 추론이 필요한 질문은 ReAct 모드를 토글로 보존해 두었습니다. 목록형 답변의 완전성 보강 같은 후속 실험은 로컬에서만 검증했고 정식 반영 전입니다.