# RAG

Qdrant HNSW는 왜 가끔 가장 가까운 문장을 놓칠까?

@Hong2026-10-03

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

실습 코드 내려받기

질문
Qdrant 벡터 검색이 가장 가까운 문장을 놓칠 때 HNSW 설정은 무엇부터 바꿀까?
답
검색 폭 hnsw_ef부터 올린다. 기본 설정에서 정확한 검색의 상위 10개를 찾은 비율은 0.984였고, 색인을 그대로 두고 hnsw_ef를 256으로 올리자 0.996, 지연 중앙값 1.64ms가 됐다.
조건
Qdrant 1.19.1(CPU 1.5개, 메모리 1280MB), intfloat/multilingual-e5-small(384차원), 벡터 39,795개, 질의 500개

환경

Qdrant
1.19.1, CPU 1.5개, 메모리 1280MB
클라이언트
Python 컨테이너, CPU 1개, 메모리 1792MB
임베딩 모델
intfloat/multilingual-e5-small(384차원)
데이터
이 사이트 글 66편을 문장 단위로 나눈 벡터 39,795개, 질의 문장 500개
지연
Qdrant REST 응답의 time 필드, 질의 500개를 세 번 잰 범위
목차
  1. 실습 준비
  2. 실습 1: 정답부터 만든다
  3. 실습 2: 검색 폭 ef, 색인은 그대로 질의마다 조정
  4. 실습 3: 그래프를 바꾸는 m과 ef_construct
  5. 정리

이번 시간에는 Qdrant HNSW의 세 설정 m, ef_construct, ef를 바꿔 가며 recall@10과 지연, 그래프 파일 크기, 색인 시간을 Docker 위의 Qdrant와 multilingual-e5-small 임베딩으로 측정하며 배워가는 시간입니다. 이 사이트의 글을 문장 단위로 나눈 벡터를 넣고, 결과가 정확한 검색과 얼마나 같은지 비교해 볼게요.

벡터 DB가 쓰는 HNSW 색인은 근사 검색이에요. 가장 가까운 벡터를 빠르게 찾는 대신, 가끔 놓칩니다.

바꿔 볼 HNSW의 세 설정은 이래요. 노드마다 잇는 이웃 수가 m, 색인할 때 이웃 후보를 찾는 폭이 ef_construct, 검색할 때 후보를 찾는 폭이 ef예요.

컬렉션이 작으면 Qdrant는 HNSW 색인을 아예 만들지 않고 모든 검색을 전체 비교로 해요. 그런 작은 컬렉션에서 청크 설정을 비교한 결과가 더 궁금하시면 청크 크기를 다룬 글을 참고해 주세요.

실습 준비

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

  • compose.yaml: Qdrant 1.19.1과 Python 클라이언트 컨테이너 정의
  • requirements.txt: 클라이언트 패키지
  • prep.py: 매니페스트에 등록된 글을 읽어 out/docs.jsonl로 저장하는 스크립트
  • hnsw.py: units, embed, build, truth, ef, sweep, graph, q30, scale 등 실습 하위 명령을 모은 스크립트(sweep, q30, scale은 이 글에서는 쓰지 않아요)
  • questions.jsonl: embed가 함께 임베딩하는 질문 30개(이 글의 측정에는 쓰지 않아요)

compose.yaml은 Qdrant 1.19.1과 Python 클라이언트 컨테이너를 띄워요. 지연과 색인 시간을 재야 하니까 두 컨테이너 모두 CPU와 메모리 상한을 정해 두었습니다.

Qdrant 데이터 볼륨은 클라이언트에도 읽기 전용으로 붙여서, 그래프 파일 크기를 잴 수 있게 했어요.

패키지 버전은 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

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

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

py() { dc exec -T client python "$@"; }
dc up -d
dc exec -T client pip install --no-cache-dir -q -r requirements.txt

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

문서 디렉터리는 이 사이트에 발행된 글 66편의 매니페스트(*.json)와 본문(*.mdx)이에요. 약 170만 자라서 글에는 넣지 않았어요.

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

실제 RAG 청크는 문단 몇 개 크기지만, 여기서는 HNSW 동작을 보려고 일부러 잘게 나눴어요. 벡터가 많아야 근사 검색이 무엇을 놓치는지 드러나거든요. 나누는 단위는 세 가지예요.

  • 산문 문장(sentence): 코드 블록을 빼고 빈 줄로 문단을 나눈 뒤, . ? ! 뒤의 공백에서 잘라요. 10자 미만은 버립니다.
  • 코드와 명령 출력 줄(code): 코드 블록 안의 줄 중 15자 이상인 것이에요.
  • 이웃한 두 문장(pair): 같은 글에서 연달아 나오는 두 문장을 이어 붙여요. 문장과 코드 줄만으로는 3만 개에 못 미쳐서 더했습니다.

