RSS
데이터베이스

pgvector HNSW 필터 검색: WHERE를 붙이면 결과가 줄어드는 이유

작성자
듀오랩스 대표·9분 읽기
SELECT id, title
FROM docs
WHERE tenant_id = 42
ORDER BY embedding <=> $1
LIMIT 10;

열 개를 달라고 했고, 테넌트 42의 문서는 수천 개 있습니다. 그런데 이 쿼리는 열 개보다 적게 돌려줄 수 있습니다. 에러도 경고도 없이, 그냥 네 줄쯤 나옵니다. 실행 계획을 열어 보면 HNSW 인덱스를 잘 타고 있어서 더 이상합니다.

pgvector가 고장 난 것이 아닙니다. 설명서에 적힌 그대로 동작한 결과입니다.

인덱스는 속도만 바꾼다는 믿음

PostgreSQL을 오래 써 온 사람일수록 인덱스를 이렇게 이해합니다. 인덱스는 같은 답을 더 빨리 찾는 장치이고, 붙이든 떼든 결과는 같다. B-tree에서는 맞는 말입니다. 그래서 벡터 컬럼에 인덱스를 거는 일도 같은 종류의 성능 작업으로 취급하게 됩니다.

HNSW와 IVFFlat은 이 전제를 따르지 않습니다. pgvector README는 첫 문단에서 이렇게 못 박습니다.

You can add an index to use approximate nearest neighbor search, which trades some recall for speed. Unlike typical indexes, you will see different results for queries after adding an approximate index.

근사 최근접 이웃(ANN) 인덱스는 정확한 답을 일부 포기하고 속도를 삽니다. 인덱스를 붙이는 순간 쿼리의 의미가 바뀝니다. 저는 이 한 문장이 벡터 검색을 운영할 때 가장 먼저 알아야 할 사실이라고 봅니다. 필터 문제는 전부 여기서 나옵니다.

후보 40개 안에서 끝나는 탐색

HNSW 검색은 그래프를 따라가며 질의 벡터에 가까운 후보를 모읍니다. 이때 들고 다니는 후보 목록의 크기가 hnsw.ef_search이고, 기본값은 40입니다. README는 결과 개수의 상한도 이 값이라고 적어 둡니다.

Results are limited by the size of the dynamic candidate list (hnsw.ef_search), which is 40 by default.

여기에 WHERE가 붙으면 순서가 문제가 됩니다. 필터는 인덱스 탐색이 끝난 뒤에 적용됩니다. 가장 가까운 40개를 먼저 뽑고, 그중에서 tenant_id = 42인 것만 남기는 식입니다. README가 직접 숫자를 들어 설명합니다.

If a condition matches 10% of rows, with HNSW and the default hnsw.ef_search of 40, only 4 rows will match on average.

전체의 10%가 테넌트 42라면, 40개 후보 중 평균 네 개만 살아남습니다. LIMIT 10은 상한일 뿐이라 모자란 만큼을 채워 주지 않습니다. 첫머리의 쿼리가 네 줄을 돌려준 이유가 이것입니다. 필터가 좁을수록 더 나빠집니다. 조건이 1%의 행에만 맞으면 평균 0.4개, 즉 빈 결과가 흔해집니다.

ef_search를 올리면 후보가 늘어 더 많이 살아남습니다. 하지만 필터의 선택도는 테넌트마다, 검색어마다 다릅니다. 작은 테넌트에 맞춰 값을 정하면 큰 테넌트는 쓸데없이 느려지고, 큰 테넌트에 맞추면 작은 테넌트는 계속 빈손입니다. 값 하나로 모든 경우를 맞출 방법은 없습니다.

0.8.0의 반복 스캔이 바꾼 것

pgvector 0.8.0부터는 반복 인덱스 스캔(iterative index scan)이 생겼습니다. 필터를 통과한 결과가 모자라면 인덱스를 더 훑어서 채우는 기능입니다.

SET hnsw.iterative_scan = strict_order;   -- 또는 relaxed_order

두 모드의 차이는 정렬 보장입니다. strict_order는 결과가 거리 순서를 정확히 지킵니다. relaxed_order는 순서가 조금 어긋날 수 있는 대신 재현율이 더 좋습니다. 순서가 중요하면 relaxed_order로 넉넉히 찾은 뒤 다시 정렬하면 됩니다. README의 예시가 이 형태입니다.

WITH relaxed_results AS MATERIALIZED (
    SELECT id, embedding <-> '[1,2,3]' AS distance
    FROM items WHERE category_id = 123
    ORDER BY distance LIMIT 5
) SELECT * FROM relaxed_results ORDER BY distance + 0;

