현장에서 다시 잰 것들 · 5편

같은 질문에 근거가 매번 달랐던 이유는 정렬 없는 LIMIT이었습니다

같은 50문항을 두 번 검색하면 근거가 43문항에서만 같았습니다. 그래프 조회의 정렬 없는 LIMIT을 고치면서, 순위 알고리즘은 라이브러리가 맡고 저장소 조회는 제품 어댑터가 맡도록 경계를 다시 그은 과정입니다.

현장에서 다시 잰 것들 시리즈 · 5/6
전체 목차 펼치기
  1. 01규칙을 고친 뒤에도 남은 판정 흔들림은 공유 추론 서버에서 왔습니다
  2. 02그래프 서버가 없는 곳에서 빌드는 트리플 0개로 완료되고 있었습니다
  3. 0398 대 86으로 진 평가는 같은 조건의 비교가 아니었습니다
  4. 04에이전트마다 빌드하면 같은 문서를 에이전트 수만큼 다시 읽어야 했습니다
  5. 05같은 질문에 근거가 매번 달랐던 이유는 정렬 없는 LIMIT이었습니다
  6. 06업로드마다 LLM을 부르는 빌드는 자동으로 켜 둘 수 없었습니다

같은 50문항을 같은 환경에서 두 번 검색했더니 근거 목록이 43문항에서만 같았습니다. 벡터 점수는 똑같았고, 흔들림은 그래프 저장소 조회 12곳에 걸린 정렬 없는 LIMIT에서 나왔습니다. 이 문제를 고치면서 순위 로직은 라이브러리(xgen-omnifuse)에, 저장소 조회는 제품 어댑터에 둔 이유를 정리합니다.


같은 질문을 두 번 넣으면 근거가 달랐습니다

저희 GraphRAG 검색은 벡터 검색으로 원문 후보를 모으고, 지식그래프에서 질문과 이어진 개체와 이웃을 따라가 후보를 보탠 뒤, 점수를 합쳐 답변 모델에 넘길 근거를 고릅니다. 그래프는 PostgreSQL 테이블에 저장돼 있습니다.

캐피탈 사내 규정 문서로 만든 컬렉션에서 50문항을 두 번 검색해 근거를 비교했습니다. LLM은 거치지 않고 검색 결과만 봤습니다. 근거가 완전히 같은 문항은 43개였습니다.

비슷한 흔들림을 예전에는 임베딩 호출과 근사 최근접 탐색의 미세한 변동으로 보고 넘긴 적이 있습니다. 이번에는 같은 청크의 벡터 점수를 두 회차에서 비교했고, 완전히 같았습니다. 벡터 쪽이 아니라면 남는 것은 그래프 조회였습니다.

순서를 정하지 않은 LIMIT은 매번 다른 행을 고를 수 있습니다

그래프 저장소 어댑터를 하나씩 보니 이웃 조회, 라벨 정확·부분 일치, 속성값, 클래스 인스턴스, 이웃 선읽기, 개체가 언급된 청크, PPR 간선까지 12곳이 ORDER BY 없이 LIMIT만 걸고 있었습니다.

PostgreSQL은 ORDER BY가 없으면 행 순서를 보장하지 않습니다. 순서만 바뀌면 괜찮지만 LIMIT이 붙으면 어떤 행이 잘려 나가는지가 바뀝니다. 상한에 걸리는 질문에서는 이웃 집합이 달라지고, 그 차이가 후보와 근거까지 내려갑니다.

정답률로는 이 문제가 잘 보이지 않았습니다. 정답률에는 답변 모델과 채점 모델의 회차 변동이 함께 들어 있어, 근거 몇 개가 바뀌는 차이는 그 안에 묻힙니다. 검색만 두 번 실행해 근거를 직접 비교하고 나서야 드러났습니다.

정렬 키를 고르는 데도 기준이 필요했습니다

먼저 떠오르는 수정은 id나 uri 같은 유니크 키로 정렬하는 것입니다. 그렇게 하자 실행 계획이 인덱스 순서로 스캔하며 조건을 필터링하는 방식으로 바뀌어 느려졌습니다.

일반화 계획을 강제한 EXPLAIN 기준
간선 조회        86 ms → 700 ms
라벨 정확 일치   100 ms → 526 ms
검색 1회         2.5 s → 5.9 s

그래서 조회마다 정렬 위치를 따로 정했습니다. 인덱스가 없는 라벨 컬럼으로 정렬하거나, 조회 결과 별칭 위에서 정렬하거나, 간선처럼 다 읽는 경우는 파이썬에서 정렬했습니다. 늘어난 정렬 비용은 벡터, 그래프 후보, PPR 입력을 동시에 읽고 요청 안에서 같은 조회를 한 번만 하도록 묶어 만회했습니다.

정렬 키가 유일하지 않으면 같은 값끼리는 여전히 순서가 임의라는 점도 리뷰에서 다시 짚었습니다. 개수와 라벨로만 정렬하던 클래스 목록에 uri를 덧붙였습니다.

같은 50문항 수정 전 정렬 + 동시 읽기
두 번 검색해 근거가 같은 문항 43/50 50/50
검색 시간 중앙값 2.50 s 2.50 s
검색 시간 p90 8.6 s 5.4 s

PPR은 질의마다 간선 테이블 전체를 읽고 있었습니다