그리고 산문 문장 500개를 무작위(시드 42)로 뽑아 질의로 쓰고, 컬렉션에서는 뺐어요. 질의 문장 자신이 1위로 나오는 걸 막기 위해서예요.

py hnsw.py units
문서 66편
sentence  13,344개  평균  48.7자
code      12,562개  평균  53.8자
pair      13,889개  평균  98.2자
색인 대상 합계 39,795개 (질의로 뺀 문장 500개)

임베딩 모델은 intfloat/multilingual-e5-small(384차원)을 써요. 이 모델은 질의 앞에 query: , 문서 조각 앞에 passage: 를 붙여 임베딩해야 해요.

1,000개로 먼저 재 보니 초당 17개 정도라, CPU 1개로는 4만 개에 40분 가까이 걸려요. 그래서 임베딩하는 동안만 Qdrant를 멈추고 클라이언트의 CPU 상한을 2.5개로 올렸어요.

dc stop qdrant
docker update --cpus 2.5 hnsw-client-1
dc exec -T -e OMP_NUM_THREADS=2 client python hnsw.py embed
docker update --cpus 1 hnsw-client-1
dc start qdrant
색인 대상 39,795개 임베딩 2343초 (17개/초)
vectors.npy 61,125,248바이트 (계산값 39795 x 384 x 4 = 61,125,120바이트)

실습 1: 정답부터 만든다

근사 검색이 얼마나 맞는지 알려면 정답이 있어야겠죠. 정답은 정확한 검색, 즉 모든 벡터와 하나씩 비교한 상위 10개예요.

Qdrant에서는 검색 요청에 exact=True를 주면 HNSW를 쓰지 않고 전체를 비교해요. 근사 검색의 품질은 recall@10으로 잽니다.

  • recall@10: 근사 검색의 상위 10개 중 정답 10개에 들어 있는 것의 비율을 질의마다 구해 평균한 값이에요. 1이면 정답과 같은 10개를 찾은 거예요.
  • 1보다 작은 질의 수: 정답 10개 중 하나라도 놓친 질의가 몇 개인지 셉니다.
  • 1위를 놓친 질의 수: 정답 1위가 근사 검색 상위 10개 안에 아예 없는 질의예요.

먼저 기본값(m 16, ef_construct 100)으로 컬렉션을 만들어 볼게요. build는 벡터를 모두 넣은 뒤 컬렉션 상태가 green이고 indexed_vectors_count가 전체 개수와 같아질 때까지 걸린 시간을 색인 시간으로 출력해요.

py hnsw.py build base 16 100
base: 벡터 39,795개, m 16, ef_construct 100, 색인 21.7초
status green  points_count 39795  indexed_vectors_count 39795  segments_count 2
hnsw_config {"m": 16, "ef_construct": 100, "full_scan_threshold": 10000, "max_indexing_threads": 0, "on_disk": false}
indexing_threshold 10000  default_segment_number 2

39,795개 모두 색인됐어요. 이제 질의 500개를 exact=True로 검색해 상위 10개를 정답으로 저장하고, 같은 일을 numpy로 직접 계산해 맞춰 볼게요.

지연은 Qdrant REST 응답의 time 필드(서버가 요청을 처리한 시간, 초 단위)로 잽니다. 질의 500개를 한 번 미리 돌린 뒤 세 번 재서, 중앙값과 p95를 세 번의 범위로 적어요.

py hnsw.py truth base
질의 500개  Qdrant exact = numpy  순서까지 같음 500개  집합이 같음 500개
정확한 검색 지연  중앙값 2.99~3.54ms  p95 4.39~6.90ms

출력을 보면 Qdrant의 정확한 검색은 numpy 결과와 500개 모두 순서까지 같았어요. 그래서 이 결과를 정답으로 씁니다. 4만 개를 모두 비교하는 데 서버 쪽에서 3ms 남짓 걸렸어요.

실습 2: 검색 폭 ef, 색인은 그대로 질의마다 조정

이번에는 base 컬렉션은 그대로 두고 검색 요청의 hnsw_ef만 바꿔 볼게요. default는 hnsw_ef를 주지 않은 경우예요.

