내 문서로 답하게 하기 — RAG를 만들기 전에 평가부터 세운 이유
파일을 올리면 그 안에서 근거를 찾아 출처와 함께 답하는 RAG를 만들었습니다. 임베딩 모델은 속도로, 검색 품질은 직접 만든 질문 세트로 쟀습니다. 그리고 결론은 "모델이 아니라 검색이 답을 결정한다"였습니다.
이 글의 독자: 지난 글에서 이어집니다. RAG를 처음 듣는 분도 따라올 수 있게 썼습니다.
들어가며 — 4B 모델은 내 보험 약관을 모른다
모델은 학습한 내용만 압니다. 제 암보험 약관이 “몇 조 몇 항에서 무엇을 보장하는지”는 당연히 모릅니다. 물어보면 그럴듯하게 지어냅니다.
해결책은 단순합니다. 질문할 때 약관의 관련 부분을 같이 보여 주는 것. 이게 RAG(Retrieval-Augmented Generation, 검색 증강 생성)입니다.
- 문서를 미리 잘게 나눠(청크) 저장해 둔다
- 질문이 오면 관련 있는 조각을 찾는다 (retrieval)
- 그 조각들을 번호를 붙여 프롬프트에 넣고, “이 근거로만 답하라”고 시킨다
어려운 건 2번입니다. 그리고 2번이 잘 되는지 알려면 재는 방법이 먼저 있어야 합니다.
Step 0 — 오늘 나올 용어 5개
| 용어 | 뜻 | 왜 중요한가 |
|---|---|---|
| 임베딩 | 글을 숫자 벡터(여기선 1024개 숫자)로 바꾼 것 | 뜻이 비슷한 글은 벡터도 가까움 |
| 청크 | 문서를 잘게 나눈 조각 (여기선 최대 약 400토큰) | 검색과 프롬프트의 단위 |
| pgvector | PostgreSQL에서 벡터 검색을 하게 해주는 확장 | 별도 벡터 DB 없이 기존 DB로 |
| Hit@k | 정답 조각이 검색 결과 상위 k개 안에 있는 비율 | 프롬프트에 들어가느냐의 문제 |
| MRR | 정답이 몇 등에 나왔는지의 역수 평균 (1등=1, 2등=0.5, …) | 정답이 위에 있을수록 높음 |
Step 1 — 구조: 무거운 일은 GPU 밖으로 (결정 1)
GPU는 이미 vLLM이 차지하고 있습니다. 그래서 RAG의 다른 일들은 CPU 컨테이너로 뺐습니다.
| 일 | 담당 | 어디서 |
|---|---|---|
| 파일에서 글자 뽑기 (PDF, Word, PPT, HTML) | Apache Tika | CPU 컨테이너 |
| 글 → 벡터 | TEI (Hugging Face의 임베딩 서버) | CPU 컨테이너 |
| 벡터 저장과 검색 | pgvector (HNSW 인덱스, 코사인 거리) | 기존 PostgreSQL |
| 나머지 전부 | 게이트웨이 | — |
Tika에서는 결과를 Markdown으로 받습니다. 그러면 원래 문서의 제목 구조(#, ##)가 남아서, 청크를 제목 경계에서 자를 수 있습니다. 청크 하나는 한 섹션을 넘지 않고, 앞뒤로 50토큰씩 겹치고, “3장 > 보장 내용 > 암 진단” 같은 제목 경로를 같이 임베딩합니다. 조각만 보면 무슨 이야기인지 모르는 경우가 많기 때문입니다.
업로드 처리는 별도 큐 시스템 없이 documents 테이블 자체를 작업 큐로 썼습니다. 워커는 FOR UPDATE SKIP LOCKED로 대기 중인 문서를 하나씩 가져갑니다. 사용자 수가 한 가족 수준이라 이걸로 충분합니다.
사용 방법은 모델 이름 하나입니다. 평소처럼 /v1/chat/completions를 부르되 모델을 rag:qwen3.5-4b처럼 보내면, 게이트웨이가 검색을 끼워 넣습니다. 답은 평소처럼 스트리밍되고, 끝나기 직전에 출처 목록 조각이 하나 붙습니다. 웹 UI는 답 속의 [1], [2]를 그 출처로 연결합니다.
Step 2 — 임베딩 모델 고르기: 질문이 아니라 색인이 비용이다
후보는 둘이었습니다. 오래 검증된 bge-m3, 그리고 더 최근의 Qwen3-Embedding-0.6B. 크기는 비슷합니다(약 6억 파라미터, 1024차원). CPU에서 쟀습니다.
| bge-m3 | Qwen3-Embedding-0.6B | |
|---|---|---|
| 질문 하나 임베딩 (p50) | 27 ms | 103 ms |
| 문서 색인 속도 | 1.9 청크/초 | 0.4 청크/초 |
| 청크 1만 개 색인 | 약 86분 | 약 7시간 |
질문 쪽은 둘 다 문제없습니다. 30~100ms는 사용자가 못 느낍니다. 진짜 비용은 문서를 올릴 때입니다. 그리고 그 차이 5배는 모델 자체보다 실행 방식에서 왔습니다. bge-m3는 저장소에 ONNX 가중치가 있어서 최적화된 런타임으로 돌고, Qwen3는 그렇지 않았습니다.
처음 계획 초안에는 “CPU에서 초당 15~25 청크”라고 적혀 있었습니다. 실제는 1.9였습니다. 10배 빗나간 겁니다. 잴 수 있는 걸 추정하지 말라는 규칙이 또 맞았습니다.
함정: 시작하자마자 메모리 초과로 죽는다
TEI가 처음에 계속 죽었습니다. 원인은 워밍업이었습니다. TEI는 시작할 때 모델의 최대 입력 길이(bge-m3는 8192토큰)로 한 번 돌려 봅니다. attention 메모리는 길이의 제곱으로 늘어나서, 8192토큰이면 층 하나에 약 4GB가 필요합니다. --max-batch-tokens 2048로 상한을 걸자 4.1GB에서 15초 만에 떴습니다.
Step 3 — 평가부터 (결정 2)
만들어서 몇 번 물어보고 “잘 되네”라고 하는 건 평가가 아닙니다. 그래서 질문 세트를 먼저 만들었습니다.
- 공개 문서 13개: RFC 4개, 논문 3개(PDF), 위키백과 6개 → 청크 898개
- 질문 36개(사실 확인 / 다른 말로 바꿔 묻기 / 영어 문서에 한국어로 묻기) + 엉뚱한 질문 5개
- 질문 초안과 첫 판정은 AI 코딩 도우미(Claude)가 청크를 보고 만들었고, 제가 검토했습니다
라벨을 어떻게 다느냐가 중요했습니다. “정답은 청크 #4821”처럼 달면, 청크 크기를 바꾸거나 다시 색인하는 순간 라벨이 전부 무효가 됩니다. 그래서 “이 문서 안에, 이 문구가 들어 있는 조각”으로 달았습니다. 청크를 어떻게 자르든 살아남는 라벨입니다.
첫 결과, 그리고 “틀린 게 아니었던” 오답들
| Hit@1 | Hit@5 | Hit@10 | MRR |
|---|---|---|---|
| 0.64 | 0.89 | 0.97 | 0.75 |
오답을 하나씩 읽어 보다가 이상한 걸 발견했습니다. 6개는 검색이 틀린 게 아니라 라벨이 좁았던 겁니다. 같은 사실이 문서의 다른 곳에 다른 표현으로도 적혀 있었고, 검색은 그쪽을 찾아왔습니다.
두 모델을 비교하기 전에, 한 모델의 오답부터 직접 읽을 것. 라벨이 틀렸으면 비교도 틀립니다.
라벨을 고친 뒤 두 임베딩 모델을 같은 세트로 비교했습니다.
| Hit@5 | MRR | |
|---|---|---|
| bge-m3 | 0.89 | 0.76 |
| Qwen3-Embedding-0.6B | 0.89 | 0.74 |
무승부. 품질이 같으면 5배 빠른 쪽입니다. bge-m3로 확정했습니다.
같이 내린 결정 두 개도 숫자로 정했습니다.
- 키워드 검색(BM25 등)은 아직 안 붙인다. 오답 4개 중 “정확한 단어가 안 맞아서” 놓친 건 1개뿐이었습니다.
- 유사도 하한선을 두지 않는다. 엉뚱한 질문 중 가장 높은 점수(0.556)가 진짜 정답 중 가장 낮은 점수(0.548)보다 높았습니다. 어디에 선을 그어도 정답을 자르거나 엉뚱한 걸 통과시킵니다. 대신 프롬프트에서 “근거에 없으면 없다고 말하라”고 시켰습니다.
Step 4 — 답의 품질: 결론을 바꾼 20개
검색 숫자 다음에는 답 자체를 봤습니다. 20개의 답을 근거 원문과 대조해 하나씩 판정했습니다.
| 상황 | 결과 |
|---|---|
| 정답 조각이 근거 안에 있음 | 13 / 13 정확, 근거에 충실 |
| 엉뚱한 질문 | 4 / 4 “문서에 없다”고 답함 |
| 검색이 정답을 놓침 | 0 / 3 완전히 맞은 답 없음 (하나는 관계없는 근거를 섞어 그럴듯하게 틀림) |
이 표가 이후 방향을 정했습니다.
4B 모델은 정답 조각을 받으면 맞게 답합니다. 틀리는 건 정답 조각을 못 받았을 때입니다. 그러니 더 큰 모델이 아니라 더 나은 검색에 시간을 써야 합니다.
실제 스택에서 검색을 포함한 첫 글자까지 시간은 0.33초였습니다(질문 임베딩 32ms, 벡터 검색 15ms). RAG가 느려서 문제 될 일은 없습니다.
Step 5 — 만들면서 밟은 것들
1. 워커 두 개가 소리 없이 죽었다. 청크 텍스트에서 링크 주소 같은 잡음을 지우는 정규식을 추가하고 다시 색인했더니, 문서 2개가 한 시간 동안 “처리 중”에서 멈춰 있었습니다. 7,000자짜리 한 줄에서 Java 정규식이 재귀하다 스택이 넘쳤고(StackOverflowError), 이건 Exception이 아니라 Error라서 워커의 catch를 그냥 지나갔습니다. 워커 코루틴 두 개가 둘 다 끝나 버린 겁니다. 정규식을 고치고, 워커는 Throwable을 잡게 바꾸고, 20만 자짜리 한 줄로 테스트를 추가했습니다.
2. 20개 넘게 보내면 500. 임베딩을 한 번에 20개 넘게 요청하면 에러가 났습니다. Spring WebClient가 응답 본문을 기본 256KB까지만 버퍼링하는데, 1024차원 벡터 20여 개가 그걸 넘었습니다. 4MB로 올렸습니다.
3. 맥에서 올린 한글 파일 이름이 다른 글자였다. macOS는 한글 파일 이름을 자모로 풀어서(NFD) 보냅니다. 화면에서는 똑같아 보이지만 "보험.pdf"와 같은 문자열이 아닙니다. 업로드 때 NFC로 정규화하고, 이미 저장된 이름은 마이그레이션으로 고쳤습니다.
4. 같은 사람의 문서만 찾아야 한다. 벡터 검색에 “내 문서만” 조건을 걸면, HNSW 인덱스가 가까운 후보를 먼저 뽑은 뒤 조건으로 걸러서 5개를 달라고 했는데 그보다 적게 올 수 있습니다. pgvector의 hnsw.iterative_scan = relaxed_order로 모자라면 더 찾게 했습니다.
정리 — 오늘 얻은 것
숫자:
- 임베딩: bge-m3, 질문 27ms, 색인은 Qwen3-Embedding보다 5배 빠름, 품질은 동률
- 첫 검색 품질: Hit@5 0.89, MRR 0.75
- 정답 조각을 받은 답 13/13 정확, 못 받은 답 0/3
방법:
- 라벨은 청크 번호가 아니라 문서 + 문구로 — 다시 잘라도 살아남게
- 비교하기 전에 오답을 직접 읽어서 라벨부터 검증
- 하한선, 키워드 검색 같은 기능은 숫자가 필요하다고 말할 때 추가
결론 하나: 이 규모에서는 검색이 답을 결정합니다.
다음 글
공개 문서로는 Hit@5가 0.89였습니다. 그런데 제 진짜 문서(한국어 보험 약관, 계약서, 자동차 설명서)를 넣자 숫자가 떨어졌습니다. 다음 글은 그걸 끌어올리려고 시도한 다섯 가지 아이디어 이야기입니다. 넷은 실패했고, 하나가 GPU 1.3GB를 받아 갔습니다.