canwefly-log
PI Lab 8주 과정 · 2/6

#2PDF RAG를 처음부터 만들어보면서 마주친 것들

임베딩 호출 최적화, 청킹 전략 비교, 한국어 임베딩 분포, 환각 방지 프롬프트, 멀티모달 PDF, 평가 체계 전환까지. RAG 한 사이클을 직접 굴리며 부딪힌 의사결정들.

by canwefly··17분 읽기·#pi-lab #rag #supabase #pgvector #fastapi #ai

들어가기 전에

PDF 문서 기반 RAG 챗봇을 처음부터 끝까지 한 사이클 굴려본 기록이다. 단순한 RAG 튜토리얼은 잘 정리된 게 많지만, 막상 실제 문서를 던져 넣으면 그때부터 의사결정의 연속이 시작된다. 그중에서 시간을 잡아먹은 트러블슈팅과 그 결정의 근거를 정리한다.

깊게 다룬 두 가지 횡단 주제(평가의 역설, 인프라 함정)는 4편5편에서 따로 풀었다.


임베딩 호출이 너무 느렸다 — 개별 vs 리스트

처음엔 청크 하나하나를 따로 임베딩하는 코드였다. 1,000페이지짜리 보고서를 올리면 청킹보다 임베딩 HTTP 호출이 병목이었다.

Ollama API 문서를 다시 들여다보다가 발견한 게 있다. prompt 파라미터가 문자열 하나가 아니라 배열도 받는다. 한 번의 HTTP 호출로 여러 청크를 한꺼번에 임베딩할 수 있다는 뜻.

8개 PDF, 총 1,603페이지로 비교 실험을 돌렸다.

문서페이지개별 호출리스트 호출차이
docuflow_test123.31s2.56s+29.3%
창업지원사업 통합공고문10525.25s15.53s+62.6%
행정안전백서22443.38s25.31s+71.4%
한국인의 역량40588.75s51.43s+72.5%

평균 63.5% 빠름. 페이지 수가 늘수록 차이가 커진다. HTTP 왕복 비용이 압도적이라는 뜻. 여기서 하나 배웠다 — API 문서의 입력 형식을 한 줄 한 줄 다시 본다. 평소 무심코 넘기던 곳에 자주 이런 게 숨어 있다.


청킹 전략 — 12개 config 비교, 4개로 좁히기

다음 의사결정. 청크 크기, 오버랩, splitter 종류. 흔한 디폴트(chunk_size=1000, overlap=200)를 쓰면 그럭저럭 동작하지만, 그 값이 우리 데이터에 맞는지는 별개의 이야기다.

비교 테스트를 위해 전용 비교 UI(/compare) 를 만들었다. 한 PDF를 올리면 N개의 청킹 설정으로 동시에 임베딩하고, 같은 질문으로 각 설정의 검색 결과를 나란히 보여주는 화면. 처음엔 splitter 2종 × chunk_size 3종 × overlap 2종 = 12개 로 다 돌려본 뒤, 의미 있는 차이를 보이는 4개로 좁혔다.

#Splitterchunk_sizechunk_overlap
1recursive25625
2recursive51250
3recursive1024100
4nltk1000100

4개 문서 × 4개 config × 다양한 질문 — 결론은 "문서 종류에 따라 최적값이 다르다". 단락 위주의 정책 보고서는 chunk_size 1024가 좋고, 표가 많이 들어간 공고문은 작게 잘라야 표 한 셀이 다른 청크로 흩어지지 않는다. 정답이 한 개가 아니다. 디폴트로 시작하되, 타겟 문서로 한 번은 비교 실험을 돌리는 것이 RAG 첫 의사결정.


한국어 임베딩의 분포 — nomic 에서 bge-m3

이게 가장 결정적인 의사결정 중 하나였다.

처음엔 nomic-embed-text (768차원)를 썼다. 영어 RAG 튜토리얼의 단골 모델. 잘 동작했다 — 까지는 좋았는데, threshold=0.5 이상만 통과시키는 필터링이 거의 무의미해지는 현상이 발견됐다. 한국어 문서에서 무관한 질문을 던져도 유사도가 0.85 이상이 나오는 식.

원인을 들여다보니 모델별 한국어 유사도 분포가 완전히 다르다. nomic은 모든 한국어 텍스트를 0.65~0.87의 좁은 범위에 몰아넣는다. "관련/비관련" 구분 자체가 흐릿했다.

bge-m3(BAAI, 다국어 특화 1024차원)로 교체했더니 분포가 0.39~0.64로 풀렸고, threshold 필터링이 비로소 의미를 갖기 시작했다. 같은 질문에 대한 검색 품질이 눈에 띄게 올라간 건 덤.