py hnsw.py ef base 1 10 16 32 64 100 128 256 default
base  ef       1  recall@10 0.855  1보다 작은 질의 244개  1위를 놓친 질의 57개  지연 중앙값 0.24~0.27ms  p95 0.43~0.61ms
base  ef      10  recall@10 0.855  1보다 작은 질의 244개  1위를 놓친 질의 57개  지연 중앙값 0.26~0.27ms  p95 0.54~0.59ms
base  ef      16  recall@10 0.911  1보다 작은 질의 171개  1위를 놓친 질의 37개  지연 중앙값 0.37~0.42ms  p95 0.50~0.83ms
base  ef      32  recall@10 0.960  1보다 작은 질의  92개  1위를 놓친 질의 17개  지연 중앙값 0.50~0.54ms  p95 0.78~0.83ms
base  ef      64  recall@10 0.978  1보다 작은 질의  57개  1위를 놓친 질의 14개  지연 중앙값 0.67~0.71ms  p95 1.15~1.53ms
base  ef     100  recall@10 0.984  1보다 작은 질의  42개  1위를 놓친 질의 11개  지연 중앙값 0.80~0.96ms  p95 1.44~2.04ms
base  ef     128  recall@10 0.987  1보다 작은 질의  35개  1위를 놓친 질의 10개  지연 중앙값 1.01~1.09ms  p95 1.81~1.94ms
base  ef     256  recall@10 0.996  1보다 작은 질의  16개  1위를 놓친 질의  3개  지연 중앙값 1.64~1.68ms  p95 2.92~2.95ms
base  ef default  recall@10 0.984  1보다 작은 질의  42개  1위를 놓친 질의 11개  지연 중앙값 0.92~1.02ms  p95 1.71~2.27ms
hnsw_config 전후 같음: True  {"m": 16, "ef_construct": 100, "full_scan_threshold": 10000, "max_indexing_threads": 0, "on_disk": false}

출력에서 눈여겨볼 부분을 하나씩 짚어 볼게요.

  • 기본값: recall@10이 0.984예요. 질의 500개 중 42개는 정답 10개 중 하나 이상을 놓쳤고, 11개는 정답 1위를 상위 10개 안에서 찾지 못했어요. 기본 설정 그대로도 가장 가까운 결과를 놓치는 일이 실제로 생기는 거죠.
  • 기본값과 ef 100: recall, 놓친 질의 수가 똑같아요. 검색할 때 ef를 주지 않으면 ef_construct(100)를 쓴다는 문서 설명과 맞습니다.
  • ef를 올리면: 16에서 0.911, 64에서 0.978, 256에서 0.996으로 올라요. 그 대신 지연 중앙값도 0.37ms에서 0.67ms, 1.64ms로 늘어납니다.
  • hnsw_config: 실습 전후로 같아요. ef를 바꾸는 데는 색인을 다시 만들 필요가 없습니다.

정확한 검색은 같은 컬렉션에서 중앙값 2.99~3.54ms였어요. ef 256으로 recall을 0.996까지 올려도 정확한 검색보다 두 배 가까이 빠른 거예요.

실습 3: 그래프를 바꾸는 m과 ef_construct

m과 ef_construct는 컬렉션 설정(hnsw_config)이라서 바꾸면 그래프를 다시 만들어야 해요. Qdrant 문서도 색인을 다시 만들고 싶을 때 ef_construct를 1 올려 최적화기가 모든 세그먼트를 다시 색인하게 하라고 안내합니다.

설정 여섯 가지로 컬렉션을 세 번씩 새로 만들어 색인 시간, 그래프 파일 크기, recall@10을 잽니다.

m은 4, 8, 16, 32로 바꾸고 ef_construct는 100으로 둬요. ef_construct는 32, 100, 400으로 바꾸고 m은 16으로 둡니다.

graph는 세그먼트 디렉터리에서 links와 graph로 시작하는 파일을 그래프 파일로 더해요.

py hnsw.py graph m4 m8 m16 m32 efc32 efc400
m4     m  4 ef_construct 100  색인 8.5~20.7초  세그먼트 1개  그래프 파일 972,680~978,252바이트  벡터 파일 61,125,124바이트
       recall@10  ef 16 0.632~0.659  ef 64 0.843~0.845  기본값 0.879~0.887
m8     m  8 ef_construct 100  색인 10.8~20.1초  세그먼트 1개  그래프 파일 1,286,429~1,290,776바이트  벡터 파일 61,125,124바이트
       recall@10  ef 16 0.839~0.843  ef 64 0.956~0.962  기본값 0.967~0.973
m16    m 16 ef_construct 100  색인 14.0~15.4초  세그먼트 1개  그래프 파일 1,608,864~1,611,320바이트  벡터 파일 61,125,124바이트
       recall@10  ef 16 0.901~0.911  ef 64 0.977~0.980  기본값 0.984~0.985
