Engineering · Retrieval · RAG

FAQ 검색 순위 수리: 전제가 맞지 않던 그래프 융합을 끄고 빠져 있던 BM25F 어휘 점수 되돌리기

고객센터 FAQ 165문항에서 정답 문서가 후보에는 있는데 상위 3개 밖으로 밀리던 원인을 단계별 순위 측정으로 추적했습니다. 노드 단위가 맞지 않던 그래프 융합을 끄고, 어댑터에서 빠져 있던 BM25F 어휘 점수를 라이브러리 설계대로 복원해 정답 문서 1위 비율을 66.1%에서 96.4%로 올렸습니다.

기간
2026.09 – 2026.09
소속
고객사 구축 · 캐피탈
역할
진단·수정·평가

Key Results

66.1% → 96.4%
정답 문서 1위 비율고객센터 FAQ 165문항, 기대답변 3-gram 겹침으로 정답 문서 판정, 청크 800자·겹침 50자 같은 컬렉션
90.9% → 99.4%
정답 문서 3위 안 비율같은 조건, 근거 3개 고정
91.5% → 99.4%
LLM-as-a-judge 정확도claude-sonnet-4-5 정확도 채점, 같은 조건의 경쟁 클라우드 에이전트 96.4%

캐피탈 고객은 고객센터 FAQ 문서로 만든 상담 에이전트를 경쟁 클라우드 서비스에서 운영하고 있었습니다. 사내 규정 비교에 이어 이 고객센터 에이전트도 같은 조건에서 XGEN의 GraphRAG 검색과 비교하기로 했고, 첫 측정은 XGEN 91.5% 대 경쟁 96.4%였습니다.

답변 품질을 손보기 전에 검색 순위부터 쟀습니다. 정답 문서가 후보 몇 위에 있는지 보니 1위 비율이 66.1%였는데, 그래프를 쓰지 않는 순수 벡터 검색은 90.9%였습니다. 그래프를 더한 검색이 벡터 하나보다 나빴던 것입니다. 무언가 순위를 적극적으로 망가뜨리고 있다는 뜻이었습니다.

이 글은 그 원인을 단계별 순위 측정으로 좁혀 간 과정을 다룹니다. 원인은 두 가지였습니다. 노드 단위가 맞지 않는 그래프에서 동작하던 그래프 융합 기능, 그리고 하이브리드 검색에서 빠져 있던 어휘 점수 절반입니다. 같은 날 잡은 측정 장애와 재시도 결함도 함께 정리합니다.

같은 조건의 비교와 검색 경로

경쟁 에이전트는 키워드 검색(simple) 인덱스에서 근거 3개를 가져오고, 청크 800자·겹침 50자, text-embedding-3-small을 썼습니다. 근거 수 3은 나중에 경쟁 에이전트 정의를 직접 조회해 다시 확인했습니다. XGEN 쪽은 같은 FAQ 문서 165건을 같은 청크 설정으로 새 컬렉션에 적재해(185청크) 기본 그래프를 빌드했고, 고객이 만든 워크플로우를 복제해 검색 노드만 온톨로지 검색 노드로 바꿨습니다. 답변 모델과 프롬프트는 고객 것 그대로, 근거 3개, judge는 세 런 모두 claude-sonnet-4-5로 맞췄습니다. 골든셋은 고객이 이전에 수행한 평가 기록에서 그대로 뽑았습니다.

온톨로지 검색 한 번은 다음 경로를 지납니다.

[워크플로우 노드] 질문 → 의미 단위 질의 분할(LLM, 원문 포함 최대 4개)
[문서 서비스]  질의별 병렬
  3 벡터 검색        벡터 DB → 후보 청크(vscore)
  4 그래프 확장      라벨 링킹 → 1홉 → 그 개체의 청크 추가
  5 청크 → 개체      청크당 12개, 허브 개체 제외
    graph_fusion     노드 → 청크 제목 시드, 이웃 0.9배
  6 융합             dense = minmax(vscore + PPR 15%) → 순위
  7 dynamic_cut → 관계 텍스트·클래스 열거 → MMR 근거 선별
