# RAG

Qdrant에서 필터를 걸면 벡터 검색이 결과를 놓칠까?

@Hong2026-10-03

이 글에 담긴 코드와 결과는 직접 실행해 측정한 값을 바탕으로 작성했습니다. 실측은 Hong과 팀원들이 진행했고, 그 결과를 바탕으로 AI가 글을 쓰고 Hong이 검토합니다.

실습 코드 내려받기

질문
Qdrant에서 payload 필터를 걸면 벡터 검색이 가까운 결과를 놓칠까?
답
놓칠 수 있다. 필터 없는 근사 검색의 recall@10(정확한 검색 상위 10개를 찾은 비율)은 0.997이었지만 무작위 필터가 30%를 남기면 0.865~0.893으로 떨어졌다.
조건
Qdrant 1.19.1(메모리 1.5GB, CPU 1.25), multilingual-e5-small(384차원), 문장 2만 7,700개, 질의 230개, 3번 반복

환경

Qdrant
qdrant/qdrant:v1.19.1, 메모리 1,536MB, CPU 1.25
클라이언트
python:3.12-slim, 메모리 1,536MB, CPU 1.25
임베딩 모델
intfloat/multilingual-e5-small(384차원)
데이터
이 사이트 글 66편을 나눈 문장 2만 7,700개, 질의 230개
반복
컬렉션을 새로 만들어 3번
목차
  1. 실습 준비
  2. 필터 검색의 두 가지 실행 방식
  3. 실습 1: 선택도를 줄여 가며 recall 재기
  4. 실습 2: 필터용 추가 연결이 일하는 경우
  5. 정리

이번 시간에는 Qdrant에서 payload 필터를 건 근사 검색이 가까운 결과를 얼마나 놓치는지 Docker 위의 Qdrant와 multilingual-e5-small 임베딩으로 측정하며 배워가는 시간입니다. 필터가 남기는 비율을 50%에서 0.06%까지 줄여 가며, 근사 검색의 recall@10과 지연을 정확한 검색과 비교해 볼게요. recall@10은 필터를 건 정확한 검색의 상위 10개 가운데 근사 검색이 찾은 비율이에요.

RAG 검색에 "Kafka 글에서만", "이 고객사 문서에서만" 같은 조건을 붙이는 일은 흔하죠. 벡터 DB는 이런 조건을 벡터마다 붙인 메타데이터(payload)에 거는 필터로 받습니다.

그러면 결과는 조건을 통과한 점 가운데 가장 가까운 10개라고 생각하기 쉬운데요, 근사 검색은 모든 점을 비교하지 않아요.

그래서 이 사이트의 글을 문장 단위로 나눠 Qdrant에 넣고 실험해 볼 거예요. payload 인덱스의 필터용 추가 연결이 있을 때와 없을 때의 차이도 함께 확인합니다.

실습 준비

이 글의 명령은 모두 실습 코드의 vector-filter-search/ 디렉터리에서 실행합니다.

  • compose.yaml: Qdrant와 Python 클라이언트 컨테이너
  • requirements.txt: 클라이언트 패키지 버전
  • prep.py: 문서 디렉터리에서 발행 글만 읽어 out/docs.jsonl로 모으는 스크립트
  • vf.py: 문장 분할, 임베딩, 컬렉션 생성, 필터 검색 실험 하위 명령을 모은 스크립트(이 글에서는 split, embed, info, sel, edges만 써요)
  • questions.jsonl: 질의로 쓰는 질문 30개

임베딩 모델은 intfloat/multilingual-e5-small(384차원)을 씁니다. 모델 파일(471MB)은 처음 불러올 때 Hugging Face에서 받아 hf 볼륨에 저장됩니다.

compose.yaml은 qdrant/qdrant:v1.19.1(1,536MB, CPU 1.25)을 REST 포트 호스트 7450, gRPC 포트 7451로 열고, python:3.12-slim 클라이언트(1,536MB, CPU 1.25)를 띄우는 구성이에요. ARTICLES_DIR에는 검색 대상으로 쓸 문서 디렉터리를 지정해 주시면 됩니다.