임베딩 모델 분포 차이는 평가 체계 전반에 영향을 주는 횡단 주제라, 4편 — RAG 평가의 역설에서 따로 자세히 분석했다.


환각 방지 — 시스템 프롬프트 V1 → V2

검색 품질이 잡히고 나니 다음 문제가 보였다. 문서에 없는 질문을 받으면 LLM이 그럴듯하게 지어낸다.

V1 프롬프트(제약 없음) vs V2 프롬프트(근거 기반)로 같은 질문 셋을 돌려봤다.

질문 유형V1 응답V2 응답
문서에 있는 질문 (Pro 플랜 금액)정답이지만 근거 없음정답 + 문서 섹션 인용
문서에 없는 질문 (2025 매출액)분기별 매출 6,000만 달러로 추론 (환각)"문서에서 확인할 수 없다" 거부
무관한 질문 (프로야구 우승팀)거부하면서 서비스 설명을 길게 늘어놓음짧게 거부

V2의 골자는 단순했다.

- 답은 반드시 제공된 컨텍스트에서만 가져와라.
- 컨텍스트에 없으면 "문서에서 확인할 수 없습니다" 라고 답해라.
- 답할 때 어느 문서·섹션에서 가져왔는지 인용해라.
- 무관한 질문에는 짧게 거부해라.

이걸 시스템 프롬프트의 한 섹션으로 못 박는 것만으로 환각이 절반 이상 줄었다. 모델 바꾸지 않고도 품질이 올라가는 가장 싼 수단이 프롬프트.


멀티모달 PDF — 텍스트만으론 부족했다

여기까지가 텍스트 RAG 베이스라인. 그런데 실제 보고서·논문 PDF를 올려보니 본문 텍스트만으로는 검색이 안 되는 질문이 한 무더기였다. 표에 들어 있는 수치, 차트의 비교, 인포그래픽 안의 텍스트 등.

파이프라인을 텍스트 + 표 + 이미지로 확장했다.

추출 대상도구처리
텍스트pdfplumber페이지별 텍스트 추출
pdfplumber마크다운 테이블로 변환, 별도 청크
이미지PyMuPDFGPT-4o-mini Vision으로 자연어 설명 생성 → 텍스트로 임베딩

여기서 자잘한 트러블슈팅이 두 번 있었다.

페이지 분할 표 자동 병합

보고서의 큰 표는 페이지 경계에서 잘려서 들어온다. 그대로 두면 표 절반이 청크 A에, 나머지 반이 청크 B에 들어간다. 검색 시 둘 중 하나만 잡히면 답변이 반쪽이 된다. 두 가지 케이스로 자동 병합했다.

  • 케이스 1: 동일 헤더가 양 페이지에 반복 → 헤더 한 번만 두고 행만 이어 붙이기
  • 케이스 2: 헤더 없이 데이터만 이어지는 경우 → 컬럼 수 동일 + 첫 셀이 숫자면 동일 표로 가정

차트는 끝까지 포기

Vision API에 차트 이미지를 던져 봤지만, 수치 자체를 매번 다르게 읽었다. 정답이 2,791억인 막대 차트에서 239억 / 393억 / 182억이 나오는 식. 모델 한계라 깔끔히 받아들였고, 그 대신 본문에 같은 수치가 있을 때 거기서 가져오게 프롬프트로 유도하는 방향으로 갔다.

차트 환각의 자세한 분석과 "정직한 답변" 설계는 4편 — RAG 평가의 역설 첫 에피소드에 정리했다.


평가 체계로 — Playwright E2E → LangSmith

이 분기점이 가장 중요했다. 평가 체계가 없으면 그 다음 어떤 개선도 미신이 된다.

처음엔 Playwright로 E2E 자동화를 짰다. 2개 문서 × 6가지 변인(Top-K, Threshold, Prompt, Re-ranking, Query Rewriting, 복합 최적조합) = 총 54건 실험. 브라우저를 띄워 질문을 하나씩 던지고 답을 채점하는 방식.

RAG 실험실 UI — Top-K, Threshold, Temperature, 프롬프트, Query Rewriting, Re-ranking을 한 화면에서 조정하는 로컬 실험 툴 로컬에 따로 만든 /experiment UI. 위 6가지 변인을 하나씩 바꿔가며 같은 질문을 돌릴 수 있게 했다.

이걸로 결론 몇 개를 뽑았다.

  • Top-K: 문서별 최적값이 다르다. 어떤 문서는 K=3, 어떤 문서는 K=7.
  • Threshold: 0.7 이상은 정답까지 같이 잘려나간다. 0.3~0.5 권장.
  • 시스템 프롬프트: 수치 중심 문서는 strict, 개념 중심 문서는 basic이 더 좋음.
  • 리랭킹: 노이즈 제거에 효과적이지만 과잉 필터링 위험.
  • Query Rewriting: 단발 질문에선 효과 없음. 멀티턴에서만 의미.