끝의 + 0은 장식이 아닙니다. README는 PostgreSQL 17 이상에서 이것이 필요하다고만 적고 이유는 설명하지 않습니다. 저는 이런 줄을 빼고 옮겼다가 버전을 올린 날 조용히 깨지는 쪽이 더 무섭다고 봅니다. 그대로 두는 편이 낫습니다.

반복 스캔에도 끝은 있습니다. hnsw.max_scan_tuples(기본 20,000)에 닿으면 덜 채운 채로 멈춥니다. 문서는 이 값이 대략적인 기준이고 첫 스캔에는 적용되지 않는다고 덧붙입니다. 그러니 반복 스캔을 켰다고 해서 "항상 LIMIT만큼 나온다"가 보장되지는 않습니다. 아주 좁은 필터에서는 여전히 모자랄 수 있습니다. 메모리 상한은 hnsw.scan_mem_multiplier이고 work_mem의 배수로 정합니다(기본 1).

필터 모양에 따라 갈리는 처방

README의 필터링 절은 반복 스캔 하나만 권하지 않습니다. 필터가 어떤 모양이냐에 따라 처방이 다릅니다. 제가 읽은 대로 정리하면 이렇습니다.

필터의 모양 처방 이유
걸러진 행이 적음 필터 컬럼에 일반 인덱스 (CREATE INDEX ON docs (tenant_id)) 걸러진 행 전체와 거리를 재므로 근사가 아닌 정확한 답
여러 컬럼으로 거름 다중 컬럼 인덱스 위와 같은 원리
걸러진 행이 많음 반복 스캔 근사 인덱스를 쓰면서 모자란 결과를 보충
고유값이 몇 개뿐 부분 인덱스 (WHERE category_id = 123) 값마다 따로 만든 그래프, 필터가 탐색 안으로
고유값이 많음 파티셔닝 파티션마다 생기는 인덱스, 부분 인덱스와 같은 효과

첫 줄이 의외로 자주 놓치는 답입니다. 벡터 검색이니까 벡터 인덱스를 써야 한다고 생각하기 쉽지만, 테넌트 하나의 문서가 수천 개라면 그 수천 개와 거리를 전부 재는 편이 빠르고 정확할 수 있습니다. 근사할 필요가 없는 규모에서 근사를 쓰고 있는 셈입니다.

부분 인덱스와 파티셔닝은 결국 같은 생각입니다. 필터를 탐색이 끝난 뒤에 거는 대신, 처음부터 그 필터에 맞는 행만 담긴 그래프를 따로 만드는 것입니다. 그래프 안에서는 필터를 다시 할 필요가 없으니 40개 후보가 전부 살아남습니다.

멀티테넌트 RAG에서 먼저 의심할 곳

RAG를 여러 고객에게 서비스하면 tenant_id 필터는 빠질 수 없습니다. 그리고 테넌트의 크기는 거의 항상 고르지 않습니다. 저라면 검색 품질이 이상하다는 보고를 받았을 때 임베딩 모델이나 청크 크기보다 이 필터를 먼저 보겠습니다. 결과가 틀린 것이 아니라 모자란 것이라면 원인은 대개 여기입니다.

확인하는 방법도 단순합니다. 같은 쿼리를 SET enable_indexscan = off로 한 번 돌려 보고, 개수와 순서를 인덱스를 탄 결과와 비교하면 됩니다. 인덱스 없이 나온 쪽이 정답입니다. 둘이 다르면 근사가 결과를 바꾸고 있다는 뜻입니다.

검색 단계의 결과가 모자라면 그 뒤의 재순위나 생성 단계가 아무리 좋아도 소용이 없습니다. 모델은 받은 문서 안에서만 답합니다. 긴 컨텍스트로 검색 자체를 줄이는 선택지는 RAG와 긴 컨텍스트에서 따로 다뤘습니다.

여기서부터는 데이터마다 다른 부분

플래너가 HNSW 인덱스와 일반 인덱스 중 무엇을 고를지는 통계에 달려 있습니다. 일반 인덱스를 만들어 두었다고 해서 플래너가 반드시 그쪽을 쓰지는 않습니다. 실제로 어느 쪽을 탔는지는 EXPLAIN ANALYZE로 직접 봐야 합니다.

재현율이 몇 퍼센트 떨어지는지, ef_search를 얼마로 올려야 하는지도 데이터의 분포와 차원 수에 따라 달라서 일반적인 숫자를 드릴 수 없습니다. README가 숫자로 말하는 것은 위의 10%와 4개 예시뿐이고, 그것도 평균입니다. 제 데이터에서 어떤지는 인덱스를 끈 정답과 비교해 보는 것 말고는 알 방법이 없습니다.

참고: pgvector README의 Filtering 절, Iterative Index Scans 절

마지막 수정:

공유하실 때는 출처(Duolabs)와 원문 주소를 표시해 주세요.