
필터링된 벡터 검색 (Filtered Vector Search): 모든 벤치마크가 당신에게 조용히 거짓말을 하는 이유
요약
벡터 데이터베이스 벤치마크가 간과하는 '필터링된 벡터 검색'의 성능 저하 문제를 다룹니다. 단순 ANN 검색과 달리 메타데이터 필터링이 결합될 때 발생하는 지연 시간 증가와 재현율 하락의 기술적 원인을 분석합니다.
핵심 포인트
- 필터링 적용 시 HNSW 그래프의 연결성 결여로 인해 검색 성능이 급격히 저하됨
- 기존 벤치마크는 필터 없는 쿼리만을 측정하여 실제 운영 환경의 성능을 왜곡함
- 필터링이 검색 속도를 오히려 높일 수 있는 최신 기술적 접근법 존재
모든 벡터 데이터베이스 (Vector Database) 벤치마크가 수행하는 마술 하나를 보여드리겠습니다.
1단계: 필터 없이 쿼리를 실행합니다. 그저 "이 벡터에 가장 가까운 이웃을 찾아줘"라고 요청하는 것이죠. 2단계: 화려한 QPS (Queries Per Second) 수치와 95%의 재현율 (Recall)을 보고합니다. 3단계: 모두가 박수를 치는 동안 절을 합니다.
하지만 실제 운영 환경에서 당신이 실행하는 쿼리는 다음과 같습니다:
<embedding>에 대한 최근접 이웃 (nearest neighbors)
WHERE tenant_id = 'acme'
AND status = 'active'
...
그리고 바로 여기서 박수가 멈춥니다. 지연 시간 (Latency)은 세 배로 늘어납니다. 재현율 (Recall)은 조용히 낭떠러지로 떨어집니다. 당신은 10개의 결과를 요청했지만 3개만 받게 되고, 사용자가 그저 "검색이 고장 난 것 같아요"라고 티켓을 접수하기 전까지는 아무도 이를 알아차리지 못합니다. 스택 트레이스 (Stack trace)도 없고, 에러도 없습니다. 그저 느낌뿐인데, 그 느낌이 매우 나쁩니다.
이것이 필터링된 벡터 검색 (Filtered Vector Search)이며, 이는 전체 검색 스택 (Retrieval Stack)에서 가장 논의가 부족한 부분입니다. 사람들은 pgvector 대 Pinecone에 대해 세 시간 동안 논쟁하면서도, 검색의 작동 여부를 실제로 결정하는 단 한 가지 요소, 즉 근사 최근접 이웃 (Approximate Nearest Neighbor, ANN) 검색에 WHERE 절을 결합했을 때 어떤 일이 발생하는지에 대해서는 대수롭지 않게 넘겨버립니다.
그러니 이에 대해 이야기해 봅시다. 어떤 데이터베이스를 살 것인가가 아닙니다. 벡터 검색에 필터링을 적용하는 것이 왜 이상할 정도로, 직관에 어긋나게 어려운지에 대한 실제 메커니즘, 이를 수행하는 세 가지 방법, 그중 두 가지가 왜 정반대의 상황에서 실패하는지, 그리고 필터가 실제로 검색 속도를 높여주는 최신 기술에 대해 말입니다. 마지막 부분은 정말 흥미로우니 끝까지 지켜봐 주세요.
왜 당신의 SQL 본능이 당신을 속이는가
간단히 직관을 확인해 봅시다. 일반적인 데이터베이스에서 WHERE status = 'active'를 추가하면 쿼리가 빨라질까요, 느려질까요?
당연히 빨라집니다. status에 인덱스 (Index)가 있으니, 일치하는 행을 찾아 스캔할 양을 줄여주기 때문입니다. 필터링은 일종의 할인과 같습니다. 이 직관은 당신의 커리어 내내 당신을 잘 보필해 왔습니다.
하지만 이제 쓰레기통에 던져버리세요. 여기서는 적용되지 않으며, 그 이유를 이해하는 것이 핵심입니다.
수백만 개의 임베딩 (embeddings)에 대한 벡터 검색 (Vector search)이 빠른 이유는 오직 HNSW (Hierarchical Navigable Small World)와 같은 구조 덕분입니다. 모든 벡터가 하나의 점이고, 각 점이 가장 가까운 이웃들과 연결되어 있는 그래프 (graph)를 상상해 보세요. 검색은 진입점에 내려앉아 탐욕적 (greedily)으로 그래프를 따라 이동하며, 전체 데이터의 아주 작은 일부만을 스치듯 지나가며 쿼리 벡터 (query vector)를 향해 점과 점 사이를 건너갑니다. 그 이동 과정이 바로 마법입니다. 그것이 "1,000만 개의 모든 벡터와 비교"하는 작업을 "아마도 1,200개 정도만 방문"하는 작업으로 바꿔줍니다.
여기에 필터 (filter)를 추가해 봅시다. 갑자기 tenant_id = 'acme'와 일치하지 않는다는 이유로 수많은 점이 접근 금지 구역이 됩니다. 하지만 여기서 문제가 발생합니다. 그래프의 연결 구조는 모든 점이 탐색 가능한 대상이라는 가정하에 구축되었습니다. 금지된 점들을 뽑아내면, 탐색 과정이 의존하는 연결성 (connectivity)에 구멍이 뚫리게 됩니다. 검색에 필요했던 경로가 더 이상 존재하지 않는 노드 (nodes)를 가로질러 지나가야 할 수도 있습니다.
오른쪽을 보세요. 필터가 디딤돌을 삭제해 버렸기 때문에 검색이 가고자 했던 경로가 끊겼습니다. 검색은 예전에 다리가 있었던 강 한쪽 편에 서 있게 되었습니다. 이것이 이 주제 전체의 핵심적인 긴장 상태입니다. 벡터 검색을 빠르게 만드는 인덱스 (index)는 아무도 필터링을 하지 않는다고 가정하며, 모든 필터는 그 가정에 대한 작은 배신입니다.
이를 해결하기 위한 세 가지 방법이 있습니다. 이 방법들은 모두 똑같이 좋은 것은 아니며, 두 가지 명백한 방법은 아주 대조적인 방식으로 실패합니다.
세 가지 전략 요약
앞으로 진행되는 동안 이 그림을 머릿속에 담아두세요. Post-filtering (사후 필터링)은 선택도 (selectivity) 범위의 오른쪽에서 무너지고, Pre-filtering (사전 필터링)은 왼쪽에서 무너집니다. 오직 세 번째 방식만이 실제 쿼리가 존재하는 중간 영역을 커버합니다. 이제 세부 사항을 살펴보겠습니다.
전략 1: Post-filtering (사후 필터링) ("일단 검색하고 나중에 사과하는" 방식)
가장 뻔한 방법입니다. 일반적인 벡터 검색 (vector search)을 실행하여 후보군을 가져온 다음, 조건에 맞지 않는 것들을 던져버리는 것입니다.
# Post-filtering (사후 필터링)
candidates = ann_search(query_vector, k=100) # 필터? 필터가 어디 있죠?
results = [c for c in candidates if c.status == "active"][:10]
단순합니다. 특별한 인덱스 (index) 마법도 필요 없습니다. 필터가 느슨해서 데이터셋의 대부분이 통과하는 경우라면 완전히 괜찮습니다. 만약 행(row)의 80%가 active 상태라면, 상위 100개의 후보군에는 active 상태인 데이터가 충분히 포함될 것입니다. 모두가 행복해지죠.
이제 이것이 어떻게 폭발하는지 지켜보세요. 만약 데이터 포인트의 1%만 조건에 일치한다고 가정해 봅시다. 필터링되지 않은 검색에서 100개의 후보를 가져오면, 평균적으로... 그중 단 하나만이 active 상태일 것입니다. 당신은 10개의 결과를 요청했지만, 1개만 얻었습니다. 실제 가장 가까운 10개의 active 벡터는요? 아예 후보에 들지도 못했습니다. 그것들은 그래프 상에서 더 멀리 떨어져 있었고, 상위 100개를 독점하고 있는 inactive 포인트들의 벽 뒤에서 정중하게 기다리고 있었을 뿐입니다.
땀을 흘리며 당신은 말합니다. "그냥 더 많이 가져오면 되잖아요." "상위 1,000개를 가져오라고요." 물론, 가끔은 그것이 당신을 구해줄 수도 있습니다. 하지만 당신은 이제 10배의 검색 작업을 수행하고 있으며, 지연 시간 (latency)은 상승하고 있습니다. 그리고 정말 까다로운 필터를 상대로는 여전히 추가적인 단계를 거쳐 도박을 하고 있는 것에 불과합니다. 과도한 페칭 (Over-fetching)은 해결책이 아니라, 문제가 당신을 괴롭히지 않도록 지불하는 뇌물이며, 이 뇌물은 필터가 얼마나 선택적인지에 따라 정비례하여 커집니다.
Post-filtering은 필터가 제한적일 때 무너집니다. 필터링이 엄격해질수록, 이 방식은 더 허우적거립니다.
전략 2: Pre-filtering (사전 필터링) ("반대의 실수")
좋습니다, 반대로 해봅시다. 먼저 필터에 일치하는 모든 것을 찾은 다음, 그 서브셋 (subset) 내부에서만 검색을 수행하는 것입니다.
# Pre-filtering (사전 필터링)
allowed_ids = metadata_index.lookup(status="active") # 역색인 (inverted index) 등
results = ann_search(query_vector, k=10, restrict_to=allowed_ids)
제한적인 필터(restrictive filter)의 경우에는 이 방식이 완벽하게 들리며, 작은 서브셋 (subset)에 대해서는 진정으로 최고(chef's kiss)입니다. 만약 필터가 검색 대상을 몇 천 개의 벡터로 좁혀준다면, 해당 범위 내에서 브루트 포스 (brute-force) 검색을 수행하여 정확하고 재현율 (recall)이 높은 결과를 빠르게 얻을 수 있습니다. 이것이 바로 성능이 좋은 엔진들이 "서브셋이 아주 작다면, 복잡한 그래프를 건너뛰고 그냥 스캔하라"는 폴백 (fallback) 메커니즘을 갖추고 있는 정확한 이유입니다. 올바른 판단이죠.
하지만 허용된 집합 (allowed set)의 규모가 커지면 상황은 완전히 뒤바뀝니다. 그래프는 연결성 (connectivity)을 기반으로 작동한다는 점을 기억하세요. 탐색 경로 (walk)를 특정 서브셋으로 제한하면, 검색 도중에 일치하지 않는 모든 점을 강제로 뽑아내는 꼴이 됩니다. 필터가 여전히 수십만 개의 흩어진 포인트와 일치하는 대규모 데이터셋의 경우, 첫 번째 다이어그램에서 보여준 것과 똑같이 그래프가 파편화됩니다. 탐색 (greedy walk) 과정에서 제외된 노드들을 통해 경로를 찾으려 시도하다가 막다른 길에 부딪히게 되고, 결국 쓰레기 값을 반환하거나 매우 느린 상태로 무너져 버립니다. 로그 시간 (logarithmic)의 마법은 사라집니다. 다시 비용이 많이 드는 방식으로 돌아가는 것입니다.
따라서 사전 필터링 (pre-filtering)은 사후 필터링 (post-filtering)의 정확한 거울 이미지입니다. 사후 필터링은 느슨한 필터에는 잘 작동하지만 엄격한 필터에서는 무너집니다. 사전 필터링은 아주 작은 결과 집합에는 잘 작동하지만 큰 결과 집합에서는 무너집니다. 이로 인해 중간에 거대한 협곡 (canyon)이 생기는데, 즉 대규모 컬렉션에서 중간 정도의 선택성을 가진 필터를 사용할 때 두 방식 모두 제대로 작동하지 못하는 구간이 발생합니다. 실제 운영 환경의 거의 모든 쿼리가 어디에 위치할 것 같습니까? 바로 그 협곡입니다. 모두가 그 협곡 속에 살고 있습니다.
사전 필터링은 필터링된 서브셋이 크고 흩어져 있을 때 무너집니다.
전략 3: 필터 인식 검색 (Filter-aware search, 실제로 작동하는 방식)
성숙한 해결책은 필터를 검색 전이나 후에 일어나는 별개의 작업으로 취급하는 것을 멈추고, 탐색 과정 자체에 통합하는 것입니다. 그래프 순회 (graph traversal)는 여전히 일어나지만, 이제 필터가 탐색과 함께 이동하며 방향을 잡습니다. 즉, 검색이 다음 이웃 노드로 어디를 건너뛸지 결정할 때, 필터를 무시하거나 (사후 필터링) 필터에 의해 눈먼 벽에 가로막히는 대신 (사전 필터링), 이미 필터 조건을 고려하여 결정하게 됩니다.
실제 운영 환경에서 조절하게 될 실제 토글(toggles)과 매칭되기 때문에, 알아둘 가치가 있는 두 가지 방식이 있습니다.
사전에 구축된 필터 인식 엣지 (Filter-aware edges, built ahead of time). 한 가지 접근 방식은 필터링할 필드에 따라 그래프에 추가적인 엣지(edges)를 더하는 것입니다. category와 같은 필드의 경우, 인덱스는 각 값에 대해 연결된 서브그래프(subgraphs)를 조용히 구축합니다. 따라서 나중에 category = 'laptop'으로 필터링할 때, 이미 탐색할 수 있는 노트북 전용의 완전 연결된(fully-connected) 그래프가 준비되어 있습니다. 이 작업은 빌드 타임(build time)에 이루어지기 때문에, 런타임 비용(runtime tax)이 거의 없이 엄격한 필터 조건 하에서도 검색 품질이 유지됩니다. 문제는 조합입니다. 인덱스는 category와 brand를 각각 별도로 미리 연결할 수는 있지만, 모든 category AND brand AND price 조합을 미리 연결할 수는 없습니다. 수학적으로 계산량이 폭발하기 때문입니다. 따라서 하나의 엄격한 필터는 매끄럽게 작동하지만, 선택도(selectivity)가 높은 필터를 두세 개 쌓으면 여전히 그래프가 파편화될 수 있습니다. 다만 그 시점이 이전 방식보다 훨씬 늦어질 뿐입니다.
쿼리 타임에 결정되는 적응형 탐색 (Adaptive traversal, decided at query time). 더 새로운 계열(2026년에 ACORN이라는 이름으로 불리게 될 방식)은 "필터를 미리 알고 있어야 한다"는 요구 사항을 건너뜁니다. 비결은 이렇습니다. 탐색(walk) 중에 특정 노드의 직접적인 이웃들이 모두 필터링되어 제외된다면, 탐색을 중단하는 대신 그 이웃들의 이웃을 살짝 들여다보는 것입니다. 즉, 2-홉 점프(two-hop jump)를 하는 것입니다. 이를 통해 검색은 필터링되어 제외된 영역을 뛰어넘을 수 있으며, 학습되지 않은 필터 조합에 대해서도 연결성을 유지할 수 있습니다. 솔직한 대가는 더 많은 노드를 탐색해야 하므로 쿼리당 시간이 더 많이 걸린다는 점입니다. 따라서 모든 조회에 사용하는 것이 아니라, 정말 까다로운 케이스(복잡하고 예측 불가능하며 선택도가 낮은 필터)를 위해 이 기능을 켭니다. 실제로 이는 불도저가 아니라 정밀한 메스(scalpel)와 같기 때문에, 정확히 쿼리당 플래그(per-query flag) 형태로 나타납니다.
이것이 그 간극(canyon)을 메울 수 있는 유일한 전략입니다. 그리고 이는 마치 부정행위를 하는 것처럼 느껴질 정도의 보상을 제공합니다.
좋은 점: 검색을 더 빠르게 만드는 필터
단순한 (naive) 전략들을 사용할 때, 필터링은 순전한 세금(tax)입니다. 더 많은 작업, 더 높은 지연 시간 (latency), 그리고 더 많은 한숨을 유발할 뿐입니다. 하지만 진정한 필터 인식 (filter-aware) 검색을 사용하면 정반대의 상황이 발생할 수 있습니다. 필터가 검색 대상이 되는 풀 (pool)을 실질적으로 축소하고, 엔진이 탐색이 끝난 후가 아니라 탐색 과정 _도중 (during)_에 해당 작업을 가지치기 (prune) 한다면, 필터를 추가하는 것이 필터가 없는 쿼리보다 지연 시간 (latency)을 더 낮출 수 있습니다.
아래로 내려가는 저 초록색 선을 보십시오. 통합 엔진 (integrated engines)에서 보고된 동작은 이 형태를 따릅니다. 필터가 없는 쿼리가 특정 기준선 (baseline)에 위치할 때, 50% 선택도 (selective)를 가진 필터는 지연 시간 (latency)을 낮추고, 1% 선택도 필터는 지연 시간을 더욱 낮춥니다. 검색 엔진이 수행해야 할 작업 자체가 단순히 줄어들기 때문입니다. 저 하향 곡선은 진정한 필터 인식 (filter-aware) 구현의 증거입니다. 또한 엔진을 테스트할 때 아주 훌륭한 진단 도구가 되기도 합니다.
워크로드를 필터링된 상태와 필터링되지 않은 상태로 각각 실행해 보십시오. 지연 시간 (latency)이 어느 방향으로 움직이는지 관찰하십시오. 만약 데이터를 필터링했을 때 속도가 더 빨라진다면, 해당 엔진은 알고리즘 내부에서 필터링을 수행하고 있는 것이며, 그것이 바로 제대로 된 방식입니다. 만약 필터링이 일관되게 속도를 느리게 만든다면, 엔진은 당신 몰래 전처리 (pre-filtering) 또는 후처리 (post-filtering)를 수행하고 있는 것이므로, 그에 맞춰 계획을 세워야 합니다.
퀵스타트 가이드에는 아무도 적어두지 않는 주의사항 (The gotchas)
전략을 이해하는 것이 싸움의 절반입니다. 나머지 절반은 필터링된 검색을 소리 없이 망가뜨리지만
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기