패키지 버전은 requirements.txt에 고정해 두었습니다.

--extra-index-url https://download.pytorch.org/whl/cpu
torch==2.14.1+cpu
sentence-transformers==6.1.0
transformers==5.18.0
qdrant-client==1.19.1
numpy==2.5.3

다음으로 셸 함수 두 개를 만들고 컨테이너를 띄울게요. 실습 코드는 vf.py 한 파일에 모아 놨어요.

아래 블록은 파일로 만들지 말고 실습 디렉터리에서 연 터미널에 그대로 붙여 넣어 주세요. 이후 명령은 모두 이 터미널에서 실행하고, 터미널을 새로 열면 다시 붙여 넣으시면 됩니다.

export ARTICLES_DIR=/path/to/articles
dc() { docker compose -p vfilt "$@"; }

py() { dc exec -T client python -u "$@"; }
dc up -d
dc exec -T client pip install -q -r requirements.txt
mkdir -p out

prep.py는 매니페스트에 등록된 발행 글을 읽어 SVG 도식을 지우고 out/docs.jsonl로 저장해요.

py prep.py
문서 66편
전체 1,690,716자, 코드 블록을 뺀 산문 705,797자
가장 짧은 글 11,301자, 가장 긴 글 48,315자

vf.py는 python vf.py <명령> [인자...]처럼 부르시면 됩니다. split은 산문을 문장으로 나누고 코드 블록의 줄도 한 줄을 한 문장으로 넣어 out/sents.jsonl에 저장해요. 근사 검색용 그래프 색인(HNSW)이 실제로 쓰이는 규모를 만들려는 거예요.

문장마다 payload를 붙이는데, 이 글에서 필터로 쓰는 건 bucket이에요. 0~9999 사이 정수를 고정 시드로 무작위 배정했어요. bucket < N 조건이 전체의 N/10000을 남기니까, 선택도(전체 중 조건을 통과하는 비율)를 정확히 조절할 수 있습니다.

출력은 앞 두 줄만 옮겼어요(발췌).

py vf.py split
문서 66편, 문장 27,700개(산문 13,979, 코드 줄 13,721)
평균 길이 50자

embed는 문장마다 passage: 를 붙여 벡터로 바꾸고 out/vecs.npy로 저장합니다. 이후 실습은 이 파일을 읽기만 하고 다시 임베딩하지 않아요.

py vf.py embed
1,000개 시험: 40.6초, 전체 27,700개 예상 18.7분
전체 27,700개: 16.4분, 모양 (27700, 384)

질의는 230개를 씁니다. questions.jsonl의 질문 30개에 query: 를 붙여 임베딩했고, 여기에 코퍼스 문장 200개를 고정 시드로 뽑아 더했어요. 문장 질의는 자기 자신을 결과에서 뺍니다.

정답은 같은 필터를 건 exact=True 검색의 상위 10개로 정했습니다. exact=True는 HNSW 색인을 쓰지 않고 전체를 비교하는 옵션이에요(이 옵션이 더 궁금하시면 임베딩 거리를 다룬 글을 참고해 주세요).

recall@10은 근사 검색(exact=False) 결과가 이 정답과 겹치는 비율로 계산해요. 지연은 클라이언트에서 잰 왕복 시간이고, 230개의 중앙값과 p95를 냅니다.

필터 검색의 두 가지 실행 방식

HNSW는 점마다 가까운 이웃 몇 개(기본 16개)를 연결해 둔 그래프예요. 검색은 그래프의 한 점에서 출발해, 질의에 더 가까운 이웃으로 옮겨 가며 후보를 모읍니다.

여기에 필터가 붙으면 필터에 걸린 점은 결과에 넣을 수 없고, 필터를 통과한 점끼리만 이어진 길이 정답까지 닿지 않을 수 있어요.

