
pgvector의 필터링된 벡터 검색(Filtered vector search)이 조용히 재현율(Recall)을 손실할 수 있는 이유
요약
pgvector에서 WHERE 절을 사용한 필터링된 벡터 검색 시 재현율(Recall)이 저하되는 원인을 분석합니다. HNSW 인덱스가 필터링 전 후보군을 먼저 추출하기 때문에 발생하는 문제와 이를 측정하는 방법을 다룹니다.
핵심 포인트
- pgvector는 인덱스 검색 후 WHERE 필터를 적용하여 재현율 손실 가능성이 있음
- HNSW의 검색 프런티어 내에 필터 조건에 맞는 데이터가 적으면 결과가 누락됨
- PostgreSQL 플래너는 비용 모델에 재현율을 포함하지 않아 경고를 주지 않음
- 특정 테넌트 데이터가 임베딩 공간상 특정 영역에 몰려 있을 때 문제가 심화됨
WHERE tenant_id = 42 ORDER BY embedding <-> $query LIMIT 10이 왜 생각보다 더 적은 수의 정확한 이웃을 반환할 수 있는지, 그리고 이러한 현상이 언제 발생하는지 확인하는 방법.
거의 모든 RAG 앱은 결국 다음과 같은 쿼리를 작성하게 됩니다:
SELECT id, chunk
FROM documents
WHERE tenant_id = 42
...
이 쿼리 벡터에 가장 가까운 10개의 청크(chunk)를 가져오되, tenant 42에 해당하는 것만 가져오라는 요청입니다. 겉보기에는 무해해 보입니다. Postgres는 행(rows)을 반환하고, 앱은 다음 단계로 넘어갑니다.
문제는 이것입니다: 해당 행들이 필터링된 이웃 중 가장 가까운 10개가 아닐 수도 있다는 점입니다. 때로는 10개의 행조차 반환되지 않을 수도 있습니다. 그리고 에러도, 경고도, 쿼리 실행 계획(query plan)도 재현율(recall)이 무너졌다는 사실을 알려주지 않습니다.
저는 지난 몇 주 동안 이 현상이 정확히 언제 발생하는지 측정했으며, 이를 포착하기 위한 작은 도구를 만들었습니다. 제가 발견한 내용은 다음과 같습니다.
실제로 실행되는 방식
Postgres가 ORDER BY embedding <-> $query LIMIT k에 대해 HNSW 인덱스 스캔(index scan)을 선택할 때, pgvector는 벡터 인덱스를 먼저 검색한 다음 WHERE 절을 나중에 적용합니다. 이 순서는 생각보다 훨씬 중요합니다.
HNSW는 근사 인덱스(approximate index)입니다. 이는 그래프를 탐색하며 필터링되지 않은 검색 프런티어(search frontier)를 유지하는데, 기본값은 hnsw.ef_search = 40입니다. 이 프런티어는 SQL 필터가 적용되기 전에 구축됩니다. 그 후 Postgres는 tenant_id = 42와 일치하지 않는 후보들을 버립니다.
따라서 진짜 질문은 "전체 테이블에서 tenant 42가 얼마나 드문가?"가 아닙니다. "이 쿼리 벡터 근처의 후보들 중 tenant 42에 속하는 것이 얼마나 되는가?"입니다. 만약 해당 프런티어에서 살아남는 일치하는 행이 너무 적다면, 고정된 프런티어 스캔(fixed-frontier scan)으로부터 10개의 좋은 필터링된 이웃을 가져오는 것이 불가능해집니다. pgvector는 필터를 통과해 살아남은 것들만 반환하며, 재현율(recall)은 조용히 떨어집니다.
왜 플래너(planner)는 경고를 주지 않을까요? PostgreSQL의 플래너는 실행 작업의 비용(costing execution work)을 계산할 뿐, 의미론적 재현율(semantic recall)을 계산하지 않기 때문입니다. 재현율은 비용 모델(cost model)의 일부가 아니기 때문에, 플래너는 너무 적은 수의 정확한 필터링된 이웃을 반환하더라도 비용이 저렴해 보이는 계획을 선호할 수 있습니다.
100만 개의 행에서 측정했습니다
이를 구체화하기 위해, Postgres 17 / pgvector 0.8.4 환경에서 100만 개의 실제 SIFT 벡터를 대상으로 벤치마크를 실행했습니다. 필터는 전체 테이블의 5%와 일치하지만, 각 쿼리에 대해 정확한 상위 40개 근접 이웃(top-40 nearest neighbors) 중 필터를 통과하는 것이 하나도 없도록 배치되었습니다. 이것이 바로 적대적 사례(adversarial case)입니다. 즉, 전체적으로는 흔하지만 정작 검색하려는 지점에는 존재하지 않는 필터입니다. (만약 쿼리가 위치한 임베딩 공간(embedding space)의 다른 영역에 문서가 존재하는 테넌트(tenant)를 경험해 본 적이 있다면, 이미 이를 마주하신 것입니다.)
동일한 쿼리를 네 가지 방식으로 실행한 결과입니다:

| 전략 (Strategy) | recall@10 | 10개 행을 모두 반환했는가? |
|---|---|---|
| 사후 필터링 (post-filter, 기본값) | 0.34 | 쿼리의 25%만 해당 |
| ... | ... | ... |
기본 계획(default plan)은 올바른 결과의 약 3분의 1만을 반환했으며, 4개의 쿼리 중 3개에서는 일치하는 행을 10개조차 찾지 못했습니다. 모든 대안 방식이 품질을 개선했습니다. 정확한 필터링된 스캔(exact filtered scan)과 pgvector의 반복 스캔(iterative scan, 0.8.0에서 추가됨)은 모두 완전한 재현율(recall)을 회복했습니다. 또한 이 필터를 위해 구축된 부분적 HNSW 인덱스는 0.86의 재현율을 유지하면서도 네 가지 방식 중 실제로는 가장 빨랐습니다.
올바른 계획들은 안전을 위해 지불해야 하는 무거운 세금이 아니었습니다. 단지 기본값이 여기서는 잘못된 선택이었을 뿐이며, 이를 알 수 있는 정보는 바로 그곳에 있었습니다. 플래너(planner)가 단지 그 정보를 사용하지 않을 뿐입니다.
분명히 말씀드리자면, 이는 실패를 눈에 보이게 만들기 위해 의도적으로 선택된 어려운 사례입니다. 쿼리 근처에 밀집된 친화적인 필터의 경우, 사후 필터링(post-filtering)은 빠르고 완벽하게 작동합니다. 그리고 이것이 핵심입니다: 기본값이 훌륭할지 끔찍할지는 Postgres가 전혀 살펴보지 않는 요소에 달려 있습니다.
전체 선택도(Global selectivity)는 잘못된 수치입니다
제가 로컬(local) (또는 프론티어(frontier)) 선택도(selectivity)라고 부르게 된 요소, 즉 전체 테이블의 비율이 아니라 쿼리의 실제 근접 이웃(near-neighbors) 중 필터를 통과하는 비율을 Postgres는 전혀 살펴보지 않습니다.
이 두 수치는 매우 크게 다를 수 있습니다. 특정 테넌트(tenant)는 전체 행의 0.1%에 불과할 수 있지만, 해당 테넌트의 문서들이 서로 클러스터링(clustering)되어 있기 때문에 그들의 쿼리에 대해서는 이웃의 100%를 차지할 수 있습니다. 반대로, 다른 곳에 위치한 쿼리에 대해서는 전체 행의 5%를 차지하면서도 이웃은 0%일 수도 있습니다. Postgres가 추정하는 수치인 전체 선택도(Global selectivity)는 당신이 어떤 경우에 처해 있는지에 대해 거의 아무것도 알려주지 않습니다. 사후 필터링(post-filtering)이 유효할지 여부를 결정하는 것은 필터와 벡터 공간(vector space) 사이의 _상관관계(correlation)_이며, 이것이 바로 비용 모델(cost model)이 놓치고 있는 신호입니다.
이 관점으로 바라보면, 해결책은 "사후 필터링은 나쁘다"가 아닙니다. "올바른 전략은 로컬 선택도(local selectivity)에 달려 있으므로, 누군가가 실제로 이를 측정해야 한다"가 됩니다.
VecAdvisor
그래서 저는 측정을 수행하는 작은 읽기 전용 CLI인 VecAdvisor를 만들었습니다.
데이터베이스와 쿼리를 지정하면 VecAdvisor는 다음을 수행합니다:
- 테이블 통계(table statistics)를 읽고 Postgres 자체의 전체 선택도(global selectivity) 추정치를 재현합니다.
- 저렴한 프로브(probe)를 한 번 실행합니다. 즉, 쿼리의 근접 이웃 프론티어(nearest-neighbor frontier)에 대해 단일
count(*) FILTER (...)를 수행하여 쿼리가 실제로 도달하는 지점의 로컬 선택도를 측정합니다. - 보정된 모델(calibrated model)을 사용하여 모든 전략(정확한 검색(exact), 사후 필터링(post-filter), 반복적(iterative), 부분 인덱스(partial index), 파티셔닝(partitioning))의 비용을 계산합니다.
- 어떤 전략이 재현율(recall) 측면에서 안전한지, 그리고 그 이유가 무엇인지 알려줍니다.
이 도구는 플래너(planner)나 데이터에 전혀 손을 대지 않습니다. 패치(patch)가 아니라 어드바이저(advisor)입니다. 실행 결과는 다음과 같이 나타납니다:
EXPLAIN VECTOR documents (1M rows, HNSW)
filter: tenant_id = 42
s(global) = 0.05 s(local) = 0.00 <- 이 쿼리 근처에서 필터가 희소함(sparse)
...
현재 상태에 대해 솔직하게 말씀드리고 싶습니다. 제가 정밀하게 조사한 결과, VecAdvisor는 기본 포스트 필터(post-filter)가 실패한 모든 사례(9개 중 9개 모두)에서 재현율(Recall) 목표를 충족하는 계획을 권장했습니다. 그중 4개 사례에서는 정확히 가장 빠른 안전한 계획을 선택했으며, 나머지 사례에서는 절대적인 최적값보다는 약간 느리지만 안전한 계획을 선택했습니다.
"절대로 조용히 재현율을 손실하지 않는다"는 이 첫 번째 버전의 약속이며, "항상 지연 시간(latency)에 최적화된 계획을 선택한다"는 제가 여전히 진행 중인 보정(calibration) 작업입니다. 저는 완벽한 척하기보다는, 솔직하게 항상 안전하며 대개는 빠른 결과물을 출시하고 싶습니다.
사용해 보기
pip install vecadvisor
이 프로젝트는 알파(alpha) 단계이며, Apache-2.0 라이선스를 따르고, pgvector 프로젝트와 관련이 없습니다. 단지 pgvector와 함께 작동할 뿐입니다. 리포지토리에는 전체 벤치마크(새로운 체크아웃으로부터 재현 가능), 비용 모델(cost model), 그리고 설계 노트가 포함되어 있습니다.
Postgres에서 필터링된 벡터 검색(filtered vector search)을 실행하고 계신다면, 이것이 귀하의 워크로드에서 실제로 문제를 잡아내는지 진심으로 알고 싶습니다. Star와 Issue 모두 도움이 되며, 특히 "내 경우에는 이것이 틀렸다"라는 솔직한 보고가 가장 큰 도움이 됩니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기