[워크플로우 노드] 질의별 병합 → 따옴표 구 우선 → 근거 3개 컷 → 에이전트

순위 계산은 오픈소스 검색 라이브러리 omnifuse가 맡고, 제품의 어댑터가 벡터 DB 후보와 PostgreSQL 그래프를 라이브러리 프로토콜에 연결합니다. 이 경계가 이번 결함의 위치였습니다.

순위를 재기 전에 치운 지연 두 가지

처음에는 측정 자체가 어려웠습니다. 새 800/50 컬렉션에서만 온톨로지 검색이 9분 넘게 첫 이벤트를 내지 못했고, 고객 원본 컬렉션은 0.8초, 같은 컬렉션의 벡터 검색은 2.9초로 정상이었습니다. 짧은 FAQ는 문장마다 상품명, 메뉴, 번호가 개체로 잡혀 청크당 개체가 수십 개였으므로 그래프 확장의 팬아웃을 의심했습니다. 청크당 개체 상한과 그래프 단계 시간 예산을 넣었고, 이어서 노드 테이블 JOIN과 큰 배열 조건을 의심해 조회를 고치고 인덱스를 추가했습니다. 그래도 개체 조회 단계는 54~118초 그대로였습니다.

DB 세션 활동을 샘플링하자 54초 전부가 한 문장의 CTE였습니다. EXPLAIN ANALYZE로 같은 문장을 보니 custom plan은 58,347 ms, generic plan은 60 ms였습니다. 원인은 플래너의 행 추정이었습니다. 이 컬렉션의 행 수를 1로 추정했는데(실제 4,100) 중첩 루프 세미 조인이 CTE를 183만 번 재스캔했습니다. 청크·개체 매핑 표(163만 행)는 전날 이후 자동 통계 갱신이 없었고, 당일 만든 컬렉션들이 최빈값 목록에 없어 "기타 값" 평균으로 1행이 추정된 것입니다. 세 표에 ANALYZE를 실행하자(17초) 같은 검색이 126초에서 1.9초로 돌아왔습니다. 앞서 넣은 상한과 인덱스는 그 자체로 유효했지만 결정타는 아니었습니다. 같은 무렵 컬렉션 청크의 20%를 넘게 등장하는 허브 개체를 이웃 확장에서 빼는 변경도 넣었는데, DB에서 확인하니 이 기준에 걸리는 개체는 고객센터 그래프 2,349개 중 3개('있어요.', '가능한', '확인은')뿐이라 지연과도 품질과도 거의 무관했습니다.

두 번째는 검색이 1~2초가 된 뒤에도 워크플로우 실행이 64초 걸리던 문제입니다. SSE 도착 시각을 보니 실행 시작 전 30초, 종료 후 30초가 비어 있었습니다. 원인은 가드레일 설정이었습니다. 가드 모델 주소가 워크플로우 컨테이너 자신을 가리키고 있어, 부하 시 응답이 30초 타임아웃까지 밀린 뒤 fail-open으로 통과했고, 실행당 두 번(실행 기록 생성, 에이전트 입력 검사) 호출됐습니다. 코드가 아니라 설정 문제라 설정만 바꿨습니다. 이 두 가지를 치우고 나서야 순위를 잴 수 있었습니다.

답변이 아니라 순위를 먼저 잰 이유

첫 비교의 오답을 답변 탓으로 볼 수도 있었지만, 근거가 3개뿐인 구성에서는 정답 문서가 3위 안에 들지 못하면 답변 모델이 할 수 있는 일이 없습니다. 그래서 165문항마다 정답 문서(기대답변과 3-gram 겹침으로 판정)가 후보 몇 위에 있는지를 쟀습니다.

