그래프 크기를 따라가던 검색 비용과 흔들리던 근거: PostgreSQL 그래프 조회 계층 정비
PostgreSQL 위 GraphRAG 검색이 한 번에 54~127초 걸리거나 같은 질문에 근거가 달라지는 현상을 pg_stat_activity, 실행 계획, 로그 타임스탬프로 추적했습니다. 플래너 통계, 그래프 전체를 읽던 PPR, 검색마다 반복되던 집계, 정렬 없는 LIMIT을 차례로 고쳐 비용을 후보 크기에 묶고 근거 재현을 50/50으로 맞췄습니다.
Key Results
- 43/50 → 50/50
- 두 번 검색 시 근거가 같은 문항캐피탈 규정 컬렉션 50문항, LLM 없이 검색 결과만 비교
- 208 ms → 1 ms
- 질의당 PPR 계산PPR 간선 34,293개 컬렉션, 전체 간선 PPR 대 로컬 push PPR
- 8.6 s → 5.4 s
- 검색 p90같은 50문항, 결정적 정렬과 동시 읽기 적용, 중앙값 2.50 s 유지
XGEN의 온톨로지 검색은 지식그래프를 PostgreSQL 표 세 개에 두고, 벡터 검색과 그래프 탐색 결과를 한 번에 융합해 답변 근거를 고릅니다. 2026년 9월, 캐피탈 고객의 사내 규정과 FAQ 문서로 비교 평가를 이어 가던 중 검색 계층에서 이상 징후가 연달아 나왔습니다. 새로 만든 FAQ 컬렉션에서 검색 한 번이 54~127초 걸렸고, LLM으로 관계를 보강한 컬렉션은 44초가 걸리면서 정확도까지 떨어졌으며, 같은 50문항을 두 번 검색하면 근거가 43문항에서만 같았습니다.
증상은 제각각이었지만 뿌리는 비슷했습니다. 검색 한 번의 비용이 후보 청크 수십 개가 아니라 그래프나 매핑 표 전체의 크기를 따라가고 있었고, 결과 일부가 표의 물리적 행 순서에 좌우되고 있었습니다. 평가 수치가 이 위에서 나오고 있었으니, 비교 결론을 내리기 전에 조회 계층부터 바로잡아야 했습니다.
이 글은 약 3주 동안 원인을 좁혀 간 과정을 다룹니다. 처음 세운 가설 세 개가 결정타가 아니었던 일, 실행 계획에서 행 수 추정 1을 찾은 일, 단계별 계측에 잡히지 않던 24초를 로그 타임스탬프로 찾은 일, 정렬 키 하나가 실행 계획을 바꾸던 일을 순서대로 적습니다.
검색 한 번이 PostgreSQL에서 하는 일
온톨로지 검색은 질문 하나에 대해 네 갈래로 후보 청크를 모읍니다. 의미가 가까운 청크, 질문 낱말을 포함한 청크, 아직 후보에 없는 파일의 대표 청크, 그리고 질문 낱말과 라벨이 맞는 개체에서 이웃을 따라간 청크입니다. 이렇게 모은 풀 안에서 밀집 점수, BM25 점수, PPR(Personalized PageRank) 점수를 가중합해 순위를 매기고, 절단과 MMR(중복을 줄이는 선별)을 거쳐 답변 모델에 넘길 근거를 고릅니다.
질문
├─ ① 벡터 후보 ANN 상위 100 → 최고점 대비 비율 절단 → 최대 40
├─ ② 낱말 보강 질문 낱말을 포함한 청크 중 벡터 상위 → 최대 12
├─ ③ 파일 보정 후보에 없는 파일에서 벡터 상위 → 최대 12
└─ ④ 그래프 확장 라벨 링킹 시드 → 상위 6개 × 이웃 10
→ 그 개체의 청크(최대 500) → 벡터 재점수 → 최대 12
후보 풀: 통상 64개, 상한 76개
⑤ 융합 밀집 1.0 + BM25 0.8 + PPR 0.15 → 절단 → MMR → 근거 8~16그래프는 노드, 간선, 노드-청크 매핑의 세 표에 저장돼 있습니다. 검색마다 이 표를 읽는 조회가 네 종류 있습니다. 후보 청크가 언급한 개체를 찾는 청크→개체 조회, 개체가 언급된 청크를 찾는 개체→청크 조회, 너무 많은 청크에 등장하는 허브 개체를 거르는 등장 비율 계산, 그리고 시드 개체에서 그래프를 걸어 점수를 퍼뜨리는 PPR입니다.
코드는 두 곳으로 나뉩니다. 융합, MMR, PPR 같은 순위 알고리즘은 오픈소스 검색 라이브러리 omnifuse에 있고, PostgreSQL 표를 읽는 SQL은 제품 쪽 그래프 저장소 어댑터에 있습니다. 어떤 결함을 어느 쪽에서 고칠지가 이 구분으로 정해졌습니다.
측정 대상은 캐피탈 고객 문서로 만든 컬렉션들이었습니다. 규정 컬렉션은 443문서·14,924청크(청크 2,000자, 겹침 200자), FAQ 컬렉션은 165문서·185청크(800자, 겹침 50자)였고, 고객 체험 환경의 매핑 표는 163만 행이었습니다. 위 구조에서 후보 풀은 많아야 76개입니다. 질의당 비용도 이 크기를 따라가는 것이 정상이고, 그 기준을 벗어나는 조회를 찾는 것이 이 작업의 큰 줄기였습니다.
평가 도중 드러난 네 가지 이상 징후
| 증상 | 컬렉션 | 처음 관찰한 수치 |
|---|---|---|
| 검색 스트림 첫 이벤트 9분 이상 지연, 이후 54~127초 | FAQ를 800자로 다시 적재한 컬렉션 3개 | 같은 시각 규정 컬렉션 6.5초, 원래 FAQ 컬렉션 0.8초 |
| 검색 1회 14.5초로 퇴행 | 규정 컬렉션 | 전날 밤 1~2초 |
| 검색 33~44초, 정확도 71.5% | LLM 관계 보강 컬렉션 | 같은 문서의 기본 빌드 4초, 77.0% |
| 두 번 검색 시 근거 일치 43/50 | 규정 컬렉션 50문항 | 같은 청크의 벡터 점수는 동일 |
첫 번째 증상에서는 같은 재적재 컬렉션의 벡터 전용 검색이 2.9초로 정상이었습니다. 범위는 처음부터 그래프 단계로 좁혀져 있었습니다. 두 번째는 평가 실행에서 문항 하나가 245초까지 걸리며 드러났습니다. 세 번째는 관계가 4만 개에서 7.7만 개로 늘었는데 검색은 열 배 넘게 느려지고 정확도는 낮아진 경우라, 그대로 읽으면 관계 보강이 손해라는 결론이 나오는 숫자였습니다. 네 번째는 성능과 별개로 재현성의 문제였습니다.
54초의 원인은 인덱스가 아니라 행 수 추정이었습니다
FAQ 컬렉션의 첫 가설은 데이터 모양이었습니다. 짧은 FAQ가 청크마다 개체 수십 개로 빌드돼 이웃 확장과 융합이 곱으로 커진다고 봤습니다. 서버 로그를 볼 수 없어 단계를 확정하지 못한 채, 청크당 개체 상한(변별력 순 12개), 그래프 단계 시간 예산(30초, 넘으면 확장 조회는 캐시나 빈 결과로 응답), 단계별 소요 시간 추적을 함께 넣었습니다.
배포 뒤 검색은 55.6초에 돌아왔습니다. 예산이 발동한 결과였고, 추적에 단계가 처음 보였습니다. 벡터 0.18초, 그래프 확장 0.13초, 융합 0.13초인데 청크→개체 조회가 54.61초였습니다. 문서 10건짜리 컬렉션(간선 108개)에서는 같은 단계가 1.13초라 간선 수에 비례한다고 보고, 두 번째 가설로 이웃 선읽기 쿼리의 노드 표 이중 JOIN을 지목했습니다. 노드 표에 (컬렉션, uri) 인덱스가 없었기 때문입니다. JOIN을 걷어내고 라벨은 한 번에 모아 읽게 바꿨습니다.
배포 뒤에도 54~118초였습니다. 세 번째 가설은 큰 배열을 = ANY(배열)로 비교할 때의 행별 스캔과 소속 인스턴스 전개의 폭증이었습니다. 배열 조건을 = ANY(SELECT unnest(...)) 형태의 반조인으로 바꾸고, 인스턴스 전개를 앵커당 100개로 SQL에서 자르고, 노드 표와 매핑 표에 (컬렉션, uri) 인덱스를 추가했습니다. 인덱스가 실제로 생성된 것까지 확인했는데 청크→개체 조회는 여전히 54초였습니다.
세 수정은 모두 그럴듯한 가설에서 나왔지만, "어느 쿼리가 시간을 쓰는가"를 직접 보지 않고 코드 모양에서 원인을 추정했다는 공통점이 있었습니다. 방법을 바꿔 검색을 실행하는 동안 pg_stat_activity를 반복 샘플링하자, 54초 전부가 청크→개체 조회의 CTE 한 문장에 머물러 있었습니다. 같은 문장을 EXPLAIN ANALYZE로 보니 차이가 분명했습니다.
같은 문장, 같은 파라미터
custom plan 58,347 ms 새 컬렉션 조건의 행 추정 1, 실제 4,100
→ Nested Loop Semi Join, CTE 재스캔 183만 회
generic plan 60 ms플래너가 새 컬렉션의 행 수를 1로 추정하고 중첩 루프를 고른 것입니다. 통계를 보니 매핑 표(163만 행)와 간선 표는 전날 밤 이후 자동 통계 갱신이 한 번도 돌지 않았습니다. 그사이 바뀐 행은 6.5만 개로 자동 갱신 임계(약 16만 행)에 못 미쳤고, 그날 만든 컬렉션 세 개는 통계의 최빈값(MCV) 목록에 없어 "기타 값"의 평균으로 1행이 추정됐습니다. 같은 이유로 빌드 막바지의 그래프 마무리 조인도 100초를 넘기고 있었습니다.
세 표에 ANALYZE를 실행하자(17초, 통계만 갱신) 같은 검색이 126초에서 1.9초, 54초에서 1.0초, 117초에서 1.6초가 됐고 시간 예산도 발동하지 않았습니다. 앞의 세 수정은 유효한 개선으로 남았지만 이 건의 결정타는 아니었습니다.
규정 컬렉션의 14.5초 퇴행은 처음부터 쿼리를 봤습니다. 추적에서는 그래프 확장 6.03초와 청크→개체 조회 6.30초가 컸고, pg_stat_activity에는 다음 문장이 동시에 세 개 걸려 있었습니다.
SELECT count(DISTINCT chunk_id) FROM node_chunks WHERE collection_id = $1;
-- EXPLAIN ANALYZE 3,565 ms: 매핑 448,104행 Bitmap Heap Scan(31,232 블록), 결과 14,809허브 개체를 거르려고 직전에 넣은 등장 비율 계산의 분모였습니다. 컬렉션의 청크 수를 세려고 매핑 표 44만 행을 읽었고, 이 계산이 그래프 후보 시드, 확장, 청크→개체 조회에서 검색당 세 번 불려 10초 가까이 됐습니다. FAQ 컬렉션은 청크가 185개라 드러나지 않았고, 전날 밤 평가는 이 변경 이전이라 1~2초였습니다.
이 값은 한 요청 안에서 바뀔 일이 없습니다. 요청 수명 동안 한 번만 계산하도록 캐시하고, (컬렉션, 청크) 인덱스를 청크마다 건너뛰며 세는 재귀 CTE(loose index scan)로 바꿨습니다. 같은 14,809를 3,325 ms에서 864 ms에, FAQ 컬렉션의 184를 346 ms에서 2.6 ms에 셌습니다. 비율과 임계값 코드는 그대로 두었고, FAQ 165문항의 순위가 R@1 96.4, R@3 99.4로 수정 전과 같은 것을 확인했습니다. 규정 컬렉션 검색은 14.5초에서 6.8~8.4초가 됐습니다.
그래프가 커질수록 느려지던 조회 두 개
관계 보강 컬렉션의 44초는 처음에 관계 데이터의 품질 문제로 보였습니다. 원문에서 함께 등장하지 않는 관계가 4만 건 있었고 정확도도 낮았기 때문입니다. 그런데 검색 추적의 단계 합은 7초였고, 그래프 시간 예산 30초가 소진됐다는 표시가 함께 찍혀 있었습니다. 단계 합과 총시간이 맞지 않으면 계측 밖에서 시간이 쓰이고 있다는 뜻입니다.
요청 하나의 로그 타임스탬프를 순서대로 따라가자 PPR 적용에 약 24초가 쓰이고 있었고, 이 단계는 추적 항목에 없었습니다. 라이브러리의 PPR 점수 읽기 함수는 PageRank가 닿은 모든 노드로 개체→청크 조회를 불렀고, 관계 7.7만 개 그래프에서 닿는 노드는 약 4.9만 개였습니다. 같은 모양의 질의를 PostgreSQL에서 재현하니 인덱스 탐색 49,030회에 30.7초였습니다. 의심했던 간선 전체 조회는 0.48초(기본 컬렉션 0.28초)라 무관했습니다.
이 조회에는 1,000행 LIMIT이 걸려 있어 후보 12개 중 4~6개만 PPR 점수를 받고 있었습니다. 더 큰 피해는 연쇄였습니다. PPR이 그래프 시간 예산을 먹자 뒤이은 클래스 인스턴스 조회와 이웃 조회가 빈 결과로 응답했고, 클래스 목록이 포함된 문항이 기본 컬렉션 133/165에서 관계 보강 컬렉션 5/165로 줄었습니다. 관계 보강 컬렉션은 그래프 기능의 상당 부분이 꺼진 상태로 측정되고 있었습니다. 관계 데이터의 잡음도 실제로 있었지만, "관계 보강은 손해"라는 판단은 이 비용 결함을 걷어낸 뒤에야 내릴 수 있었습니다.
고칠 자리는 명확했습니다. PPR 점수가 필요한 것은 후보 청크뿐이고, 후보 청크의 개체는 어댑터가 청크→개체 조회로 이미 들고 있습니다. 라이브러리 함수에 그 매핑을 받는 인자를 추가로 넣었습니다.
# 닿은 노드 전체 대신 후보 쪽 개체로 PPR 점수를 읽는다. 인자를 안 넘기면 기존 동작.
ppr_chunk_scores(..., chunk_entities=candidate_entities)읽기 범위가 그래프 전체에서 후보 64개로 바뀌고 1,000행 절단도 사라집니다. PPR 도입 때의 의도(후보 집합 불변, 최댓값 사용, 라벨과 변별력으로 정한 시드)를 유지하는지 점검했고, 1,000행 LIMIT은 PPR 설계가 아니라 개체→청크 조회의 예산 보호 장치였다는 것도 확인했습니다. 가짜 그래프·벡터 저장소 위의 재현 하니스로 수정 전후를 비교해 60/60 동일(추적 키 추가만 다름)을 확인한 뒤 배포했습니다.
같은 시기 청크 1,000자로 다시 적재한 규정 컬렉션(29,050청크)은 검색이 15~22초였습니다. 후보 64청크로 EXPLAIN을 떠 보니 개체별 등장 청크 수를 검색마다 새로 세고 있었습니다.
| 질의 | 시간 | 읽은 행 |
|---|---|---|
| 청크→개체 본 질의(개체별 청크 수 집계 포함) | 8.3 s | 개체 919개 × 평균 213행, 약 19.6만 행 |
| 등장 비율(개체별 distinct 청크 수) | 2.9 s | 개체 315개 × 401행 |
| 이웃 선읽기 | 1.1 s |
청크가 작아지면 문서당 청크가 두 배가 되고, 개체마다 등장 청크도 두 배, 후보가 걸치는 개체도 늘어 셈 비용이 곱으로 커집니다. 2,000자 컬렉션에서 2초이던 단계가 12초가 된 이유였습니다.
처음 떠올린 방법은 개체별 청크 수를 빌드 마무리에서 노드 표의 열로 물질화하는 것이었습니다. 그런데 검색 때 세는 방식은 편집과 삭제를 포함한 현재 컬렉션 상태를 반영하려는 의도로 들어간 것이었고, 열을 추가하면 표 구조 변경과 기존 컬렉션 재빌드가 따라옵니다. 매핑 표가 바뀌는 시점은 빌드, 편집, 삭제뿐이라는 점에 착안해, 표 지문(행 수와 최대 id)이 같으면 직전 스냅샷을 재사용하는 프로세스 캐시(컬렉션 16개 상한)로 바꿨습니다. 같은 값을 같은 반영 시점에 돌려주면서 PostgreSQL에는 아무것도 쌓지 않는 방식입니다. 이때 청크→개체 결과의 정렬도 안정화했습니다.
두 수정이 배포된 개발 서버에서 청크→개체 조회는 중앙값 0.21초, PPR 단계는 0.65초, 검색은 중앙값 3.0초(p90 4.9초)였습니다. 이전 환경의 약 2초, 4.3초보다 줄었지만, 임베딩 모델이 달라 코드 효과만 분리한 수치는 아닙니다.
같은 질문에 다른 근거가 나온 이유
비결정성은 9월 13일에 한 번 지나친 적이 있습니다. 라이브러리 공개 API로 어댑터를 옮긴 뒤 같은 배포본으로 규정 165문항을 두 번 검색하자 근거 순서가 같은 문항은 117/164, 집합이 같은 문항은 134였습니다. 꼬리 순위만 바뀌고 정답 문서 순위는 그대로라, 당시에는 임베딩 호출과 근사 최근접 탐색의 미세 변동으로 해석하고 넘어갔습니다. 그 해석은 검증되지 않은 채 남아 있었습니다.
9월 18일 로컬 스택에서 50문항을 두 번 검색해 43/50만 같다는 것을 다시 보고, 이번에는 해석부터 확인했습니다. 두 회차에서 같은 청크의 벡터 점수가 완전히 같았습니다. 임베딩과 벡터 DB가 원인이 아니라면 남는 것은 그래프 조회였고, 어댑터를 훑자 ORDER BY 없이 LIMIT만 건 조회가 12곳 있었습니다. 이웃 세 방향, 라벨 정확·부분 일치, 속성값, 클래스 인스턴스, 이웃 선읽기, 개체의 청크, PPR 간선입니다. PostgreSQL은 ORDER BY가 없으면 행 순서를 보장하지 않으므로, 상한에 걸리는 질문에서는 어떤 행이 잘릴지가 실행마다 달라지고 그 차이가 후보와 근거까지 내려갑니다.
정렬 키를 고르는 데서 한 번 더 막혔습니다. id나 uri 같은 유일 키로 정렬하자 일반화 계획이 인덱스 순서 스캔 후 필터로 바뀌었습니다. force_generic_plan으로 확인한 EXPLAIN에서 간선 조회 86 ms가 700 ms, 라벨 정확 일치 100 ms가 526 ms, 검색 한 번은 2.5초에서 5.9초가 됐습니다. 결정성을 얻는 대신 검색 비용이 두 배가 되는 셈이라, 조회마다 계획을 바꾸지 않는 정렬 위치를 따로 골랐습니다. 인덱스가 없는 라벨 컬럼으로 정렬하거나, 조회 결과 별칭 위에서 정렬하거나, 어차피 전부 읽는 간선은 애플리케이션에서 정렬했습니다. 리뷰에서는 개수와 라벨로만 정렬하던 클래스 목록이 동률일 때 여전히 임의라는 지적을 받아 uri를 추가했습니다. 표의 유일 제약이 (컬렉션, uri)뿐이었기 때문입니다.
정렬로 늘어난 비용(중앙값 +0.45초)은 독립적인 읽기를 동시에 실행해 되돌렸습니다. 벡터 채널(①②③), 그래프 확장 풀(④), PPR 시드와 간선을 asyncio.gather로 함께 읽고 합류하며, 요청 안에서 표 지문, 라벨 검색, 간선 조회, 질문 임베딩은 한 번만 하도록 메모했습니다. 결정적 순서 판을 두 번 실행하면 근거가 50/50 같았고, 순차 실행과 동시 실행의 근거 파일과 본문도 50/50 같았습니다. 검색 중앙값은 2.50초 그대로, p90은 8.6초에서 5.4초가 됐습니다.
남은 비용은 PPR 간선이었습니다. 기존 구현은 질의마다 컬렉션의 간선 표를 통째로 읽고 정해진 횟수만큼 반복했고, 읽는 간선에는 8만 개 상한이 있었습니다. 리뷰에서 이 상한이 문제로 올라오자 처음에는 상한 안이면 전부 읽고 넘으면 정렬해 자르는 식으로 다듬었지만, 임시방편이라고 판단했습니다. 상한은 큰 그래프에서 PPR이 보는 범위를 자를 뿐, 전부 읽는 구조는 그대로였습니다.
그래서 라이브러리에 로컬 push 방식 PPR(Andersen-Chung-Lang)을 추가했습니다. 잔여 질량이 허용 오차보다 큰 노드의 이웃만 읽고, 처리 순서를 정렬해 결과를 결정적으로 만들었습니다. 배치 이웃 조회는 저장소가 선택적으로 구현하는 메서드로 두고, 없으면 단건 이웃 조회로 대체합니다. 첫 구현은 걸을 간선이 없는 시드에 질량을 남겨 두어 기존 PPR과 상위 10 후보가 49문항 중 3개만 일치했습니다. 기존 함수처럼 그런 시드에는 질량을 주지 않도록 시드 의미를 맞추자 41/49가 됐고(허용 오차 1e-6이면 43/49), 나머지는 반복 횟수가 고정된 기존 계산이 덜 수렴해 생긴 근소한 차이였습니다. 간선 34,293개 컬렉션에서 질의당 PPR은 208 ms에서 1 ms, 읽은 행은 중앙값 0, 최대 2,334였습니다. 이후 간선 표 전체를 읽던 경로와 상한, 반복 횟수 설정을 모두 삭제했습니다.
고칠 자리를 고른 기준
선택지를 고를 때 기준은 두 가지였습니다. 질의당 비용은 그래프가 아니라 후보 크기에 비례해야 하고, 같은 입력에는 같은 근거가 나와야 합니다. 고치는 위치도 정했습니다. 저장소와 무관한 순위 알고리즘(PPR 읽기 범위, 로컬 PPR)은 라이브러리에서 고쳐 제품은 버전만 올려 받고, 표 구조에 묶인 조회(정렬, 집계, 캐시)는 제품 어댑터에서 고칩니다. 9월 13일에 어댑터가 라이브러리 비공개 메서드를 덮어쓰던 구조를 공개 API 호출로 바꿔 둔 덕분에 이 구분이 실제로 작동했습니다.
| 선택지 | 장점 | 단점 | 판단 |
|---|---|---|---|
| 정렬 키로 id·uri 같은 유일 키 사용 | 의미가 가장 명확함 | 일반화 계획이 인덱스 순서 스캔으로 바뀌어 검색 2.5 s → 5.9 s | 기각 |
| 계획을 바꾸지 않는 위치에서 정렬(라벨·별칭·애플리케이션) | 결정성 확보, +0.45 s는 동시 읽기로 상쇄 | 조회마다 위치를 따로 골라야 함 | 채택 |
| PPR 간선 수 상한 유지 | 변경이 작음 | 범위만 자르고 전부 읽는 구조는 그대로 | 기각 |
| 로컬 push PPR | 시드 주변만 읽음, 208 ms → 1 ms | 기존 PPR과 근사 차이(상위 10 일치 41/49) | 채택 |
| 개체별 청크 수를 빌드 때 열로 물질화 | 검색은 읽기만 함 | 표 구조 변경, 기존 컬렉션 재빌드 필요 | 보류 |
| 표 지문이 같으면 스냅샷 재사용 | 현재 상태 반영 의도 유지, 스키마 변경 없음 | 지문 조회 자체의 비용이 남음 | 채택 |
| 낡은 통계에 수동 ANALYZE | 즉시 복구 | 새 컬렉션이 생길 때마다 다시 필요 | 응급 조치 |
마지막 줄은 실제로 재발했습니다. 9월 14일 관계 보강 컬렉션을 만든 뒤 매핑 표에 분석되지 않은 변경이 17만 행 쌓여 같은 ANALYZE를 다시 실행했습니다. 빌드 마무리에서 세 표를 ANALYZE하거나 표별 자동 갱신 비율을 낮추는 구조 수정은 후속 과제로 남겼습니다.
결과와 남은 과제
| 지표 | 이전 | 이후 | 측정 조건 |
|---|---|---|---|
| FAQ 800자 컬렉션 검색 1회 | 126 s · 54 s | 1.9 s · 1.0 s | 고객 체험 환경, 세 표 ANALYZE 직후 같은 검색 |
| 컬렉션 청크 수 집계 | 3,325 ms | 864 ms | 규정 컬렉션(14,809청크), 요청당 1회 + loose index scan |
| 규정 컬렉션 검색 1회 | 14.5 s | 6.8~8.4 s | 같은 컬렉션, 집계 수정 배포 뒤, 순위 불변 |
| 두 번 검색 시 근거 일치 | 43/50 | 50/50 | 로컬 스택, 규정 컬렉션 50문항, LLM 없이 |
| 검색 p90 | 8.6 s | 5.4 s | 같은 50문항, 결정적 정렬 + 동시 읽기, 중앙값 2.50 s 유지 |
| 질의당 PPR | 208 ms | 1 ms | 간선 34,293개, 전체 간선 PPR 대 로컬 push PPR |
| 검색 중앙값 · p90 | 3.40 s · 6.7 s | 1.95 s · 3.4 s | 수정 전 제품 기본 라인 대 최종 수정본, 같은 스택 50문항 |
| 답변 정답(동적 · top5) | 41 · 38 | 42 · 41 | 같은 스택·모델·judge, 잃은 문항 0, 차이는 회차 변동 범위 |
마지막 두 줄이 최종 비교입니다. 검색 순위 지표는 R@1 25로 같고 R@5와 R@10은 한 문항씩 늘었으며, 50문항 중 49문항이 빨라졌습니다. 답변 정답 수 차이는 기존 코드의 회차 변동(±2) 안이라 정확도가 올랐다는 근거로 쓰지 않았고, "잃는 문항 없이 매번 같은 결과"로 정리했습니다.
돌아보면 첫 사건에서 세 번의 수정을 거친 이유는 측정 수단을 늦게 꺼냈기 때문이었습니다. 이후로는 pg_stat_activity 샘플링, EXPLAIN ANALYZE, 로그 타임스탬프를 먼저 확인했고, 단계 합이 총시간과 맞지 않으면 계측 밖을 의심했습니다. "질의당 비용이 후보 크기에 비례하는가"라는 질문도 이후 조회 코드를 볼 때마다 쓰는 점검 기준이 됐습니다.
남은 일도 있습니다. 첫째, 개체별 청크 수 캐시의 지문 조회가 큰 테넌트에서 그 자체로 비쌌습니다. 275만 행 표에서 Parallel Seq Scan이 일어나 콜드 2.1초, 웜 0.15~0.4초였고, 요청당 한 번으로 메모해 두었습니다. (컬렉션, id) 인덱스도 시험했지만 고객 컬렉션이 표의 22%라 플래너가 인덱스를 쓰지 않았습니다(웜 98 ms에서 110 ms, 콜드 4.7초). 쓰기 시점에 행 수를 유지하는 방식이 필요합니다. 둘째, 라벨 시드 조회는 인덱스를 컬렉션 조건으로만 타고 라벨 약 10만 행을 매번 필터링합니다(유휴 129 ms, 부하 시 0.5~0.9초). (컬렉션, lower(label)) 함수 인덱스가 후보입니다. 셋째, 임베딩 서버가 동시 부하에서 같은 질문 벡터를 1e-4 수준으로 다르게 반환해 근거 재현이 46/50에 그친 회차가 있었습니다. 검색 코드 밖의 비결정성이라 기록만 해 두었습니다.