시작정답정답필터 통과필터에 걸림(지나갈 수 없음)
필터에 걸린 점을 건너뛸 수 없으면 정답 쪽으로 가는 길이 끊깁니다.

Qdrant 문서는 이 문제를 두 가지 방법으로 다룬다고 설명하고 있어요.

  • 쿼리 플래너: 필터를 통과할 점 수(카디널리티)를 payload 인덱스로 추정합니다. 추정값이 full_scan_threshold보다 작으면 payload 인덱스로 통과한 점을 뽑아 전부 비교하고, 크면 HNSW를 따라가며 필터를 적용해요. payload 인덱스는 필터에 쓰는 필드에 만드는 인덱스예요.
  • 필터용 추가 연결(filterable HNSW): HNSW 그래프를 만들 때 payload 인덱스가 있는 필드의 값을 기준으로 연결을 더 만들어요. 문서는 이 연결이 "인덱스가 있는 payload 필드에만" 만들어진다고 적고 있습니다.

실습 1: 선택도를 줄여 가며 recall 재기

info는 bucket 필드에 정수 payload 인덱스를 만든 뒤 2만 7,700개를 넣고, 색인이 끝날 때까지 기다려 컬렉션 설정을 출력해요.

py vf.py info
업로드 4.2초, 색인 완료까지 5.5초 더
status green  points_count 27700  indexed_vectors_count 27700  segments_count 2
hnsw m 16  ef_construct 100  full_scan_threshold 10000  payload_m None
optimizer indexing_threshold 10000  default_segment_number 0
full_scan_threshold 10000KB = 384차원 벡터 6,667개
  세그먼트 points 0  indexed 0  plain
  세그먼트 points 27700  indexed 27700  indexed

full_scan_threshold는 10,000KB예요. Qdrant 문서의 설정 예시 주석에 "1Kb = 크기 256인 벡터 1개"라고 적혀 있으니 float32 기준 1,024바이트죠. 384차원 벡터 하나는 1,536바이트라서 10,000KB는 벡터 6,667개가 됩니다.

점은 모두 색인된 세그먼트 하나에 있으니까, 이 컬렉션에서는 필터를 통과할 점이 6,667개보다 적은지가 실행 방식을 가릅니다.

sel은 이 컬렉션에 bucket < N의 N을 5000부터 5까지 줄여 가며 같은 질의 230개를 보내요. 필터 없는 검색도 기준선으로 넣었습니다.

끝 열은 Qdrant 텔레메트리(/telemetry?details_level=10)의 vector_index_searches 카운터 가운데 이 단계에서 올라간 항목이에요.

  • filtered_small_cardinality: 통과할 점이 적다고 판단해 payload 인덱스로 뽑은 점을 전부 비교했다는 뜻이에요.
  • filtered_large_cardinality: 통과할 점이 많다고 판단해 HNSW를 따라가며 걸렀다는 뜻이에요.
  • filtered_exact: exact=True 검색입니다.

HNSW 그래프는 만들 때마다 조금씩 달라지기 때문에, 컬렉션을 새로 만들어 세 번 반복했어요. 아래는 1회차 출력이에요.