수정 전 1위 66.1%, 3위 안 90.9%, MRR 0.79였고, 같은 후보를 벡터 점수로만 정렬하면 1위 90.9%, 3위 안 98.2%, MRR 0.95였습니다. 정답이 벡터 1위인 150문항 중 최종 1위에 남은 것은 109문항뿐이었습니다. 벡터가 이미 맞힌 순위를 뒤의 단계가 뒤집고 있었습니다.

뒤집는 단계를 찾기 위해 가설을 하나씩 측정으로 지웠습니다. 질의 분할은 측정이 단일 질의 조건이라 무관했습니다. PPR 15% 가중은 실제 점수 정규화상 1.0 대 0.81 같은 차이를 뒤집지 못했고, 그래프 덤프로 PPR을 오프라인 재현해도 순위를 깎지 않았습니다. MMR은 1위를 바꾸지 않았고, 오프라인 재현에서 3위 안 98.2%를 유지했습니다. 그래프 분석 답변 포함 여부는 순위와 무관했습니다. 나중에 남은 실패를 볼 때도 제목 어휘 가산(가중 0.1~0.5), 플랫폼 리랭커(켜도 벡터와 같은 순서), DB 커넥션 풀 타임아웃(로그 0건)을 같은 방식으로 지웠습니다.

그래프 융합: 맞지 않는 전제 위에서 동작하던 기능

남은 후보는 라이브러리의 graph_fusion이었습니다. 이 기능은 "노드가 곧 문단"인 그래프를 전제로 합니다. 질문에 라벨이 글자 그대로 보이는 노드를 찾아 그 노드의 문단을 벡터 최고점 × (1 + 가산)으로 끌어올리고, 이웃 문단에는 0.9배를 줍니다. 조문이 노드인 법령 그래프라면 타당한 동작입니다.

XGEN의 그래프에서 노드는 문단이 아니라 개체였습니다. 어댑터는 개체 노드를 받으면 "그 개체가 언급된 청크 중 점수 최고 또는 정렬상 첫 청크"로 풀어 줬습니다. 여기에 기본 빌드가 뽑은 조각 개체가 겹쳤습니다. '알려주세요'(15청크), '방법에', '대해', '이자를', '확인은'(37청크) 같은 어절이 질문 어절과 글자 그대로 일치해 시드가 됐고, 무관한 청크가 끌어올려졌습니다. 예를 들어 한 문항의 최종 3위는 '방법에'에 매핑된 범칙금 안내 청크였습니다. 그래프 덤프를 CSV로 받아 오프라인에서 이 경로를 확인했습니다.

선택지 장점 단점 판단
라벨이 청크 제목에 든 노드만 청크로 해석 기능을 유지하면서 조각 개체 시드를 제거 짧은 라벨이 여러 제목에 부분 포함돼 잔여 실패 31건 중 30건 1차 적용, 1위 77.0%에서 한계 확인
graph_fusion 끄기 전제가 맞지 않는 기능을 제거, 관련 설정·코드 삭제 문단 노드 그래프에서 얻을 수 있는 효과를 이 경로에서는 포기 채택

1차 수정 뒤 1위는 77.0%, 3위 안은 95.2%였습니다. 잔여 실패 31건(벡터 최고점 청크와 최종 1위가 다른 문항)을 보니 30건이 '대출만기'(8건), '대출이자'(4건) 같은 짧은 라벨이 1위 청크 파일명에 부분 포함돼 제목 검사를 통과한 경우였습니다. 1위 청크의 벡터 점수 비율이 최고점 대비 0.42~0.97이라 PPR 15%로 설명되는 차이도 아니었습니다. 규칙을 더 붙여 이 기능을 살리는 대신, 노드 단위가 문단이 아닌 그래프에서는 기능의 전제 자체가 맞지 않는다고 판단했습니다. graph_fusion을 끄고, 노드를 청크로 해석하는 경로와 관련 환경 변수를 삭제했습니다. 배포 뒤 1위 89.7%, 3위 안 98.2%로, 순수 벡터 기준(90.9%, 98.2%)과 거의 같아졌습니다. 사내 규정 컬렉션에서는 코드에 남아 있던 이전 실측상 이 기능의 기여가 없었으므로 그쪽 영향도 없다고 예측했습니다.