m32    m 32 ef_construct 100  색인 14.8~15.6초  세그먼트 1개  그래프 파일 1,817,087~1,819,605바이트  벡터 파일 61,125,124바이트
       recall@10  ef 16 0.931  ef 64 0.982~0.983  기본값 0.988
efc32  m 16 ef_construct  32  색인 4.0~4.3초  세그먼트 1개  그래프 파일 1,257,631~1,259,676바이트  벡터 파일 61,125,124바이트
       recall@10  ef 16 0.820~0.844  ef 64 0.938~0.949  기본값 0.893~0.907
efc400 m 16 ef_construct 400  색인 56.0~89.3초  세그먼트 1개  그래프 파일 1,866,357~1,872,098바이트  벡터 파일 61,125,124바이트
       recall@10  ef 16 0.916~0.926  ef 64 0.987~0.989  기본값 0.999

m부터 볼게요.

  • 그래프 파일: m 4에서 0.97MB, 32에서 1.82MB로 m이 클수록 커져요. 이 규모에서는 그래프보다 벡터 파일(61MB)이 훨씬 큽니다.
  • recall@10(ef 64): 4에서 0.84, 8에서 0.96, 16에서 0.98, 32에서 0.98이에요. 4에서 8로, 8에서 16으로 올릴 때는 크게 오르고, 16에서 32로 올릴 때는 0.003 남짓 올라요.
  • 색인 시간: 다른 실습과 함께 돌린 탓에 m 4와 8은 범위가 넓어요. 16과 32는 14~16초로 거의 같습니다.

ef_construct를 바꾸면 이렇게 달라져요.

  • 색인 시간: 32에서 4초, 100에서 14~15초, 400에서 56~89초로, 여섯 설정 중 색인 시간 차이가 가장 커요.
  • 같은 ef 64에서 recall@10: 32에서 0.938~0.949, 100에서 0.977~0.980, 400에서 0.987~0.989예요. 그래프를 공들여 만들면 같은 검색 폭으로 더 많이 찾는 거죠.
  • 기본값(ef를 주지 않음)으로 검색하면: 32에서 0.893~0.907로, 같은 컬렉션의 ef 64보다도 낮아요. 400에서는 0.999입니다. 검색할 때 ef가 ef_construct를 따라가기 때문에, ef_construct를 바꾸면 기본 검색 폭도 함께 바뀌어요. 색인을 빨리 하려고 ef_construct를 낮추면 검색 품질까지 함께 떨어지니, 이때는 검색 요청에 hnsw_ef를 따로 주시는 게 좋아요.

거리 함수와 정규화가 검색 순위에 어떤 영향을 주는지 더 궁금하시면 임베딩 유사도를 다룬 글을 참고해 주세요. 양자화를 켜면 여기서 본 HNSW 오차 위에 양자화 오차가 더해집니다.

정리

범위: Qdrant 1.19.1(CPU 1.5개, 메모리 1280MB), intfloat/multilingual-e5-small(384차원), 이 사이트 글 66편을 문장 단위로 나눈 벡터 39,795개, 질의 500개.

  • 기본 설정(m 16, ef_construct 100)에서 recall@10은 0.984였고, 질의 500개 중 11개는 정답 1위를 상위 10개 안에서 찾지 못했어요. HNSW의 품질은 exact=True 결과와 비교해서 재 주세요.
  • hnsw_ef를 16, 64, 256으로 올리자 recall@10이 0.911, 0.978, 0.996으로 오르고 지연 중앙값이 0.37ms, 0.67ms, 1.64ms로 늘었으며 hnsw_config는 그대로였어요. recall이 모자라면 색인을 다시 만들기 전에 hnsw_ef부터 올려 보세요.
  • hnsw_ef를 주지 않은 검색은 ef 100과 결과가 같았고, ef_construct 32 컬렉션에서는 기본 검색의 recall@10이 0.893~0.907로 떨어졌어요. ef_construct를 낮추면 검색 요청에 hnsw_ef를 따로 주세요.
  • ef_construct를 32, 100, 400으로 바꾸자 색인 시간이 4초, 14~15초, 56~89초가 됐고 ef 64의 recall@10은 0.938~0.949, 0.977~0.980, 0.987~0.989였습니다.
  • m 4에서는 ef 64의 recall@10이 0.843~0.845에 그쳤고, m을 16에서 32로 올리면 ef 64의 recall@10이 0.003 남짓만 올랐어요. m을 16보다 낮출 때는 recall@10을 먼저 재 보세요.
내 링크 만들기