PPR(Personalized PageRank)은 질문에서 찾은 시드 개체에서 출발해 그래프를 걸으며 점수를 퍼뜨리는 방법입니다. 기존 구현은 질의마다 간선 테이블을 통째로 읽고 정해진 횟수만큼 반복했습니다. 처음에는 읽는 간선 수에 상한을 두었다가 임시방편이라고 판단해 걷어냈습니다. 상한은 큰 그래프에서 PPR이 보는 범위를 자를 뿐, 전부 읽는 구조는 그대로였습니다.

그래서 시드 주변만 읽는 로컬 PPR로 바꿨습니다. 남은 점수 질량이 허용 오차보다 큰 노드의 이웃만 읽고, 처리 순서를 정렬해 결과가 결정적이게 했습니다. PPR이 걷는 간선이 34,293개인 같은 컬렉션에서 질의당 시간이 208 ms에서 1 ms로 줄었고, 읽은 행은 중앙값 0, 최대 2,334였습니다.

첫 구현은 걸을 간선이 없는 시드에 질량을 남겨 두어, 기존 PPR과 상위 10개 후보가 49문항 중 3개에서만 일치했습니다. 시드 의미를 기존과 맞추자 41/49가 됐습니다. 남은 차이는 반복 횟수가 고정된 기존 계산이 덜 수렴해 생긴 근소한 차이로 봤습니다.

순위는 라이브러리가, 저장소 조회는 어댑터가 맡습니다

두 수정을 하면서 고칠 자리가 분명해졌습니다. 정렬 없는 LIMIT은 PostgreSQL 테이블 구조에 묶인 문제라 제품 어댑터에서 고칩니다. PPR은 저장소와 무관한 순위 알고리즘이라 라이브러리에서 고치고, 제품은 버전을 올려 받습니다.

이렇게 나눌 수 있었던 것은 그 전에 경계를 다시 그어 두었기 때문입니다. 원래 제품 어댑터는 라이브러리 클래스를 상속해 비공개 메서드를 오버라이드하고, 그래프 확장과 PPR을 자체 사본으로 들고 있었습니다. 이 구조에서는 라이브러리를 고쳐도 제품에 닿지 않고, 같은 알고리즘이 두 곳에서 따로 바뀝니다.

반대로 SQL까지 라이브러리에 넣으면 라이브러리가 제품의 테이블 스키마를 알아야 하고, 은행 관리자 콘솔의 그래프처럼 다른 저장소에는 붙이기 어려워집니다. 기준은 두 가지였습니다. 라이브러리 버전만 올려도 제품에 기능이 따라와야 하고, 저장소는 골라 끼울 수 있어야 합니다.

그래서 라이브러리에 공개 접점을 추가했습니다. 프롬프트 빌더를 주입받고, LLM 호출 직전에 멈춰 선별된 근거와 프롬프트를 반환하는 모드를 두고, 그래프 후보와 PPR 함수를 공개했습니다. 기존 호출이 그대로 동작하도록 전부 추가로만 넣었습니다. 어댑터에는 SQL 매핑, 청크 정리, 한글 프롬프트, 설정값만 남았습니다.

옮기기 전후가 같은지는 가짜 저장소로 확인했습니다. 같은 메서드 계약을 따르는 가짜 그래프·벡터 저장소와 해시 기반 임베딩으로 합성 코퍼스를 입력으로 실행해 근거, 프롬프트, 후보 목록을 비교했고 60/60이 같았습니다. 처음에는 60개가 전부 다르게 나왔는데, 비교 도구가 집합을 순회해 실행마다 순서가 바뀐 제 버그였습니다. 재현성을 측정하는 도구부터 결정적이어야 했습니다.

경계가 깨지는 경로도 있었습니다

라이브러리의 한 버전이 검색 중에 새 메서드를 호출하기 시작했는데, 제품 어댑터에는 그 메서드가 없었습니다. 그래프 검색 경로가 통째로 실패하고 벡터 검색으로 조용히 폴백됐습니다. 이후 새 프로토콜 메서드는 선택 사항으로 두고 없으면 기존 방식으로 대체하게 했고, 제품은 라이브러리 버전을 정확히 고정합니다. 로컬 PPR이 쓰는 배치 이웃 조회도 없으면 단건 이웃 조회로 대체합니다.

확인한 범위와 남은 것

수치는 한 고객 컬렉션의 50문항을 로컬 스택에서 측정한 것입니다. 같은 조건의 답변 정확도 비교에서 잃은 문항은 없었고, 수정본은 두 회차 모두 같은 수치를 냈습니다. 다만 차이가 회차 변동 안이라 정확도가 올랐다고 말할 근거는 아닙니다.

검색 코드 밖의 흔들림은 남아 있습니다. 임베딩 서버가 동시 부하에서 같은 질문 벡터를 1e-4 수준으로 다르게 반환해, 근거 재현이 46/50에 그친 회차가 있었습니다. 추론 서버의 배치 처리에서 오는 것으로 보고 기록만 해 두었습니다.

돌아보면 재현성은 정확도 개선을 측정하기 위한 전제였던 것 같습니다. 같은 질문에 같은 근거가 나와야 다음 변경이 좋아졌는지 나빠졌는지를 말할 수 있었습니다.