이번 시간에는 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부터 볼게요.
- 그래프 파일:
m4에서 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 남짓 올라요.
- 색인 시간: 다른 실습과 함께 돌린 탓에
m4와 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개.
- 기본 설정(
m16,ef_construct100)에서 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_construct32 컬렉션에서는 기본 검색의 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였습니다.m4에서는 ef 64의 recall@10이 0.843~0.845에 그쳤고,m을 16에서 32로 올리면 ef 64의 recall@10이 0.003 남짓만 올랐어요.m을 16보다 낮출 때는 recall@10을 먼저 재 보세요.