다국어 RAG 파이프라인의 구축·활용을 직접 실습하기 위해 구축했던 토이 프로젝트를 역으로 분석한 방법론 문서.
사주명리학(四柱八字)을 주제로 선정한 이유는, 사주명리학이 통계에 기반한 분석 + 한국어·중국어·영어 원문이 자연스럽게 혼재하여 다국어 청킹/임베딩/검색의 모든 문제를 한 프로젝트에서 경험할 수 있기 때문이다.
모든 실험과 운영은 로컬 환경(M1 Mac)에서 수행되었으며, 외부 서비스 의존 없이 완결된 파이프라인이다.
이 문서는 개요와 4개의 상세 문서로 구성된다.
| 문서 | 내용 | 핵심 질문 |
|---|---|---|
| 본 문서 (개요) | 용어 해설, 배경, 아키텍처 전체 그림, 설계 원칙 | RAG란 무엇이고, 왜 이렇게 만들었는가? |
| Part 1: 데이터 수집·정제·청킹 | 데이터 소스 계층, 정제 규칙, 청킹 전략, 임베딩 모델 버그 발견 | 원본 데이터를 어떻게 검색 가능한 단위로 만드는가? |
| Part 2: 임베딩·인덱싱·검색 | Dense/BM25 이중 인덱스, 하이브리드 검색, 적응형 가중치, 다국어 쿼리 확장 | 인덱스를 어떻게 만들고, 검색은 어떻게 동작하는가? |
| Part 3: 코퍼스 관리·자동 강화·에이전트 | 코퍼스 열화 유형, 최신화 전략, 자동 강화 사이클, LLM 에이전트 활용 | 코퍼스를 어떻게 최신 상태로 유지하고, LLM이 어떻게 활용하는가? |
| Part 4: 확장·Production·IT 적용 | 10K→1M+ 스케일링, 단계별 구축 옵션, IT 회사 Knowledge Base 적용 | 이것을 실제 서비스에 어떻게 적용하는가? |
이 문서에서 반복적으로 등장하는 핵심 용어를 먼저 정리한다. RAG나 임베딩 개념에 익숙하다면 건너뛰어도 된다.
LLM(대규모 언어 모델)이 답변을 생성하기 전에, 관련 문서를 먼저 검색하여 그 내용을 참고하게 하는 기법. LLM 단독으로는 학습 데이터에 없거나 부정확한 지식을 "환각(hallucination)"으로 지어내는 문제가 있다. RAG는 "검색한 원문을 근거로 답하라"고 강제하여 이 문제를 완화한다.
일반 LLM: 사용자 질문 → LLM → 답변 (학습 데이터에 의존, 환각 위험)
RAG: 사용자 질문 → [검색] → 관련 문서 발견 → LLM + 문서 → 답변 (근거 있음)
RAG가 검색 대상으로 사용하는 문서 모음 전체를 뜻한다. 이 프로젝트에서는 사주명리학 관련 PDF, 마크다운, 텍스트 파일 593개가 코퍼스를 구성한다. 코퍼스의 품질과 범위가 RAG 성능의 상한선을 결정한다. 아무리 검색 알고리즘이 좋아도, 코퍼스에 해당 정보가 없으면 찾을 수 없다.
긴 문서를 검색에 적합한 **작은 단위(청크)**로 분할하는 과정. 책 한 권(수만 자)을 통째로 검색하면 어느 부분이 관련 있는지 알 수 없으므로, 300~500자 정도의 의미 단위로 잘라서 각각을 독립적인 검색 대상으로 만든다. 너무 크게 자르면 검색 정밀도가 떨어지고, 너무 작게 자르면 문맥이 사라진다.
텍스트를 **숫자 벡터(수백 개의 소수점 숫자 배열)**로 변환하는 것. "갑목의 성격은 곧고 강하다"라는 문장이 [0.12, -0.34, 0.56, ...] 같은 384개 숫자의 배열이 된다. 의미가 비슷한 문장은 비슷한 벡터를 갖게 되므로, 벡터 간 거리를 계산하면 의미적 유사도를 수치로 비교할 수 있다. 이것이 "의미 검색"의 원리이다.
전통적인 키워드 기반 검색 알고리즘. 문서에 쿼리 단어가 몇 번 나오는지(TF), 그 단어가 얼마나 희귀한지(IDF)를 조합하여 점수를 매긴다. "甲木"을 검색하면 정확히 "甲木"이라는 글자가 있는 문서를 찾는다. 임베딩이 "의미"로 찾는 것이라면, BM25는 "글자"로 찾는 것이다. 둘을 합치면(하이브리드 검색) 각각의 약점을 보완한다.
BM25(키워드 매칭)와 Dense Embedding(의미 검색)의 결과를 합산하여 최종 순위를 매기는 방식. 예를 들어 "갑목 성격"을 검색하면:
1차 검색으로 후보를 추린 뒤, 더 정밀한 모델로 다시 순위를 매기는 2단계 검색. 1차(BM25+Dense)는 빠르지만 대략적이고, 2차(Cross-Encoder)는 느리지만 정밀하다. 쿼리와 문서를 동시에 입력받아 직접 관련성을 판단하므로, 1차 검색보다 훨씬 정확한 순위를 만든다. 다만 전체 코퍼스에 적용하면 너무 느리기 때문에, 1차에서 20~40개 후보를 추린 뒤 이들에만 적용한다.
임베딩 벡터 간의 거리(유사도)를 계산하여 가장 가까운 벡터를 찾는 것. 이 프로젝트에서는 9,586개 청크의 벡터와 쿼리 벡터의 내적(dot product)을 계산한다. 규모가 커지면 FAISS, Pinecone 같은 전용 벡터 데이터베이스를 사용하여 근사 최근접 이웃(ANN) 검색으로 속도를 확보한다.
검색 결과에서 상위 K개만 반환하겠다는 설정값. top-k 5이면 가장 관련성 높은 5개 청크만 가져온다. K가 너무 작으면 관련 정보를 놓치고, 너무 크면 관련 없는 노이즈가 섞여 LLM이 혼란스러워한다. 이 프로젝트에서는 CLI 단일 검색 시 top-k 5, RAG 배치 검색 시 top-k 3을 기본으로 사용한다.
텍스트를 검색 가능한 토큰(단어/형태소) 단위로 분리하는 도구. 한국어는 "사주명리학을"을 "사주/명리/학"으로 분리하는 형태소 분석이 필요하고, 중국어는 띄어쓰기가 없어서 "四柱八字"를 "四柱/八字"로 분절해야 하고, 영어는 공백으로 나누면 대부분 해결된다. 각 언어에 맞는 토크나이저를 사용해야 BM25 검색 품질이 올라간다.
LLM은 사주명리학처럼 체계적이지만 비주류인 전문 지식에서 환각이 심하다. "갑목(甲木) 일간이 인월(寅月)에 태어났을 때 용신은?"이라고 물으면, 그럴듯하지만 틀린 답을 자신있게 내놓는다. 고전 원문(적천수, 궁통보감)에 근거한 정확한 해석이 필요한 영역에서 LLM 단독으로는 신뢰할 수 없다.
이것은 사주명리학만의 문제가 아니다. 서비스 기획서, 특정 프레임워크의 최신 API, 법률 판례, 의료 가이드라인 등 LLM의 학습 데이터에 충분히 포함되지 않은 모든 도메인에서 동일한 문제가 발생한다.
사주명리학은 본질적으로 다국어 도메인이다:
| 언어 | 역할 | 예시 |
|---|---|---|
| 중국어 | 원전·고전 텍스트 | 滴天髓, 窮通寶鑑, 子平真詮 |
| 한국어 | 현대 해석·교재·실전 사례 | 격국론, 용신론, 실전 100구문 |
| 영어 | 학술 논문·ML 연구 | BaZi career prediction, AI character simulation |
하나의 쿼리 "갑목 인월 용신"이 한국어 교재, 중국어 원전 窮通寶鑑, 영어 논문을 동시에 검색해야 완전한 답을 구성할 수 있다. 이것은 IT 회사에서 영어 기술 문서 + 한국어 기획서 + 일본어 파트너 API 문서를 통합 검색해야 하는 상황과 구조적으로 동일하다.
┌─────────────────────────────────────────────────────────┐
│ 0단계: 데이터 수집·정제 │
│ 원본 PDF/MD/TXT → 구조화 마크업 (--- 구분선, 출처 표기) │
│ 중복 검사 → 출처 검증 → books/ 디렉토리에 배치 │
│ → 상세: Part 1 │
└───────────────┬─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 1단계: 청킹 (chunk.py) │
│ PDF→PyMuPDF / TXT→직접 읽기 │
│ 언어 감지 → 단락 경계 존중 → 512자 청킹 (64자 overlap) │
│ 출력: chunks.jsonl (9,586개 청크, 4.16MB) │
│ → 상세: Part 1 │
└───────────────┬─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 2단계: 인덱싱 (embed.py) │
│ │
│ ┌──────────────────┐ ┌──────────────────────┐ │
│ │ Dense Embedding │ │ BM25 Sparse Index │ │
│ │ MiniLM-L12-v2 │ │ 한국어: Kiwi POS │ │
│ │ 384차원, L2 정규화 │ │ 중국어: jieba 분절 │ │
│ │ → embeddings.npy │ │ 영어: regex+stopwords │ │
│ │ (14MB) │ │ → bm25_index.pkl │ │
│ └──────────────────┘ └──────────────────────┘ │
│ → 상세: Part 2 │
└───────────────┬─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 3단계: 하이브리드 검색 (search.py) │
│ │
│ 쿼리 → 언어 감지 → 다국어 토큰 확장(한↔漢 143개 키) │
│ → BM25 top-K ∪ Dense top-K 후보 (K=max(20,top_k×4)) │
│ → (옵션) bge-reranker-v2-m3 리랭킹 │
│ → 소스 다양성 페널티 → top-K 반환 │
│ → 상세: Part 2 │
└───────────────┬─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────┐
│ 4단계: LLM 에이전트 활용 + 코퍼스 관리 │
│ │
│ Claude Code Skill 오케스트레이터 │
│ → 5개 서브에이전트 병렬 실행 │
│ → 각 에이전트가 search.py로 RAG 검색 │
│ → 원문 인용 기반 분석 보고서 생성 │
│ → 자동 갭 분석 → 수집 → 리빌드 사이클 │
│ → 상세: Part 3 │
└─────────────────────────────────────────────────────────┘
| 항목 | 수치 |
|---|---|
| 소스 문서 | 593개 (PDF + MD + TXT) |
| 총 청크 수 | 9,586개 |
| 총 텍스트 용량 | 약 416만 자 (chunks.jsonl 파일 크기 10.4MB) |
| 평균 청크 크기 | 434자 |
| Dense 임베딩 | 9,586 × 384 float32 = 14MB |
| BM25 인덱스 | 14.7MB (bm25_index.pkl 7.1MB + tokenized_corpus.pkl 7.6MB) |
| 언어 분포 | 한국어 80.5% / 중국어 14.2% / 영어 5.3% |
이 프로젝트에서 검증된, 도메인과 규모에 무관하게 적용할 수 있는 원칙을 정리한다.
전문 도메인에서는 BM25(키워드) + Dense(의미)를 반드시 병행한다. 고유명사, 약어, 코드명, API 엔드포인트 등 정확한 글자 매칭이 필요한 용어가 있는 도메인에서 Dense 단독은 위험하다. Dense는 "의미적으로 비슷한" 엉뚱한 결과를 가져올 수 있고, BM25는 정확한 키워드가 없으면 아무것도 못 찾는다. 둘을 합쳐야 한다.
동의어, 약어, 다국어 대응 테이블을 구축하면 검색 재현율(recall)이 극적으로 올라간다. 이 프로젝트의 143개 키(209쌍) 한↔漢 매핑이 그 예시. 법률 도메인이면 "불법행위 ↔ 위법행위 ↔ tort", IT이면 "K8s ↔ Kubernetes ↔ 쿠버네티스", 의료면 "당뇨 ↔ diabetes mellitus ↔ DM" 같은 테이블을 구축한다.
고정 크기 청킹은 간단하지만, 의미 단위가 잘리면 검색 품질이 떨어진다. --- 구분선이나 마크다운 헤딩(##) 같은 문서 구조를 atomic boundary로 활용하는 것이 효과적이다. 그리고 이것이 작동하려면 데이터 수집 단계에서 이미 구조화가 되어 있어야 한다. 청킹 품질은 데이터 정제 품질의 반영이다.
Stage 1에서 BM25+Dense로 후보를 넓게 잡고, Stage 2에서 Cross-Encoder로 정밀 순위를 매기는 2-stage 파이프라인은 검색 품질을 크게 올린다. 후보 40개를 리랭킹하는 비용은 미미하지만, 정밀도 향상은 체감된다.
정적인 코퍼스란 없다. "갭 감지 → 자료 수집/갱신 → 리빌드 → 검증"을 자동화해야 시간이 지날수록 품질이 유지되거나 올라간다. 수동 관리는 규모가 커지면 반드시 실패한다.
LLM에게 검색 결과를 전달할 때, 요약하지 말고 원문을 그대로 전달하라. 요약 과정에서 정보가 손실되거나 왜곡된다. LLM이 원문을 직접 보고 판단하게 해야 인용의 정확성이 보장된다. 이 프로젝트에서 서브에이전트가 "원문 전문 인용"을 강제하는 이유이다.
| 구성 요소 | 이 프로젝트의 구현 | Production 확장 옵션 | 상세 문서 |
|---|---|---|---|
| 데이터 수집 | 수동 + saju-research-collector 에이전트 | Confluence API, Git 웹훅, S3 이벤트, Unstructured.io | Part 1 |
| 데이터 정제 | --- 구분선, 출처 필수, AI 생성 금지 |
HTML→MD 변환, PII 마스킹, 버전 관리 | Part 1 |
| 청킹 | 512자, 64자 overlap, --- 경계 존중 |
LangChain TextSplitter, Semantic Chunking | Part 1 |
| 임베딩 | MiniLM-L12-v2 (384d, 로컬) | bge-m3, OpenAI embedding API, Cohere embed | Part 2 |
| BM25 | rank-bm25 (in-memory pickle) | Elasticsearch, OpenSearch, Weaviate 내장 | Part 2 |
| 벡터 검색 | numpy 내적 (14MB npy) | FAISS, Pinecone, Qdrant, pgvector | Part 2 |
| 리랭커 | bge-reranker-v2-m3 (로컬) | Cohere Rerank, Jina Reranker, LLM 리랭킹 | Part 2 |
| 쿼리 확장 | 143개 키(209쌍) 한↔漢 사전 | WordNet, 도메인 온톨로지, LLM 기반 확장 | Part 2 |
| 코퍼스 관리 | 자동 갭→수집→리빌드 사이클 | 증분 동기화, TTL 만료, 사용자 피드백 루프 | Part 3 |
| 에이전트 | Claude Code Skills (5+5 병렬) | LangChain Agent, LlamaIndex Agent | Part 3 |
| 스케일링 | numpy + pickle (9.6K 청크) | FAISS → Qdrant → Pinecone | Part 4 |
| IT 적용 | — | 기획서 RAG, KB 봇, 장애 대응 | Part 4 |
이 시스템의 핵심 가치는 "작지만 완전한" RAG 파이프라인이라는 것이다. 593개 문서, 9,586 청크, Python 스크립트 4개(chunk.py, embed.py, search.py, dedup.py)로 구성된 이 시스템이 다국어 하이브리드 검색, 리랭킹, 적응형 가중치, 자동 코퍼스 강화까지 모두 포함하고 있다.
규모를 키울 때는 위 표에서 각 구성 요소를 Production 옵션으로 교체하면 된다. 설계 원칙과 파이프라인 구조는 규모와 무관하게 동일하게 유지된다. 달라지는 것은 인프라이지, 아키텍처가 아니다.