하이브리드의 빠진 절반: BM25F 어휘 점수

그래프의 방해를 걷어내자 벡터 수준까지는 돌아왔지만, 키워드 검색 기반인 경쟁 에이전트가 맞힌 문항이 여전히 남았습니다. 남은 실패는 모두 1·2위 벡터 점수 비가 0.93~0.99인 접전이었습니다(정상 문항 중앙값 0.82). 자료열람청구권, 위법계약해지권, 근저당 해지 같은 문항입니다. 질문에 정확한 용어가 있는데 벡터 유사도는 그 용어를 구별하지 못하는 유형이었습니다.

라이브러리의 설계를 다시 읽으니 omnifuse의 기본 랭킹은 밀집 점수와 어휘 BM25F 점수를 질의별 min-max 정규화 뒤 1.0 : 0.8로 합치는 하이브리드였습니다. 그런데 외부 인덱스 후보를 감싸는 어댑터는 벡터 점수만 넘기고 있었고, 어휘 점수는 처음부터 빠져 있었습니다. 고장 난 것이 아니라 절반이 연결되지 않았던 것입니다. 오프라인 시뮬레이션에서 어휘 BM25를 결합하면 선형 결합이든 RRF든 3위 안이 100%였고, 라이브러리의 인메모리 어휘 인덱스를 기본 가중 그대로 쓰면 1위 97.6%, 3위 안 100%, 질의당 17 ms였습니다.

선택지 장점 단점 판단
제목 어휘 가산 추가 구현이 단순 가중 0.1~0.5 모두 3위 안 98.2%로 효과 없음 기각
플랫폼 리랭커 사용 기존 기능 재사용 켜도 벡터와 같은 순서 기각
라이브러리 어휘 인덱스·정규화·기본 가중 재사용 설계 의도대로, 새 상수 없음, 질의당 17 ms 후보 위에서만 계산(풀 밖 회수는 아님) 채택

구현은 후보 청크 위에 라이브러리의 인메모리 어휘 인덱스(BM25F)를 세우고, 라이브러리의 정규화 함수와 기본 가중을 인스턴스에서 읽어 합산하는 것입니다.

dense   = minmax(vscore + PPR 15%)
lexical = minmax(BM25F over 후보, 일치 없는 후보는 0점으로 채운 뒤 정규화)
fused   = 1.0 × dense + 0.8 × lexical

일치 없는 후보를 0점으로 채우는 데는 이유가 있습니다. 어휘가 일치한 후보만으로 정규화하면 일치가 하나뿐일 때 최고점과 최저점이 같아져 그 후보가 0으로 눌립니다. 전체 후보를 0으로 채운 뒤 정규화해야 유일하게 일치한 정답이 제 점수를 받습니다. 배포 뒤 1위 96.4%, 3위 안 99.4%가 됐습니다. 세 결함 수정(제목 해석, 융합 끄기, 어휘 합산)을 합친 문서 서비스 변경은 31줄 추가, 205줄 삭제였고, 추가된 숫자 리터럴은 초기값 0.0뿐이며 문항, 파일명, 컬렉션명 같은 도메인 문자열은 없습니다.

답변 경로의 재시도 결함

순위와 별개로 사전 검색 모드의 문항마다 답변 LLM의 첫 호출이 검색 직후 즉시 실패하고 SDK가 0.5초 뒤 재시도하는 것을 로그에서 발견했습니다. 도구 모드에는 없었습니다. 더미 OpenAI 호환 서버로 재현하니 원인이 드러났습니다. 질의 분할과 폴더 선택 LLM을 별도 스레드의 새 이벤트 루프에서 비동기로 호출하는데, LangChain OpenAI 클라이언트는 비동기 HTTP 클라이언트를 lru_cache로 모든 인스턴스가 공유합니다. 임시 루프에서 만든 연결이 풀에 남고, 본 루프의 다음 답변 호출이 그 연결을 닫으려다 'Event loop is closed'로 실패한 뒤 재시도했습니다. 재현 기준으로 이 경로는 645 ms, 동기 호출 뒤에는 6 ms였습니다.