py vf.py sel
[1회차] 세그먼트 [0, 27700]
  필터 없음                     recall@10 0.997  최소 0.9  1 미만   6/230  지연 중앙값   1.4ms p95   1.6ms
  bucket < 5000  13,898개 50.17%  recall@10 0.955  최소 0.5  1 미만  71/230  지연 중앙값   1.3ms p95   1.4ms  filtered_large_cardinality+filtered_exact
  bucket < 4000  11,128개 40.17%  recall@10 0.928  최소 0.5  1 미만 103/230  지연 중앙값   1.3ms p95   1.4ms  filtered_large_cardinality+filtered_exact
  bucket < 3000   8,450개 30.51%  recall@10 0.865  최소 0.0  1 미만 140/230  지연 중앙값   1.2ms p95   1.4ms  filtered_large_cardinality+filtered_exact
  bucket < 2000   5,558개 20.06%  recall@10 1.000  최소 1.0  1 미만   0/230  지연 중앙값   1.9ms p95   2.1ms  filtered_small_cardinality+filtered_exact
  bucket < 1000   2,751개  9.93%  recall@10 1.000  최소 1.0  1 미만   0/230  지연 중앙값   1.4ms p95   1.6ms  filtered_small_cardinality+filtered_exact
  bucket < 500    1,383개  4.99%  recall@10 1.000  최소 1.0  1 미만   0/230  지연 중앙값   1.2ms p95   1.2ms  filtered_small_cardinality+filtered_exact
  bucket < 200      562개  2.03%  recall@10 1.000  최소 1.0  1 미만   0/230  지연 중앙값   1.1ms p95   1.2ms  filtered_small_cardinality+filtered_exact
  bucket < 100      267개  0.96%  recall@10 1.000  최소 1.0  1 미만   0/230  지연 중앙값   1.0ms p95   1.1ms  filtered_small_cardinality+filtered_exact
  bucket < 50       132개  0.48%  recall@10 1.000  최소 1.0  1 미만   0/230  지연 중앙값   1.0ms p95   1.0ms  filtered_small_cardinality+filtered_exact
  bucket < 20        59개  0.21%  recall@10 1.000  최소 1.0  1 미만   0/230  지연 중앙값   1.0ms p95   1.1ms  filtered_small_cardinality+filtered_exact
  bucket < 10        31개  0.11%  recall@10 1.000  최소 1.0  1 미만   0/230  지연 중앙값   1.0ms p95   1.0ms  filtered_small_cardinality+filtered_exact
  bucket < 5         16개  0.06%  recall@10 1.000  최소 1.0  1 미만   0/230  지연 중앙값   1.0ms p95   1.1ms  filtered_small_cardinality+filtered_exact

세 번 반복한 결과를 합치면 이렇게 돼요.

  • 필터 없음: recall@10 0.997로 세 번 모두 같았어요.
  • 50% 남김: 0.950~0.977
  • 40% 남김: 0.923~0.958
  • 30% 남김: 0.865~0.893이에요. 230개 질의 중 129~142개가 정답을 하나 이상 놓쳤고, 정답을 하나도 찾지 못한 질의(최소 0.0)도 나왔습니다.
  • 20% 이하: 세 번 모두 1.000입니다.

여기서 눈여겨볼 건 recall이 떨어진 구간이에요. 아주 좁은 필터 쪽이 아니라, 통과하는 점이 기준값 6,667개를 막 넘는 30%~50% 구간이었어요.

20%(5,558개)부터는 기준값 아래라서 Qdrant가 전부 비교로 바꿨고, 그 아래로는 아무리 좁혀도 정답을 놓치지 않았어요. 그리고 HNSW를 쓰기로 한 구간 가운데 가장 좁은 필터에서 recall이 가장 낮았던 거죠.

지연은 1~2ms로 큰 차이가 없었어요.

실습 2: 필터용 추가 연결이 일하는 경우

실습 1에서는 좁은 필터가 모두 전부 비교로 처리돼서 HNSW를 타지 않았어요. 그러니 추가 연결의 효과를 보려면 좁은 필터도 HNSW로 찾게 만들어야 합니다.

그래서 여기서는 full_scan_threshold를 100KB(384차원 벡터 약 67개)로 낮추는 방식으로 가볼게요. 그러면 통과하는 점이 67개를 넘는 필터는 모두 HNSW 방식으로 실행돼요.

edges는 이 설정에서 bucket 인덱스의 추가 연결을 켠 컬렉션과 끈(enable_hnsw=False) 컬렉션을 비교합니다. 세 번 반복했고, 아래는 1회차예요.

