pgvector HNSW 필터링 문제: RAG에서 10개 행을 요청했으나 0개를 받았습니다
요약
pgvector에서 HNSW 인덱스를 사용하여 RAG 검색을 수행할 때, `WHERE` 절 필터링이 근사 인덱스 후보군 선택 이후에 적용되면서 작은 테넌트의 데이터가 누락되는 문제가 발생합니다. 이는 특히 `LIMIT` 값보다 필터 조건에 맞는 행 비율이 낮을 경우 0개 행을 반환하게 만듭니다.
핵심 포인트
- pgvector HNSW는 근사 인덱스로, WHERE 절 필터링이 후보군 선택 후 적용됨.
- 작은 테넌트의 데이터가 누락될 위험이 있으며, 이는 RAG 시스템에서 문제가 될 수 있음.
- 해결책으로 반복적 인덱스 스캔(`hnsw.iterative_scan`)이나 부분 인덱스를 고려해야 함.
지원 티켓은 한 줄로 되어 있었습니다. "당신의 어시스턴트가 저희 문서에 환불 관련 내용이 없다고 말합니다. 저희에게는 '환불(Refunds)'이라는 페이지 전체가 있습니다."
제가 확인해 보니, 그 페이지는 존재했습니다. 청크(chunk)로 분할되었고, 임베딩되어 있었으며, 올바른 tenant_id를 가진 Postgres에 저장되어 있었습니다. 저는 봇이 실행하는 것과 정확히 동일한 검색 쿼리를 psql에서 수동으로 실행해 보았습니다.
결과는 0개 행이었습니다.
잘못된 행이 아닙니다. 나쁜 행도 아닙니다. 청크가 4,000개인 테넌트(tenant)에 대해 LIMIT 10을 사용한 쿼리에서 무려 0개였습니다. 원인은 pgvector HNSW 필터링 때문입니다. 즉, 당신의 WHERE 절이 근사 인덱스(approximate index)가 이미 후보군을 선택한 후에 실행되기 때문에 발생하는 문제입니다. 작은 테넌트들은 이 경쟁에서 밀리게 되고, 로그에는 아무것도 기록되지 않습니다.
요약 (TL;DR)
- pgvector의 HNSW 인덱스는 먼저 전체 테이블에서
hnsw.ef_search가장 가까운 벡터(기본값 40)를 찾고, 그 후에 Postgres가 이 40개 후보군에 대해 당신의WHERE필터를 적용합니다. - 예상 반환 행 수 ≈
ef_search × selectivity. 만약 필터가 전체 행의 2%와 일치한다면,LIMIT값과 상관없이 평균적으로 약 0.8개 행만 얻게 됩니다. - 어떤 필터가 테이블의
LIMIT / ef_search(상위 10개 기준: 10/40 = 25%)보다 적은 비율을 일치시키면, 평균적으로 요청한 개수보다 적은 행이 반환됩니다. - 해결 방법으로는 반복적 인덱스 스캔(
SET hnsw.iterative_scan = strict_order, pgvector 0.8.0+), 테넌트별 부분 인덱스(partial indexes) 또는 파티션, 혹은 작은 필터에 대한 정확한 스캔을 사용할 수 있습니다. - 이 문제를 감지하는 방법은 반환 행 수가
LIMIT보다 적은 모든 검색을 로깅하는 것입니다. 이는 무료이며, 제가 이 버그를 발견한 첫날부터 잡아냈을 것입니다.
문제가 발생한 쿼리는 어떤 모습인가요?
이는 세상에서 가장 일반적인 RAG 쿼리입니다:
SELECT id, content
FROM chunks
WHERE tenant_id = 42
...
embedding에 HNSW 인덱스가 있는 경우:
CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops);
제 테이블에는 약 20만 개의 청크가 있었습니다. 하나의 큰 테넌트가 대부분을 소유하고 있었고, 테넌트 42는 그중 2%를 차지했습니다. 큰 테넌트에 대해서는 검색이 완벽했지만, 테넌트 42에 대해서는 봇이 유료 고객에게 자신들의 문서가 존재하지 않는다고 확신하며 말하고 있었습니다.
EXPLAIN ANALYZE 결과는 다음과 같았습니다:
Limit (실제 행 수=0)
-> Index Scan using chunks_embedding_idx on chunks (실제 행 수=0)
Order By: (embedding <=> '[...]'::vector)
...
Rows Removed by Filter: 40. 이 숫자가 모든 이야기입니다.
pgvector HNSW 필터링은 왜 LIMIT보다 적은 행을 반환할까요?
HNSW는 근사 인덱스(approximate index)이며, 고정 크기의 후보 목록만 생성하고 Postgres가 그 목록을 나중에 필터링하기 때문입니다. 이 인덱스는 tenant_id의 존재를 알지 못합니다.
HNSW 검색은 벡터 그래프를 따라 이동하며 쿼리 방향으로 탐욕적으로 뛰어갑니다(greedily hopping). 자신이 본 최고의 후보들의 실행 목록을 유지합니다. 그 목록의 크기는 hnsw.ef_search이며, 기본값은 40입니다. 탐색이 완료되면 인덱스는 이 40개의 행을 거리 순서로 Postgres에 전달합니다.
그런 다음 실행기(executor)가 WHERE tenant_id = 42를 적용합니다. 다른 테넌트의 모든 행은 버려집니다. 만약 40개 중 어느 것도 테넌트 42에 속하지 않으면, 아무것도 반환되지 않지만 쿼리는 여전히 '성공'합니다.
수학은 잔인하고 간단합니다. 만약 테넌트 42의 청크들이 벡터 공간에 무작위로 뿌려져 있다면:
- 예상 일치 건수 = 40 × 0.02 = 0.8개 행
- 일치 건수가 0일 확률 = 0.98^40 ≈ 45%
따라서 테넌트 42의 질문 중 약 절반은 아무것도 검색하지 못할 것이고, 거의 아무것도 완전한 10개를 검색하지 못할 것입니다. 실제로는 종종 더 나쁩니다. 제가 속한 대형 테넌트는 비슷한 제품을 판매했기 때문에, 그 반환 정책 청크들이 임베딩 공간에서 테넌트 42의 반환 정책 청크 바로 옆에 위치했습니다.
| 필터 일치율 | 40개 후보에서 예상되는 행 수 | LIMIT 10 적용 시 얻는 행 수 |
|---|---|---|
| 50%의 행 | 20 | 10 |
| ... | ||
그 10%의 행은 제가 지어낸 것이 아닙니다. pgvector README에도 동일한 예시가 명시되어 있습니다. 기본 ef_search를 사용하고 필터링 조건이 전체 행의 10%에 해당할 경우, 평균적으로 약 4개의 행만 반환합니다. |
이것이 버그가 매우 잘 숨어 있는 이유입니다. 테스트 테넌트가 아마도 가장 큰 테넌트이거나 유일한 테넌트일 것입니다. 필터는 아마도 불평을 가장 적게 하는 고객들에게만 선택적으로 적용될 것입니다.
hnsw.ef_search를 높이는 것이 해결책인가요?
부분적으로 그렇습니다. 임시방편(bandaid)이며, 사용하기 전에 그 대가를 알아야 합니다.
SET hnsw.ef_search = 400;
이제 40개 대신 400개의 후보를 얻게 되며, 2%의 선택성으로 약 8개의 예상 행을 얻습니다. 여전히 10개보다 적습니다. 검색 비용은 필요하지 않은 쿼리를 포함하여 모든 쿼리에서 ef_search에 따라 증가하며, 최대치는 1000입니다. 0.1%의 선택성에서는 1000조차 충분하지 않습니다. 당신은 개별 필터 문제를 해결하기 위해 전역적인 노브(knob)를 조정하고 있는 것입니다.
반복적 인덱스 스캔이 pgvector 필터링을 어떻게 해결하나요?
인덱스가 필터를 통과하는 데 필요한 충분한 행을 얻을 때까지 그래프 탐색을 계속하도록 합니다. 이것은 pgvector 0.8.0 이상 버전을 사용하고 있다면 실제적인 해결책입니다.
SET hnsw.iterative_scan = strict_order; -- 또는 relaxed_order
SET hnsw.max_scan_tuples = 20000; -- 기본값; 안전 제한치
문제는 max_scan_tuples에 있습니다. 거대한 테이블 속에 묻힌 아주 작은 테넌트의 경우, 매칭되는 결과를 10개 찾기 전에 스캔이 이 한계치에 도달할 수 있고, 결국 적은 결과만 받게 됩니다. 반복적(Iterative) 스캔은 이 절벽을 훨씬 더 멀리 이동시킵니다. 하지만 삭제하지는 않습니다.
저는 검색 트랜잭션 내에서 전역적으로 설정하는 대신 SET LOCAL hnsw.iterative_scan = relaxed_order를 설정하여, 제가 확인하기 전까지 다른 벡터 쿼리는 기존 동작을 유지하도록 했습니다.
pgvector에서 멀티테넌트 RAG에 가장 좋은 해결책은 무엇인가요?
작은 테넌트가 큰 테넌트와 후보 목록을 공유하게 만들지 마세요. 세 가지 옵션이 있으며, 이들은 쌓여서 사용됩니다.
1. 필터의 고유 값이 적을 경우 부분 인덱스(Partial indexes)를 사용합니다. 테넌트 또는 카테고리별로 HNSW 인덱스를 하나씩 생성하고, 각각 WHERE 조건으로 범위를 지정합니다:
CREATE INDEX chunks_t42_hnsw ON chunks
USING hnsw (embedding vector_cosine_ops)
WHERE (tenant_id = 42);
플래너는 쿼리의 WHERE 조건이 인덱스 술어와 일치할 때 이를 사용합니다. 모든 후보가 이미 올바른 테넌트이기 때문에 필터링은 아무것도 제거하지 않습니다. 이는 10,000개의 테넌트까지 확장되지는 않지만, 몇 개의 큰 카테고리에는 아주 좋습니다.
2. 필터의 값이 많을 경우 파티셔닝(Partitioning)을 사용합니다. PARTITION BY LIST (tenant_id) 또는 해시 파티셔닝을 사용하여 각 파티션에 HNSW 인덱스를 생성합니다. Postgres는 올바른 파티션으로 축소하여 그 그래프만 검색합니다.
3. 작은 필터에 대한 정확한 검색(Exact search)입니다. 테넌트 42의 경우, 4,000개 벡터에 대한 정확한 스캔은 저렴하며, '정확하다'는 것은 100% 재현율을 의미합니다. HNSW 인덱스가 순서 결정에 사용되지 못하도록 필터링된 세트를 먼저 구체화(materializing)하여 강제할 수 있습니다:
WITH tenant_chunks AS MATERIALIZED (
SELECT id, content, embedding
FROM chunks
...
제가 구현한 방식은 다음과 같습니다. 검색 시 반복적 스캔을 활성화하고, 모든 테넌트에 대해 이 정확한 검색 경로를 추가했습니다. 큰 테넌트는 근사 인덱스를 사용합니다. 작은 테넌트는 완벽한 재현율을 얻으면서도 여전히 빠르게 답변합니다.
사용자보다 먼저 HNSW 필터링 버그를 감지하려면 어떻게 해야 하나요?
부족분을 기록하세요(Log the shortfall). 이 내용은 10줄 분량이며, 이 게시물에서 가장 가치 있는 내용입니다:
행 목록이 적게 나오는 것은 해당 테넌트가 실제로 요청된 청크(k)보다 적은 경우라면 괜찮습니다. 하지만 수천 개에 달하는 행을 가지고도 적게 나온다면 이는 버그 신호입니다. 또한, 일주일에 한 번씩 재현율(recall) 검사를 수행하세요. 실제 쿼리 샘플을 가져와 인덱스를 사용했을 때와 위 CTE를 사용하여 정확하게 실행했을 때의 결과를 비교하고 ID 세트를 비교하는 것입니다. 만약 필터링된 쿼리의 중복률이 필터링되지 않은 쿼리의 중복률보다 현저히 낮다면, 바로 이 문제를 발견한 것입니다.
여전히 저를 당황하게 하는 부분은 그 과정이 너무 조용했다는 점입니다. 오류도 없고, 타임아웃도 없으며, 느린 쿼리 경고도 없었습니다. LLM은 빈 컨텍스트를 받았고 최선을 다해 고객에게 환불 페이지가 존재하지 않는다고 정중하게 알려주었습니다.
그렇다면 왜 pgvector 쿼리는 0행을 반환했을까요?
pgvector HNSW 필터링은 후처리(post-filtering) 방식입니다. 즉, 인덱스는 전체 테이블에서 hnsw.ef_search에 해당하는 가장 가까운 이웃(기본값 40개)을 먼저 반환하고, 그 후에 Postgres가 사용자의 WHERE 절을 적용합니다. 필터가 테이블의 작은 일부—예를 들어 행의 2%만을 가진 단일 테넌트—와 일치할 경우, 그 40개의 후보들 대부분 또는 전부가 폐기되어, LIMIT 10 쿼리가 오류 없이 0개에서 3개의 행을 반환할 수 있습니다. 이 문제를 해결하려면 pgvector 0.8.0 이상에서 hnsw.iterative_scan을 활성화하거나, 선택적 필터에 자체적인 부분 인덱스(partial index) 또는 파티션(partition)을 제공하거나, 작은 필터링된 세트를 정확한 스캔(exact scan)으로 라우팅해야 합니다. 또한, LIMIT보다 적은 행을 반환하는 모든 쿼리를 기록하여 다음 쿼리가 사용자에게 도달하기 전에 문제를 해결할 수 있도록 해야 합니다.
작성자: 인터뷰 준비 플랫폼인 Preterview의 개발자.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기