RAG의 품질 상한선은 코퍼스의 품질이 결정한다. 아무리 정교한 임베딩과 검색 알고리즘을 사용해도, 원본 데이터가 정제되지 않았으면 결과도 나쁘다. 이 프로젝트에서 청킹 파이프라인이 --- 구분선과 단락 경계를 존중할 수 있는 이유는, 데이터가 수집 단계에서 이미 그런 구조로 정제되어 있기 때문이다.
books/
├── 0. 자체 정리/ ← 가장 고품질: 1차 수집 → AI 재분류 → 주제별 MD
│ ├── 01_음양론/
│ ├── 02_오행론/
│ ├── ... (총 20개 주제 폴더 + 부록)
│ └── 20_실전사례/
├── 1. 나의 사주명리-심화편/ ← 고품질: 전자책 추출, 장/절 구조 유지
├── 2. 사주명리 실전 100구문/ ← 고품질: 전자책 추출
└── 99. 온라인 자료 (정리 X)/ ← 2차 수집 원본: PDF/TXT, 구조 불균일
핵심 관찰: 계층 0은 온라인에서 1차 수집한 자료를 AI를 활용하여 카테고리별로 정밀 재분류한 결과물이다. 원본을 주제별(음양론, 오행론, 격국론 등)로 나누고, 마크다운 구조(--- 구분선, ## 헤딩)를 부여하여 청킹 품질을 극대화했다. 계층 99는 이후에 진행된 2차 온라인 수집본으로, 아직 정리되지 않은 상태이다. 청킹 품질은 0~2에서 높고 99에서 상대적으로 낮다. 즉, 수집 → AI 기반 재분류 → 구조화라는 단계를 거친 데이터일수록 RAG 성능이 좋다.
이 프로젝트에서 온라인 자료를 수집할 때 강제하는 규칙:
1. 출처 필수:
모든 파일에 `출처: URL` 또는 `https://` 포함 필수.
없으면 자동 삭제:
grep -cE '(https?://|출처:)' "$f" → 0이면 rm
출처가 없는 문서는 신뢰할 수 없으므로 코퍼스에 포함하지 않는다.
2. --- 구분선으로 주제 분리:
하나의 TXT 파일에 여러 주제가 있을 때, ---로 명확히 구분한다. 이것이 청킹 단계에서 "주제 경계를 넘는 청크"가 생기지 않게 하는 핵심 장치이다.
# 좋은 예: 주제가 명확히 분리됨
갑목(甲木)은 양의 목기운으로, 큰 나무를 상징한다...
---
을목(乙木)은 음의 목기운으로, 풀이나 덩굴을 상징한다...
3. AI 생성 콘텐츠 금지:
LLM이 만든 내용을 코퍼스에 넣으면 환각이 재생산된다. 모든 문서는 인간이 작성한 원문(학술 논문, 교재, 전문가 글)이어야 한다.
4. 자동 수집 시 파일 크기 제한:
자동 수집 에이전트(saju-research-collector)가 새 파일을 생성할 때는 파일당 200줄 이하로 제한한다. 이는 에이전트의 안정성 문제(400줄+ 생성 시 실패) 때문이며, 코퍼스 전체의 제약은 아니다. 실제 코퍼스에는 400~1,700줄 규모의 파일도 정상적으로 포함되어 있다. 다만 하나의 파일에 너무 많은 주제가 섞이면 청킹 시 주제 오염이 발생할 수 있으므로, 가능하면 주제별로 파일을 분리하는 것이 바람직하다.
IT 회사에서 서비스 기획서나 Knowledge Base를 RAG 코퍼스로 구축할 때도 동일한 원칙이 적용된다:
| 원칙 | 사주 프로젝트 | IT 회사 적용 |
|---|---|---|
| 구조화된 마크업 | --- 구분선, ## 헤딩 |
Confluence 헤딩, Notion 블록, API doc의 endpoint별 분리 |
| 출처 추적 | URL, 페이지 번호 | 문서 ID, 버전, 작성자, 최종 수정일 |
| AI 생성 여부 표시 | 금지 | 태깅 (source: human vs source: ai-draft) |
| 중복 관리 | dedup.py 코사인 유사도 | 문서 버전 관리 + 이전 버전 아카이브 |
데이터 수집 파이프라인 예시 (Confluence → RAG):
Confluence API → 페이지 추출
→ HTML → Markdown 변환 (pandoc / html2text)
→ 메타데이터 추출 (제목, 스페이스, 라벨, 최종수정일)
→ 빈 페이지 / 템플릿 필터링
→ 구조화: 헤딩 기준 섹션 분리
→ 이전 버전 대비 diff → 변경분만 재처리
→ chunks.jsonl에 추가
# chunk.py 핵심 파라미터
--chunk-size 512 # 최대 512자 (글자 단위, 토큰이 아니라 char)
--overlap 64 # 64자 오버랩으로 문맥 단절 방지
overlap(오버랩)이란: 이전 청크의 마지막 64자를 다음 청크의 앞부분에 중복 포함하는 것이다. 예를 들어 "...갑목은 강직한 성격이다."로 끝나는 청크가 있으면, 다음 청크는 "갑목은 강직한 성격이다. 따라서 리더십이..." 처럼 이어진다. 이렇게 해야 청크 경계에서 끊긴 문장이 어느 쪽 청크에서든 완전한 문맥으로 검색될 수 있다.
512자를 선택한 이유:
임베딩 모델과의 관계 — 발견된 버그와 교훈:
이 프로젝트를 분석하는 과정에서 Dense 임베딩의 성능 손실 버그가 발견되었다. paraphrase-multilingual-MiniLM-L12-v2의 모델 아키텍처는 512 토큰을 지원하지만(max_position_embeddings: 512), sentence-transformers 라이브러리의 기본값이 128 토큰으로 설정되어 있어 청크의 앞부분만 임베딩되고 나머지가 잘리고 있었다.
| 항목 | 값 |
|---|---|
| 모델 아키텍처 지원 | 512 토큰 |
| 라이브러리 기본값 (버그, 수정 완료) | 128 토큰 |
| 한국어 512자 → 실제 토큰 수 | ~337 토큰 |
| 128 토큰 한계로 실제 임베딩 반영 | 앞부분 ~200자 (전체의 ~39%) |
| 128 vs 512 임베딩 코사인 유사도 | 0.55 (정보가 크게 다름) |
수정 방법: model.max_seq_length = 512 한 줄 추가 후 인덱스 재빌드. 다만 이 모델이 128 토큰 기준으로 학습되었을 수 있어, 512로 확장 시 품질 향상 정도는 벤치마크로 확인이 필요하다.
그럼에도 시스템이 동작한 이유:
교훈: 라이브러리의 기본값을 맹신하지 말 것. 모델 아키텍처가 지원하는 범위와 라이브러리 설정값이 다를 수 있다. 임베딩 파이프라인 구축 시 반드시 max_seq_length와 실제 청크의 토큰 수를 비교 확인해야 한다. 이런 종류의 설정 불일치는 에러를 발생시키지 않고 조용히 성능을 깎아먹기 때문에 발견이 어렵다.
64자 overlap을 선택한 이유:
단순히 512자마다 자르지 않는다. 의미 단위를 존중하는 계층적 분할:
Level 1: --- 구분선으로 섹션 분리 (atomic boundary, 절대 넘지 않음)
Level 2: 빈 줄(\n\n)로 단락 분리
Level 3: 문장 단위 분할 (단락이 512자 초과 시)
핵심 코드 (chunk.py:116-137):
def _split_into_paragraphs(text: str) -> list[str]:
# --- 구분선이 있으면 섹션별로 분리, 각 섹션을 atomic 단락으로
if _SECTION_SEPARATOR.search(text):
sections = _SECTION_SEPARATOR.split(text)
paragraphs = []
for section in sections:
section = section.strip()
if not section:
continue
paragraphs.append(section)
return paragraphs
# 없으면 빈 줄로 분리
parts = re.split(r"\n{2,}", text)
return [p.strip() for p in parts if p.strip()]
왜 --- 구분선이 중요한가:
---로 주제가 구분됨---를 atomic boundary로 설정하면 주제 오염을 원천 차단IT 서비스에서의 동일한 구조:
API 문서(Swagger/OpenAPI)는 endpoint별로 자연스러운 경계가 있고, Confluence 페이지는 ## 헤딩으로 섹션이 나뉜다. 이런 문서는 --- 대신 헤딩(##)을 atomic boundary로 사용하면 된다. 예를 들어 GET /api/users와 POST /api/users가 같은 청크에 섞이면 검색 시 혼란을 주지만, endpoint별로 청킹하면 정확한 결과를 얻는다.
이것이 가능한 전제 조건:
4장에서 설명한 대로, 수집 단계에서 데이터에 이미 --- 구분선이 포함되어 있어야 한다. 구분선이 없는 비정형 문서(스캔 PDF, 레거시 Word 파일 등)에는 이 전략이 작동하지 않으며, 추가적인 전처리(LLM 기반 섹션 감지, 규칙 기반 헤딩 추출)가 필요하다.
단락이 512자를 초과할 때 문장 단위로 분할하는데, 한국어 종결어미를 고려한다:
sentences = re.split(
r"(?<=[.!?。!?\n])\s*|(?<=[다요음함임됨것]\.)\s+",
para,
)
.!?。!? 뒤에서 분할 (범용)다., 요., 음., 함. 등 한국어 종결어미+마침표 뒤에서 분할def _detect_lang(text: str) -> str:
"""텍스트 첫 2000자의 문자 유형 비율로 언어 판별"""
# 한글 음절(AC00-D7AF) > 20% → ko
# CJK 통합(4E00-9FFF) > 20% → zh
# ASCII 알파벳이 대다수 → en
언어 태그는 청크 메타데이터에 저장되어, 검색 시 토크나이저 선택과 BM25 쿼리 확장 방향을 결정한다.