두 호출부를 동기 클라이언트 호출로 바꿨습니다. 노드 실행은 이벤트 루프가 아니라 실행기의 워커 스레드에서 돌기 때문에 동기 호출이 루프를 막지 않습니다. 이 중 폴더 선택 호출부는 2주 전 사전 검색 재설계 때 스트리밍 버퍼링을 고치며 비동기 경로로 바꿨던 곳이었습니다. 같은 호출부의 동기·비동기 선택이 서로 다른 결함으로 이어진 셈이라, 이번 변경 뒤 스트리밍 동작을 따로 다시 확인한 기록은 없다는 점을 함께 남겨 둡니다.

결과와 회고

상태 정답 문서 1위 3위 안 5위 안 judge 정확도(사전 검색 · 도구 모드)
수정 전 66.1% 90.9% 96.4% 91.5% · 91.5%
제목 일치 노드만 해석 77.0% 95.2% 98.2% 런 중단(judge 네트워크 오류)
graph_fusion 끄기 89.7% 98.2% 98.8% 96.4% · 98.2%
BM25F 어휘 합산 96.4% 99.4% 99.4% 99.4%(164/165) · 98.2%(162/165)
순수 벡터(비교) 90.9% 98.2% 100% 측정 안 함
경쟁 에이전트 측정 안 함 측정 안 함 측정 안 함 96.4%(159/165)

측정 조건은 고객센터 FAQ 165문항, 청크 800자·겹침 50자, 근거 3개, judge claude-sonnet-4-5 정확도 채점입니다. 응답 시간 중앙값은 경쟁 6.5초, 사전 검색 6.0초, 도구 모드 4.7초였습니다. 남은 오답 1건은 파일 제목은 새출발기금인데 본문과 기대답변은 오토리스 중도해지인 데이터 결함이라 세 엔진 모두 0점이었고, 검색으로 닿을 수 있는 상한은 164/165입니다.

돌아보면 이번 수리의 핵심은 새 기능을 만든 것이 아니라 라이브러리와 제품 사이의 전제를 맞춘 것이었습니다. 그래프 융합은 노드가 문단이라는 전제를, 하이브리드 랭킹은 어휘 점수가 함께 들어온다는 전제를 갖고 있었는데, 어댑터가 두 전제를 모두 어기고 있었습니다. 단계별 순위 측정이 없었다면 "그래프 검색이 원래 FAQ에 약하다"는 결론으로 끝났을 것입니다. 측정 장애에서도 같은 교훈이 있었습니다. 팬아웃, JOIN, 배열 조건을 차례로 고쳤지만 결정타는 플래너 통계였고, 실행 계획을 직접 보기 전까지는 그 사실을 알 수 없었습니다.

남은 과제도 있습니다. 짧은 FAQ에서 확인한 결과이며, 조문 참조 사슬이 중요한 규정 문서는 문단 단위 노드를 빌드할 때 graph_fusion을 다시 검토할 여지가 있습니다. 165문항 중 153문항에서 '대출', '확인', '변경' 같은 일반어 클래스 목록이 프롬프트에 실리고 있어, 열거나 개수를 묻는 질문일 때만 싣도록 줄여야 합니다. 기본 빌드가 조사가 붙은 어절을 개체로 뽑는 문제는 빌드 품질 과제로 남겼습니다. 허브 개체 제외와 함께 넣은 노드당 이웃 30개 상한은 엣지 가중치가 전부 1이라 "가중치 순"이 사실상 임의 순서이고, 상한이 노드별이 아니라 배치 전체에 걸려 있어 허브가 섞이면 다른 노드의 이웃을 굶길 수 있다는 잠재 결함도 확인했습니다. 현재 데이터에서는 발동하지 않았습니다.