이번 시간에는 이 사이트에 발행된 글 53편을 문서로 삼아, 청크 크기만 바꾸고 나머지는 그대로 둔 채 같은 질문 30개의 검색 결과를 비교하며 배워가는 시간입니다.
청크는 문서를 검색 단위로 자른 조각이에요. 몇 글자씩 자르느냐에 따라 같은 질문에도 다른 조각이 올라옵니다.
가까운 정도를 코사인 유사도와 내적 중 무엇으로 재는지 더 궁금하면 임베딩 유사도를 다룬 글을 참고하세요.
실습 준비
이 글의 명령은 모두 실습 코드의 rag-chunk-size/ 디렉터리에서 실행합니다.
compose.yaml: Qdrant와 Python 클라이언트 컨테이너requirements.txt: 클라이언트 패키지 버전prep.py: 문서 디렉터리에서 발행 글만 읽어out/docs.jsonl로 모으는 스크립트rag.py: 분할, 색인, 평가, 확인용 하위 명령을 모은 스크립트questions.jsonl: 평가 질문 30개와 정답 글·정답 문장trunc.py: 512토큰을 넘는 청크에서 버려지는 부분을 확인하는 스크립트
Qdrant와 Python 클라이언트 컨테이너를 띄워요(compose.yaml). Qdrant는 qdrant/qdrant:v1.19.1(메모리 1GB)이고 REST 포트를 호스트 7300, gRPC 포트를 7301로 엽니다. 클라이언트는 python:3.12-slim(메모리 4GB)이에요.
문서 디렉터리는 ARTICLES_DIR로 받아 읽기 전용으로 붙여요. 이 사이트의 글은 *.json 매니페스트와 *.mdx 본문으로 되어 있습니다.
문서로 쓴 이 사이트 글 53편(약 128만 자)은 글에 싣지 않았어요. 여러분 문서로 따라 하실 때는 같은 형식으로 두고 돌리시면 됩니다.
모델 파일과 pip 캐시는 hong_ml_cache 볼륨에 둬요. compose 프로젝트를 지워도 다시 받지 않게 하려고 프로젝트 밖의 볼륨으로 만들었습니다.
패키지 버전은 requirements.txt에 고정했어요. torch는 CPU 전용 빌드를 받습니다.
--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
명령을 짧게 쓰려고 셸 함수 두 개를 만들고 컨테이너를 띄울게요.
아래 블록은 파일로 만들지 말고 실습 디렉터리에서 연 터미널에 그대로 붙여 넣어 주세요. 이후 명령은 모두 이 터미널에서 실행하고, 터미널을 새로 열면 다시 붙여 넣으시면 됩니다.
export ARTICLES_DIR=/path/to/articles
dc() { docker compose -p rag "$@"; }
py() { dc exec -T client python "$@"; }
mkdir -p out
docker volume create hong_ml_cache
dc up -d
dc exec -T client pip install -q -r requirements.txt
prep.py는 매니페스트에 등록된 글만 읽어 out/docs.jsonl로 모아요. 글에 들어 있는 SVG 도식(<figure>)은 지웁니다.
코드 블록과 명령 출력은 남겨 둬요. 마지막 세 줄은 전체 글자 수와 코드 블록을 뺀 산문 글자 수를 셉니다.
py prep.py
문서 53편
전체 1,283,595자, 코드 블록을 뺀 산문 543,478자
가장 짧은 글 11,301자, 가장 긴 글 42,906자
128만 자 가운데 산문은 54만 자이고, 절반이 넘는 나머지가 코드 블록과 명령 출력이에요.
rag.py는 실습 코드를 모은 파일이에요. 임베딩 모델은 intfloat/multilingual-e5-small이고, python rag.py <하위 명령> <설정 이름...>처럼 부릅니다. 이 글에서는 색인(index)과 평가(eval)를 씁니다.
모델 카드에 적힌 내용 중 이 실습에 필요한 것은 다음과 같아요.
- 차원: 384입니다.
- 최대 입력 길이: 512토큰입니다. 모델 카드의 Limitations 부분은 "긴 글은 최대 512토큰으로 잘린다"고 적고 있어요.
- 접두어: 질문 앞에는
query:, 문서 조각 앞에는passage:를 붙여야 합니다. 붙이지 않으면 성능이 떨어진다고 FAQ에 적혀 있어요.
questions.jsonl은 한 줄에 질문 하나씩 30줄이고, 줄마다 id, type, question, 정답 글 gold_slug, 정답 문장 gold_passage가 있어요. gold_passage는 원문에서 그대로 복사한 20~80자 부분 문자열이라, 청크에 들어 있는지 문자열 비교로 확인할 수 있습니다.
예를 들어 1번 질문은 "Kafka lag 숫자가 몇 초 늦었는지를 뜻하지 않는 이유는?"이고 정답 글은 kafka-consumer-lag입니다. 30개 질문의 정답 글은 모두 다르고, 결과를 본 뒤 질문을 고치지 않았어요.
검색은 질문마다 상위 20개 청크를 가져오고, 세 가지 지표를 계산해요.
- 문서 recall@5: 상위 청크를 글 단위로 중복을 지운 순서에서, 앞의 5개 글 안에 정답 글이 있는 질문의 비율입니다.
- 문단 적중@5: 상위 5개 청크 가운데 정답 문장을 온전히 담은 정답 글의 청크가 있는 질문의 비율입니다. 정답 문장이 청크 경계에서 잘려 두 청크로 나뉘면 적중으로 세지 않아요.
- MRR@10: 중복을 지운 글 순서에서 정답 글이 n번째에 있으면 1/n을 주고, 10위 안에 없으면 0을 줘서 평균한 값입니다. 1위면 1, 2위면 0.5예요.
질문이 30개뿐이라 질문 하나가 0.033(3.3%포인트)이에요. 그래서 이 글에서는 비율과 함께 "30개 중 몇 개"를 같이 적을게요.
실습 1: 청크 크기별로 색인하기
rag.py의 fixed는 글을 size자씩 잘라요. 설정 이름은 fixed-<크기>입니다.
200, 400, 800, 1600자로 자르고, 설정마다 Qdrant 컬렉션을 하나씩 만들어요. 컬렉션의 거리 함수는 Cosine으로 정했습니다. 모델 카드 예제가 정규화한 벡터끼리 비교하기 때문이에요.
index는 청크 수와 평균 길이, 모델 토크나이저로 셌을 때 512토큰을 넘는 청크 수를 함께 출력해요.
py rag.py index fixed-200 fixed-400 fixed-800 fixed-1600
출력에서 200, 400, 1600자 줄만 옮겼어요(발췌).
fixed-200 청크 6445개 평균 199자 512토큰 초과 0개(0.0%) 임베딩 260초
fixed-400 청크 3236개 평균 397자 512토큰 초과 0개(0.0%) 임베딩 271초
fixed-1600 청크 830개 평균 1546자 512토큰 초과 770개(92.8%) 임베딩 189초
fixed-800은 청크 1,632개, 평균 787자, 512토큰 초과 0개였습니다. 임베딩 시간은 같은 머신에서 다른 실습이 함께 돌던 중에 잰 값이라 참고만 해 주세요.
800자까지는 512토큰을 넘는 청크가 없었고, 1600자에서는 830개 중 770개(92.8%)가 넘었어요. 넘친 부분이 어떻게 되는지는 실습 3에서 확인할게요.
실습 2: 크기별 검색 결과 비교하기
이제 질문 30개로 검색해 볼게요. 거리 계산은 HNSW(그래프를 따라가며 가까운 벡터를 찾는 근사 색인) 근사 검색(exact=False)과 전체 비교(exact=True)를 둘 다 돌려 상위 20개가 같은지도 봅니다.
py rag.py eval fixed-200 fixed-400 fixed-800 fixed-1600
fixed-200 recall@5 0.933 (28/30) 문단적중@5 0.300 (9/30) MRR@10 0.816 HNSW와 다른 질문 0개
fixed-400 recall@5 0.967 (29/30) 문단적중@5 0.500 (15/30) MRR@10 0.904 HNSW와 다른 질문 0개
fixed-800 recall@5 1.000 (30/30) 문단적중@5 0.600 (18/30) MRR@10 0.919 HNSW와 다른 질문 0개
fixed-1600 recall@5 1.000 (30/30) 문단적중@5 0.533 (16/30) MRR@10 0.944 HNSW와 다른 질문 0개
HNSW와 전체 비교의 결과가 모두 같은 데는 이유가 있어요. Qdrant는 벡터 크기가 indexing_threshold(기본 10,000KB, 256차원 벡터 1개를 1KB로 셉니다)를 넘어야 HNSW 색인을 만들어요. 384차원 벡터는 약 6,600개가 기준인데, 가장 큰 fixed-200도 6,445개라 아직 색인이 없습니다.
그러니 이 글의 컬렉션은 모두 전체 비교로 검색된 거예요.
크기별 결과는 이렇게 읽으면 돼요.
- 문서 recall@5: 200자에서 28/30, 400자에서 29/30이고, 800자부터는 30/30입니다. 글을 맞히는 일은 크기가 크든 작든 대부분 해내요.
- 문단 적중@5: 9/30, 15/30, 18/30, 16/30입니다. 800자가 가장 높았고 1600자에서는 다시 줄었습니다.
- MRR@10: 200자가 0.816으로 가장 낮고, 나머지 세 크기는 0.90~0.95 사이입니다.
800자부터는 문서 recall이 모두 30/30인데 문단 적중은 16~18개예요. 정답 글은 찾았지만 답이 든 청크는 상위 5개에 없는 질문이 12~14개라는 뜻이죠. 문서 recall만 보면 이 차이가 보이지 않으니까 두 지표를 같이 봐 주세요.
실습 3: 512토큰을 넘는 뒷부분은 버려집니다
trunc.py는 세 가지를 봐요.
512토큰을 넘는 청크마다 토크나이저의 글자 위치(offset_mapping)로 버려지는 뒷부분 길이를 재고, 넘는 청크 하나 뒤에 문장을 붙여 두 벡터를 비교합니다. 마지막으로 1600자 설정에서 문단 적중으로 센 질문 가운데 정답 문장이 버려진 뒷부분에 걸친 것을 셉니다. 이 마지막 부분은 실습 2의 eval이 남긴 out/results-fixed-1600.json을 읽어요.
py trunc.py
max_seq_length: 512
512토큰을 넘는 청크 770개, 버려지는 뒷부분 평균 399자, 최대 671자
chat-scale-out 청크: 669토큰
뒤에 문장을 붙인 청크와의 코사인 유사도: 1.000000
1600자 문단 적중 가운데 정답 문장이 버려진 뒷부분에 걸친 질문: 2개
669토큰짜리 청크 뒤에 Redis 문장을 하나 더 붙여 임베딩해도 벡터가 완전히 같아요.
왜 그럴까요? sentence-transformers는 max_seq_length(512)를 넘는 입력을 경고 없이 잘라서, 512토큰 뒤의 글은 벡터에 아무 영향을 주지 않기 때문이에요. 이 문서에서 512토큰은 앞쪽 1,200자 안팎에 해당해요.
그래서 1600자의 문단 적중 16개를 그대로 믿기도 어려워요. 이 중 2개는 정답 문장이 512토큰 뒤, 벡터에 반영되지 않은 부분에 있었습니다. 앞부분이 질문과 비슷해서 올라왔고, 정답 문장은 우연히 같은 청크에 들어 있었을 뿐이에요.
"청크가 클수록 맥락이 많아 좋다"는 말은 모델의 최대 입력 길이 안에서만 맞습니다.
정리
범위: 이 사이트 글 53편, 질문 30개, intfloat/multilingual-e5-small(최대 512토큰, 384차원), Qdrant 1.19.1, 모든 컬렉션이 HNSW 색인 없이 전체 비교로 검색.
- 고정 길이 200, 400, 800, 1600자의 문단 적중@5는 9/30, 15/30, 18/30, 16/30으로 800자가 가장 높았습니다. 청크 크기는 자기 문서로 만든 질문셋으로 재서 정해 주세요.
- 1600자 청크 830개 중 770개가 512토큰을 넘어 평균 399자가 경고 없이 버려졌고, 뒤에 문장을 붙여도 코사인 유사도는 1.000000이었으며, 문단 적중 16개 중 2개는 정답 문장이 버려진 부분에 있었습니다. 청크는 임베딩 모델의 최대 입력 길이 안으로 잡아 주세요.
- 이 글에 싣지 않은 추가 측정에서 20% 겹침(overlap, 청크의 끝부분을 다음 청크 앞에 한 번 더 넣는 방식)은 청크 수를 24% 늘렸지만 문단 적중은 400자에서 15에서 13, 800자에서 18에서 17이 됐습니다. 겹침은 켜기 전에 자기 질문셋으로 재 보세요.
- 같은 추가 측정에서 빈 줄 기준 문단으로 자르고 글 제목과 소제목을 붙인 설정은 문단 적중@5가 16/30이었고, 여기서 코드 블록을 빼자 24/30으로 올랐습니다. 이 질문셋은 정답 문장을 모두 산문에서 골랐으니, 코드를 찾는 질문이 있는 문서는 따로 재 주세요.