py vf.py edges
[1회차 연결 있음] 데이터 넣고 색인 완료까지 32.9초
  bucket < 4000  recall@10 0.989  1 미만  24  지연 중앙값   2.8ms
  bucket < 3000  recall@10 0.987  1 미만  28  지연 중앙값   2.5ms
  bucket < 2000  recall@10 0.992  1 미만  17  지연 중앙값   4.0ms
  bucket < 1000  recall@10 0.997  1 미만   6  지연 중앙값   5.8ms
  bucket < 500   recall@10 0.998  1 미만   4  지연 중앙값   4.3ms
[1회차 연결 없음] 데이터 넣고 색인 완료까지 30.4초
  bucket < 4000  recall@10 0.925  1 미만 107  지연 중앙값   2.5ms
  bucket < 3000  recall@10 0.870  1 미만 143  지연 중앙값   2.2ms
  bucket < 2000  recall@10 0.633  1 미만 206  지연 중앙값   2.1ms
  bucket < 1000  recall@10 0.406  1 미만 229  지연 중앙값   2.2ms
  bucket < 500   recall@10 0.131  1 미만 230  지연 중앙값   1.7ms
  • 추가 연결이 없으면 필터가 좁아질수록 recall이 무너져요. 세 번 반복에서 20% 0.633~0.736, 10% 0.406~0.440, 5%는 0.000~0.154였어요. 5%에서는 230개 질의 모두 정답을 하나 이상 놓쳤습니다. 앞의 그림에서 본 그래프 끊김이 그대로 나타난 거예요.
  • 추가 연결이 있으면 같은 필터에서 0.987~0.998을 유지했어요(세 번 모두 거의 같은 값). 30% 필터도 0.987로, 기본 설정의 0.865~0.893보다 높았습니다.
  • 대가는 색인 시간이에요. 출력을 싣지 않은 회차까지 보면, 기본 설정에서 9~14초였던 색인이 full_scan_threshold를 낮추자 16~44초로 늘었어요. 다른 실습이 함께 돌던 시간이라 값의 폭이 크니 비율만 참고해 주세요.

추가 연결을 끈 컬렉션의 30% 결과(0.867~0.870)는 기본 설정의 결과와 거의 같아요. 기본 설정에서 30% 필터가 놓친 정답은 추가 연결이 없을 때의 그래프 끊김과 같은 수준이었다는 뜻이에요.

정리

범위: Qdrant 1.19.1(메모리 1.5GB, CPU 1.25), intfloat/multilingual-e5-small(384차원), 발행 글 66편의 문장 2만 7,700개, 질의 230개, 컬렉션을 새로 만들어 3번 반복.

  • 필터 없는 근사 검색의 recall@10은 0.997이었지만, 무작위 필터가 50%를 남기면 0.950~0.977, 40%는 0.923~0.958, 30%는 0.865~0.893으로 떨어졌어요.
  • 통과할 점이 full_scan_threshold(기본 10,000KB, 384차원 벡터 6,667개)보다 적은 20% 이하 필터는 전부 비교로 처리돼 recall이 1.000이었고, 정답을 놓친 건 기준값을 막 넘는 30~50% 구간이었어요. 필터 검색이 어느 방식으로 실행됐는지는 텔레메트리의 vector_index_searches 카운터로 확인해 보세요.
  • full_scan_threshold를 100KB로 낮춰 좁은 필터도 HNSW로 찾게 하자, 필터용 추가 연결을 끈 컬렉션은 5% 필터에서 recall이 0.000~0.154까지 떨어졌고 켠 컬렉션은 0.987~0.998을 지켰어요. 색인 시간은 9~14초에서 16~44초로 늘었습니다.
  • 이 글에 싣지 않은 추가 측정에서는 글 하나를 고객사 하나로 본 작은 테넌트 66개를 컬렉션으로 나누자(테넌트 분리) 검색 결과는 한 컬렉션에서 테넌트 필터로 거를 때와 같았지만, 파일 매핑 메모리가 약 1.4GB로 한 컬렉션 방식(약 5MB)보다 컸고 겉보기 디스크는 13GB였어요. 작은 테넌트가 많으면 한 컬렉션에 넣고 테넌트 필터로 걸러 주세요.
내 링크 만들기