
벡터 검색 전 메타데이터 필터링: 아무도 측정하지 않는 재현율(Recall)의 승리
요약
RAG 시스템에서 벡터 검색 전 메타데이터 필터링을 통해 재현율(Recall)을 높이는 방법을 설명합니다. 단순한 액세스 제어를 넘어, 검색 범위를 사전에 제한함으로써 유사한 상용구로 인한 검색 오류를 방지하고 정확도를 개선할 수 있습니다.
핵심 포인트
- 메타데이터 사전 필터링은 재현율을 높이는 가장 저렴한 방법 중 하나임
- 필터링 없이 검색 시 유사한 상용구가 검색 결과 상위권을 차지할 위험이 있음
- 검색 공간을 특정 메타데이터로 축소하여 관련 청크의 순위 경쟁력을 확보함
- 단순한 보안 수단을 넘어 검색 품질 최적화 도구로 활용해야 함
- 도서: RAG Pocket Guide: Retrieval, Chunking, and Reranking Patterns for Production
- 저자의 다른 저서: Thinking in Go (2권 시리즈) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- 내 프로젝트: Hermes IDE | GitHub — Claude Code 및 기타 AI 코딩 도구를 사용하여 작업하는 개발자들을 위한 IDE
- 나에 대하여: xgabriel.com | GitHub
한 고객 지원 상담사가 봇에게 고객의 기업 계약에 관한 질문을 합니다. 봇은 전체 코퍼스(Corpus)에서 코사인 유사도(Cosine Similarity)를 기준으로 상위 10개의 청크(Chunk)를 검색합니다. 그중 두 개는 우연히 거의 동일한 상용구(Boilerplate) 언어를 사용하는 다른 고객의 계약서에서 가져온 것이었습니다. 모델은 고객의 실제 계약서에는 존재하지 않는 조항을 인용하며 자신 있게 답변을 작성합니다.
이것은 일반적인 의미의 환각(Hallucination)이 아닙니다. 검색은 설계된 대로 작동했습니다. 벡터 인덱스(Vector Index)는 의미론적으로 가장 유사한 청크를 찾았고, 상용구는 정의상 고객 간에 의미론적으로 유사합니다. 해결책은 더 나은 임베딩 모델(Embedding Model)을 사용하는 것이 아닙니다. 해결책은 인덱스에 처음부터 다른 고객의 문서를 절대 보지 말라고 명령하는 것입니다.
그것이 바로 메타데이터 필터링(Metadata Filtering)입니다. 대부분의 팀은 이를 액세스 제어(Access-control)를 위한 체크박스 정도로 취급합니다. 하지만 이는 파이프라인에서 가장 저렴하게 재현율(Recall)을 높일 수 있는 방법 중 하나이며, 거의 아무도 이를 측정하지 않습니다.
사전 필터링(Pre-filtering)이 실제로 하는 일
여러분의 벡터 스토어(Vector Store)는 청크를 보유하고 있습니다. 각 청크는 customer_id, doc_type, language, indexed_at, team과 같은 메타데이터를 포함합니다. 사전 필터링(Pre-filtering)이란 벡터 검색이 무엇인가를 순위 매기기 전에 해당 메타데이터에 대해 엄격한 서술어(Predicate)를 적용하는 것을 의미합니다.
필터가 없다면, "연체료 (late payment penalty)"에 대한 쿼리는 200만 개의 청크 (chunk) 전체를 검색하여 임베딩 공간 (embedding space)에서 가장 가까운 10개를 반환합니다. 필터가 있다면, customer_id = 4417인 청크들만 검색한 다음 그것들을 순위 매깁니다. 검색 공간이 200만 개에서 아마도 800개 정도로 줄어듭니다. 이제 상위 10개 결과 모두가 올바른 문서 집합에서 나옵니다.
여기서는 재현율 (Recall)의 관점이 중요합니다. 재현율 (Recall)은 당신이 검색해내는 관련 청크 (chunk)의 비율입니다. 코퍼스 (corpus)가 거의 중복된 상용구 (boilerplate)로 가득 차 있을 때, 관련 없지만 유사한 청크들이 상위-k (top-k) 내의 관련 청크들을 밀어냅니다. 검색 공간을 관련이 있을 가능성이 있는 문서들로 축소하면, 관련 청크들이 유사한 것들과 경쟁하는 일이 멈춥니다. 상위-k (top-k)는 실제로 답변할 가능성이 있는 청크들로 채워집니다.
# Qdrant: 한 번의 호출로 필터링 후 검색.
from qdrant_client import QdrantClient
from qdrant_client.models import (
...
쿼리 벡터 (query vector)는 동일합니다. 유일한 변화는 서술어 (predicate)입니다. 코퍼스 (corpus)에 자연적인 파티션 (partition)이 존재하는 경우, 결과 품질의 변화는 미미하지 않습니다.
프리 필터 (Pre-filter) 대 포스트 필터 (Post-filter) (문제가 발생하는 지점)
서술어 (predicate)를 적용하는 곳은 두 군데가 있으며, 이 둘은 동일하지 않습니다.
포스트 필터 (Post-filter): 전체 코퍼스 (corpus)에 대해 벡터 검색을 수행하여 상위-k (top-k)를 가져온 다음, 서술어 (predicate)를 통과하지 못하는 청크들을 버립니다. 문제점: 만약 10개를 요청했는데 상위 10개 중 9개가 다른 고객의 것이라면, 당신은 단 하나의 청크만 남기게 됩니다. 당신은 10개를 원했지만, 1개만 얻었습니다. 관련 있는 청크들은 40번, 55번, 71번 위치에 순위가 매겨져 후보 집합 (candidate set)에 포함되지 못했습니다.
프리 필터 (Pre-filter): 후보 집합 (candidate set)을 서술어 (predicate)를 통과하는 청크들로 제한한 다음, 그 안에서 순위를 매깁니다. 당신은 항상 올바른 파티션 (partition)에서 최대 10개의 결과를 얻습니다.
포스트 필터링 (Post-filtering)은 조용히 재현율 (recall)을 고갈시킵니다. 테스트 쿼리가 우연히 지배적인 파티션 (partition)과 일치하는 데모에서는 작동하는 것처럼 보일 수 있습니다. 하지만 관련 파티션 (partition)이 코퍼스 (corpus)의 아주 작은 부분인 쿼리에서는 무너집니다. 그리고 바로 그 경우가 멀티 테넌트 (multi-tenant) 시스템에서 중요한 상황입니다.
대부분의 관리형 벡터 저장소(managed vector stores)는 검색 호출 시 서술어(predicate)를 전달하면 사전 필터링(pre-filtering)을 수행하며, 애플리케이션 코드에서 직접 결과 리스트를 필터링하면 사후 필터링(post-filtering)을 수행합니다. 사용 중인 저장소의 문서를 읽어 API가 어떤 방식을 제공하는지 확인하세요. Pinecone, Qdrant, Weaviate, 그리고 적절한 인덱스를 갖춘 pgvector는 모두 사전 필터링(pre-filtering)을 지원합니다. 함정은 사후에 Python에서 필터링을 수행하면서 그것이 동일한 것이라고 가정하는 것입니다.
카디널리티 함정 (The cardinality trap)
사전 필터링(Pre-filtering)은 공짜가 아니며, 그 비용은 서술어(predicate)가 얼마나 선택적인지(selective)에 따라 달라집니다.
HNSW와 같은 근사 최근접 이웃 (Approximate nearest-neighbor, ANN) 인덱스는 그래프 구조입니다. 검색은 진입점(entry points)에서 쿼리 벡터를 향해 그래프를 따라 이동합니다. 필터는 이 이동 과정 중에 노드들을 가지치기(prune)합니다. 필터가 그래프의 대부분을 유지할 때는 이동이 정상적으로 작동합니다. 하지만 필터가 아주 작은 부분만을 남길 경우, 이동 경로는 막다른 길(dead ends)에 부딪힙니다. 필터를 통과하는 도달 가능한 노드들이 희소(sparse)해지면 그래프 탐색(graph traversal)이 정체되며, 이로 인해 재현율(recall)이 저하되거나 엔진이 일치하는 세트에 대해 느린 전수 조사(brute-force scan) 방식으로 전환됩니다.
이것이 카디널리티 함정(cardinality trap)이며, 이는 양날의 검과 같습니다.
- 높은 카디널리티 필드, 낮은 선택성 (
language = "en"이지만 코퍼스(corpus)의 95%가 영어인 경우): 필터가 검색 공간을 거의 줄이지 못합니다. 필터 비용은 지불하면서 얻는 이득은 거의 없습니다. - 높은 카디널리티 필드, 높은 선택성 (
customer_id = 4417이지만 각 고객이 코퍼스의 0.04%를 차지하는 경우): 일치하는 세트가 매우 작고 HNSW 그래프 전체에 흩어져 있습니다. 그래프 탐색 성능이 저하됩니다. 일부 엔진은 자동으로 전수 조사(brute force)로 전환하는데, 이는 청크(chunk)가 800개일 때는 괜찮지만 80,000개일 때는 문제가 됩니다.
선택성(selectivity)의 최적 지점은 코퍼스(corpus)의 대부분을 제거하면서도, 인덱스(index)가 탐색하기에 충분히 크고 필터링할 가치가 있을 만큼 충분히 작은 매칭 세트를 남기는 술어(predicate)입니다. 매우 선택적인 테넌트(tenant) 필터의 경우, 페이로드 인덱스(payload index, Qdrant), 파티션된 컬렉션(partitioned collection), 또는 메타데이터 인식 인덱스 설정(metadata-aware index config)이 필터링된 검색 속도를 유지해 주는 핵심입니다.
# Qdrant: 필터링하는 필드에 인덱스를 생성하세요.
# 그렇지 않으면 대규모 환경에서 필터링된 검색이 스캔(scan) 방식으로 저하됩니다.
from qdrant_client.models import PayloadSchemaType
...
해당 인덱스가 없다면, 대규모 컬렉션에서의 customer_id 필터는 엔진이 매칭되는 항목을 찾기 위해 모든 페이로드(payload)를 스캔하게 만듭니다. 인덱스가 있다면, 엔진은 매칭 세트를 사전에 파악하고 이를 바탕으로 검색을 계획합니다. 200만 개의 청크(chunk) 기준, 그 차이는 수십 밀리초(ms) 대 수 초(s)에 달합니다.
재현율(Recall) 향상 측정하기
이 이점은 실재하지만, 데이터가 얼마나 파티션(partitioned)되어 있는지에 따라 완전히 달라지므로 반드시 자신의 코퍼스에서 직접 측정해야 합니다. 단일 테넌트(single-tenant) 지식 베이스는 향상이 거의 없는 반면, 멀티 테넌트(multi-tenant) 계약 저장소는 큰 향상을 보입니다.
골드 세트(gold set)를 구축하세요. 즉, 실제로 정답을 제공하는 청크 ID와 쌍을 이룬 실제 쿼리들을 수동으로 라벨링하여 준비합니다. 그런 다음 동일한 쿼리를 필터가 있을 때와 없을 때 두 번 실행하고, 각각에 대해 recall@k를 계산합니다.
def recall_at_k(retrieved_ids, gold_ids, k=10):
top = set(retrieved_ids[:k])
hits = top & set(gold_ids)
...
필터를 배포하기 전에 이를 실행하십시오. 만약 향상 폭이 작다면, 코퍼스가 필터링의 의미가 있을 만큼 충분히 파티션되어 있지 않은 것이므로 재순위화(reranking)에 노력을 기울여야 합니다. 만약 향상 폭이 크다면, 술어(predicate) 하나와 페이로드 인덱스만으로 재현율(recall)을 높이는 승리를 거둔 것입니다. 어느 쪽이든, 이제 당신은 수치를 갖게 되었습니다. 이는 멀티 테넌트 코퍼스를 대상으로 검색을 수행하는 대부분의 팀이 할 수 있는 것보다 훨씬 진보된 상태입니다.
선택성을 해치지 않고 필터 조합하기
실제 쿼리는 하나 이상의 제약 조건을 포함합니다. 에이전트는 특정 고객을 위한 계약서의 최신 영어 버전을 원합니다. 이는 customer_id, language, 그리고 최근의 indexed_at이라는 세 가지 술어(predicate)를 의미합니다.
from qdrant_client.models import Range
def search_scoped(query_vec, customer_id, k=10):
...
엔진이 내부적으로 순서를 재조정하더라도, 머릿속으로는 술어(predicate)를 선택도(selectivity)에 따라 정렬해 두십시오. 가장 선택도가 높은 필드(customer_id)가 핵심적인 역할을 수행하며, 나머지 필드들은 남은 범위를 좁히는 역할을 합니다. 높은 선택도를 가진 필드 위에 language와 같이 선택도가 낮은 술어를 추가하는 것은 비용이 적게 듭니다. 하지만 다섯 개의 낮은 선택도를 가진 술어를 쌓아놓고 그것이 하나의 좋은 높은 선택도 술어를 대체할 수 있기를 기대하는 것은, 결국 지연 시간(latency)만 발생시키고 동일하게 노이즈가 섞인 후보 집합을 반환하는 필터를 만드는 지름길입니다.
메타데이터 스키마(metadata schema)는 초기에 제대로 설정해야 하는 요소입니다. 데이터 수집(ingestion) 단계에서 저장하지 않은 필드로는 필터링을 할 수 없습니다. customer_id, doc_type, language, status, 그리고 indexed_at을 청킹(chunking) 레이어를 거치는 모든 청크의 페이로드(payload)로 포함시키십시오. 이미 인덱싱된 코퍼스(corpus)에 메타데이터를 사후에 추가하는 것은 전체 재인덱싱(re-index)을 의미하며, 이는 이 기술 전체가 피하고자 하는 비용이 많이 드는 작업입니다.
아무도 쓰지 않는 부분
벡터 검색(Vector search)은 블로그 포스트의 주제가 됩니다. 코사인 유사도(cosine similarity), 리랭커(reranker), 쿼리 재작성기(query rewriter) 같은 것들 말입니다. 반면 메타데이터 필터링은 배관(plumbing) 작업과 같아서, 접근 제어(access-control) 이야기에서는 체크박스 하나로 취급될 뿐 재현율(recall) 이야기에서는 자리를 차지하지 못합니다.
그것이 바로 간극입니다. 자연적인 파티션(partition)이 존재하는 모든 코퍼스에서, 인덱스 실행 전에 적용하는 술어는 당신이 막 추가하려던 세 번째 리랭커보다 top-k 품질에 더 큰 기여를 합니다. 이는 더 저렴하고, 결정론적(deterministic)이며, 감사(auditable) 가능합니다. 즉, 봇이 다른 고객의 계약서를 절대 보지 않았음을 증명할 수 있으며, 이는 보안 검토(security review)에서 반드시 듣고 싶어 하는 문장일 것입니다.
메타데이터를 저장하십시오. 사후 필터링(post-filter)이 아닌 사전 필터링(pre-filter)을 하십시오. 선택도가 높은 필드를 인덱싱하십시오. 당신만의 골드 셋(gold set)을 통해 성능 향상(lift)을 측정하십시오. 재현율의 승리는 당신이 이미 저장하고 있지만 검색에는 활용하지 않고 있는 필드 안에 놓여 있습니다.
이 내용이 유용했다면
RAG Pocket Guide는 메타데이터 스키마 (metadata schemas), 하이브리드 필터 결합 벡터 검색 (hybrid filter-plus-vector search), 필터링된 ANN (Approximate Nearest Neighbor) 성능을 저하시키는 카디널리티 함정 (cardinality traps), 그리고 사용자가 인지하기 전에 재현율 (recall) 저하를 포착할 수 있는 평가 (eval) 체계를 구축하는 방법까지 검색 계층 (retrieval layer) 전반을 다룹니다. 만약 당신의 검색 품질이 실제 파티션 (partitions)이 존재하는 코퍼스 (corpus)에 의존하고 있다면, 필터링 (filtering) 장이야말로 쉽고 빠르게 성과를 낼 수 있는 곳입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기