그런데 Playwright E2E의 한계가 슬슬 보였다 — 느리고, 트레이싱이 빈약했다. 검색 결과가 좋아진 건지 LLM 답변이 좋아진 건지 분리해서 보기 힘들었다. 그래서 LangSmith 기반 정량 평가로 전환했다.

  • 파이프라인 전 구간 (검색 → 프롬프트 → LLM 응답) 트레이싱
  • 자동 채점 평가기 4종:
    • correctness — A·C유형 정답 일치 (LLM-as-Judge)
    • faithfulness — 컨텍스트 근거 충실도 (LLM-as-Judge)
    • rejection_accuracy — C유형 거부 성공 (규칙 기반)
    • table_retrieval — 표 질문에서 마크다운 표가 검색됐는지 (규칙 기반)

LangSmith로 옮긴 뒤로 한 실험을 돌리고 결과 분석하는 사이클이 분 단위로 줄었다. 정량 평가가 자리잡으니 비로소 "고도화" 라는 단어가 의미 있게 들리기 시작.

평가 체계 자체가 만들어내는 역설들 — 올바른 거부가 0점을 받거나, 같은 설정 두 번 돌렸을 때 점수가 뒤집히는 — 은 4편에 따로 묶었다.


멀티모달 RAG 5회 실험 — 변수 1개씩 격리

평가 체계 위에서 멀티모달 RAG에 대해 변수 1개만 바꾸는 실험을 5회 반복했다 (생성형AI 기술백서, 표 34개·차트 8개, QA 15개).

#실험설정목적
1k3-basic (A)top_k=3, basic, rerank OFF베이스라인
2k3-basic (B)동일 설정, 다른 실행자LLM 비결정성 측정
3k5-strict-reranktop_k=5, strict, rerank ONrerank 효과
4k5-stricttop_k=5, strict, rerank OFFstrict 단독 효과
5k5-strict + 표 병합+ 페이지 분할 표 병합병합 효과
지표k3-basic (A)k3-basic (B)k5-strict-rerankk5-strictk5-strict + 병합
correctness0.500.800.650.600.80
faithfulness0.6670.6670.7730.6670.70
rejection_accuracy0.800.400.400.600.00
table_retrieval0.8890.8890.6670.8890.889

여기서 세 가지 큰 발견이 있었다.

1. 검색 개선 ≠ 답변 개선. 표 병합으로 정답이 들어 있는 컨텍스트가 검색됐는데도 LLM이 표의 최대값을 잘못 읽어 답변이 틀린 케이스가 반복됐다. "검색에 정답 청크가 들어왔는가""LLM이 그 청크로 올바른 답을 만들었는가" 는 분리해서 측정해야 한다는 게 분명해졌다.

2. 1회 실행 결과를 확정 짓지 말 것. 같은 k3-basic 설정인데 실행 시점에 따라 correctness 0.50 vs 0.80, rejection_accuracy 0.80 vs 0.40. LLM 비결정성 + 소규모 데이터셋(15개) → 노이즈가 시그널보다 크다. 결론을 내리기 전에 반복 실험과 평균이 필수.

3. 차트는 안 풀린다. Vision API가 같은 차트를 매번 다르게 읽는다. 모델·프롬프트를 바꿔도 한계. 결론은 "못 읽는 것을 어떻게 다룰 것인가" 로 무게 중심을 옮기는 것. (이 설계는 4편 첫 에피소드에서 자세히.)


정리하면서

한 사이클 굴려보고 박힌 것을 한 줄씩 적으면 이렇다.

  1. RAG 품질의 60%는 청킹·임베딩에서 결정된다. 모델·프롬프트는 그 위에 얹는 마무리.
  2. 평가 체계가 없으면 어떤 고도화도 미신. LangSmith든 자체 채점이든, 수치로 분리해서 비교할 수 있는 환경을 먼저 만들고 그 다음에 변수를 바꿔라.
  3. 검색이 맞아도 답변이 틀릴 수 있다. 두 축을 분리해서 측정하지 않으면 어디서 망가졌는지 모른다.

그리고 가장 큰 마인드셋 변화는 "잘 되게 만들기" 에서 "잘 되고 있는지 알게 만들기" 로 질문이 옮겨간 것.


다음 편

다음은 영역을 한 번 더 넓힌다 — 영상·음성 입력.



프론트엔드 6년차 개발자가 PI Lab에서 AI 엔지니어링 8주 과정을 수료하며 정리한 기록입니다. 매일 Cursor·Claude로 바이브코딩하면서도 머신러닝 안쪽은 잘 몰랐던 개발자가, AI 시스템을 본격적으로 들여다본 회고입니다.