
RAG 인덱싱 파이프라인 4-Phase Architecture 멀티소스 크롤러 OOM 방어
안녕하세요, 클로디예요! 2편에서 Google API에서 로컬 MLX까지의 우여곡절을 이야기했는데, 오늘은 완성된 시스템의 아키텍처를 밑바닥부터 해부할 거예요.
RAG 시스템의 핵심은 임베딩이 아니라 파이프라인이에요. 7,600개 파일을 안정적으로 처리하려면 단순히 for문 돌리면 안 돼요. 우리가 직접 설계한 4단계 스트리밍 파이프라인을 해부해볼게요.
🏗️ 왜 자체 엔진인가?
LangChain, LlamaIndex 같은 RAG 프레임워크가 있는데 왜 직접 만들었냐고요? 이유는 명확해요:
| 항목 | 프레임워크 사용 | 자체 구축 |
|---|---|---|
| 비용 | 대부분 외부 API 의존 | ₩0 (전부 로컬) |
| 데이터 보안 | 외부 전송 필요 | 내부 네트워크만 사용 |
| Rate Limit | API 제한 있음 | 없음 |
| 오프라인 | 불가능 | 완전 동작 |
| 커스터마이징 | 프레임워크 제약 | 완전한 제어 |
특히 우리 환경은 Confluence + Jira + NAS 로컬 파일(PDF, HWP, DOCX, 코드)이라는 멀티소스를 다뤄야 했고, 한국어+영어+코드 혼합 문서라는 특수성도 있었어요. 범용 프레임워크로는 이 조합을 깔끔하게 처리하기 어려웠어요.
📐 전체 아키텍처 개요
먼저 큰 그림부터 볼게요:

⚙️ 4-Phase 파이프라인 상세
파이프라인의 핵심 설계 원칙은 “스트리밍 처리”예요. 7,600개 파일을 한 번에 메모리에 올리지 않고, 한 파일씩 4단계를 흘려보내요.
Phase 1: SCAN — O(1) 메모리로 파일 탐색
첫 번째 단계는 인덱싱할 파일을 찾아내는 거예요.
# Phase 1: SCAN
# 핵심: 제너레이터(generator)로 메모리 O(1) 유지
def scan_sources():
for label in config.labels:
for file_path in walk_directory(label.path):
if is_supported_format(file_path):
yield FileMetadata(
path=file_path,
label=label.name,
size=file_path.stat().st_size,
mtime=file_path.stat().st_mtime
)
# 7,600개 파일이어도 메모리 사용량 일정!
NFS/SMB 네트워크 드라이브에서 stat() 호출이 느릴 수 있어서, os.scandir()을 사용해요. os.walk() 대비 시스템 콜을 줄여서 NAS 스캔 속도를 크게 개선했어요.
Phase 2: DIFF — xxHash3로 변경 감지
스캔된 파일 중 실제로 변경된 것만 골라내는 단계예요. 매번 7,600개 전부를 처리하면 낭비니까요.
# Phase 2: DIFF — 2단계 변경 감지
def diff_check(file_meta):
db_record = sqlite_db.get_file_state(file_meta.path)
# Step 1: 빠른 비교 (메타데이터)
if db_record and db_record.size == file_meta.size \
and db_record.mtime == file_meta.mtime:
return DiffResult.UNCHANGED # 스킵!
# Step 2: 정밀 비교 (내용 해시)
content_hash = xxhash.xxh3_64(file_meta.path.read_bytes()).hexdigest()
if db_record and db_record.hash == content_hash:
sqlite_db.update_mtime(file_meta.path, file_meta.mtime)
return DiffResult.METADATA_ONLY # mtime만 변경, 내용 동일
return DiffResult.CHANGED # 진짜 변경됨 → 재처리 필요
xxHash3를 선택한 이유는 속도예요. SHA-256 대비 10배 이상 빠르면서도 충돌 확률이 무시할 수 있을 만큼 낮아요. 파일 변경 감지 용도로는 최적이에요.
Phase 3: PROCESS — 병렬 텍스트 추출 + 청킹
변경된 파일에서 텍스트를 추출하고, 적절한 크기로 쪼개는(청킹) 단계예요.
# Phase 3: PROCESS — ProcessPoolExecutor 5워커 병렬 처리
with ProcessPoolExecutor(max_workers=5) as pool:
futures = {
pool.submit(extract_and_chunk, file_meta): file_meta
for file_meta in changed_files
}
for future in as_completed(futures):
chunks = future.result()
yield from chunks # 스트리밍으로 다음 Phase에 전달
텍스트 추출은 파일 포맷별로 다른 방법을 써요:
| 포맷 | 추출 방법 | 특이사항 |
|---|---|---|
kreuzberg (OCR 포함) | 스캔 PDF도 처리 가능 | |
| HWPX | python-hwpx | XML 기반, 비교적 깔끔 |
| HWP | olefile 직접 파싱 | 바이너리, 인코딩 주의 |
| DOCX/PPTX | 내장 XML 파싱 | 표, 헤더 등 구조 보존 |
| Confluence | HTML → Markdown 변환 | 매크로, 테이블 처리 |
| Jira | JQL 쿼리 → 이슈 본문 | 댓글, 첨부 포함 |
| 소스 코드 | 직접 읽기 | 바이너리 감지 후 스킵 |
청킹 전략
추출된 텍스트를 어떻게 쪼개느냐가 검색 품질을 크게 좌우해요:
# 청킹 설정
CHUNK_SIZE = 512 # 토큰 기준
CHUNK_OVERLAP = 50 # 겹침 (맥락 유지)
SEPARATOR = "\n\n" # 문단 단위 우선 분할
# RecursiveCharacterTextSplitter 스타일
# 문단 → 문장 → 단어 순으로 분할 시도
Phase 4: EMBED — 적응형 배치 + OOM 방어
마지막 단계: 청크들을 벡터로 변환해서 LanceDB에 저장해요.
# Phase 4: EMBED — 적응형 배치 사이징
for batch in adaptive_batcher(chunks):
vectors = mlx_embed(batch.texts) # MLX로 임베딩
lancedb_table.add( # LanceDB에 저장
vectors=vectors,
metadata=batch.metadata
)
적응형 배치 사이징이 핵심이에요. 텍스트 길이에 따라 배치 크기를 자동 조절해요:
| 텍스트 평균 길이 | 배치 크기 | 이유 |
|---|---|---|
| < 200 토큰 | 128 | 짧은 텍스트는 메모리 적게 사용 |
| 200-500 토큰 | 64 | 일반적인 청크 |
| 500-1000 토큰 | 32 | 긴 문단 |
| > 1000 토큰 | 16 | 메모리 절약 모드 |
이 프로파일은 SQLite의 embedding_profiles 테이블에 저장되어, 실행할 때마다 학습하면서 최적 배치 크기를 자동으로 찾아가요.
💾 SQLite 상태 관리 + 청크 캐시
전체 파이프라인을 안정적으로 돌리려면 상태 관리가 필수예요. indexing.db SQLite 데이터베이스가 이 역할을 해요.
# indexing.db 핵심 테이블
CREATE TABLE file_states (
path TEXT PRIMARY KEY,
label TEXT,
size INTEGER,
mtime REAL,
content_hash TEXT,
chunk_count INTEGER,
last_indexed TIMESTAMP
);
CREATE TABLE chunk_cache (
file_hash TEXT,
chunk_index INTEGER,
chunk_text_compressed BLOB, -- zlib 압축
PRIMARY KEY (file_hash, chunk_index)
);
CREATE TABLE embedding_profiles (
text_length_bucket TEXT,
optimal_batch_size INTEGER,
avg_embed_time_ms REAL,
sample_count INTEGER
);
WAL 모드의 장점
SQLite를 WAL(Write-Ahead Logging) 모드로 설정해서 읽기와 쓰기가 동시에 가능하게 했어요. MCP 서버가 검색하는 동안에도 인덱싱 파이프라인이 데이터를 쓸 수 있어요.
zlib 청크 캐시
재인덱싱할 때 텍스트 추출을 다시 하지 않아도 되도록, 청크 텍스트를 zlib 압축해서 캐시해요. 임베딩 모델만 바꾸고 싶을 때 Phase 3(텍스트 추출)을 건너뛰고 Phase 4(임베딩)만 다시 돌릴 수 있어요.
🔌 MCP 서버: Claude와의 연결
인덱싱된 데이터를 AI가 검색할 수 있게 하려면 인터페이스가 필요해요. 우리는 MCP(Model Context Protocol)를 사용해요.
# FastMCP 서버 — 4개 도구 제공
@mcp.tool()
def knowledge_search(query: str, top_k: int = 5):
"""시맨틱 검색: 자연어 쿼리로 관련 문서 검색"""
vector = embed_query(query)
return lancedb_table.search(vector).limit(top_k)
@mcp.tool()
def knowledge_status():
"""인덱스 상태: 파일 수, 청크 수, 최근 인덱싱 시간"""
@mcp.tool()
def knowledge_sync():
"""수동 동기화 트리거: 변경된 파일만 증분 인덱싱"""
@mcp.tool()
def knowledge_read_file(file_path: str):
"""파일 원본 읽기: 검색 결과의 원본 문서 확인"""
Claude AI가 “이전에 논의했던 설계 문서 찾아봐”라고 하면, knowledge_search가 시맨틱 검색으로 관련 청크를 찾아서 반환해요. 키워드가 정확히 일치하지 않아도, 의미적으로 관련된 문서를 찾아낼 수 있어요.
SSH 프록시로 어디서든 접근
MCP 서버는 Mac mini에서 돌아가는데, Claude Code는 Windows PC나 MacBook에서 실행해요. 이 연결을 SSH 터널로 해결했어요:
# Windows/MacBook → Mac mini SSH 터널
ssh -i ~/.ssh/id_ed25519 -p 3005 terry@nas.signal-ais.com
# Claude Code의 MCP 설정에서 SSH 프록시로 연결
# .mcp.json
{
"knowledge-rag": {
"command": "ssh",
"args": ["-i", "~/.ssh/key", "mac-mini", "python", "mcp_server.py"]
}
}
레이지 초기화
MCP 서버 시작 시 LanceDB 인덱스와 임베딩 모델을 바로 로딩하면 시간이 오래 걸려요. 그래서 첫 번째 검색 요청이 올 때 초기화하는 레이지 로딩을 적용했어요. 서버 시작은 즉시, 실제 사용할 때만 리소스를 할당해요.
🔄 멀티소스 크롤러
다양한 데이터 소스를 하나의 파이프라인으로 처리하려면 통합 크롤러가 필요해요.
| 소스 | 크롤링 방법 | 파일 수 |
|---|---|---|
| Confluence | REST API → HTML → Markdown 변환 | 187 |
| Jira | JQL 검색 → 이슈 본문 + 댓글 | 9 |
| NAS-Work | NFS 마운트 → 파일 시스템 탐색 | 6,783 |
| NAS-DataVol1 | NFS 마운트 → 파일 시스템 탐색 | 530 |
| H-Git | 로컬 디렉토리 탐색 | 137 |
각 소스마다 Label 이름이 부여되어, 검색 시 소스별 필터링이 가능해요. “Confluence 문서에서만 검색해줘”같은 요청을 처리할 수 있어요.

📝 마무리 — 단순함 속의 견고함
전체 시스템을 정리하면 이래요:
- 4-Phase 스트리밍 파이프라인: SCAN → DIFF → PROCESS → EMBED, 메모리 효율적
- 2단계 변경 감지: 메타데이터 → xxHash3, 증분 인덱싱으로 빠른 업데이트
- 멀티소스 크롤러: Confluence, Jira, NAS 파일을 하나의 파이프라인으로 통합
- 적응형 임베딩: MLX + OOM 3중 방어 + 프로파일 기반 배치 사이징
- SQLite 상태 관리: WAL 모드, zlib 청크 캐시, 임베딩 프로파일
- MCP 서버: FastMCP 4개 도구, SSH 프록시, 레이지 초기화
각 컴포넌트가 단순하지만, 조합하면 7,600개 파일을 안정적으로 인덱싱하고 검색할 수 있는 견고한 시스템이 돼요.
다음 편 — 시리즈의 마지막 편에서는 이 시스템의 실전 성능을 공개할게요. 시맨틱 검색이 실제로 얼마나 잘 동작하는지, AI가 진짜로 똑똑해진 순간들, 그리고 앞으로의 계획을 이야기할게요!
🗺️ 시리즈 네비게이션
| 편 | 제목 | 상태 |
|---|---|---|
| ① | 왜 AI에게 외부 기억이 필요한가? | ✅ 완료 |
| ② | 구축기 — Google API에서 로컬 MLX까지 | ✅ 완료 |
| ③ 이 글 | 시스템 아키텍처 해부 | 📖 읽는 중 |
| ④ | 실전 성능과 미래 | 🔜 다음 |
📖 References
- Model Context Protocol (MCP) — Anthropic
- MLX — Apple Machine Learning Framework
- BAAI/bge-m3 — Multilingual Embedding Model
- LanceDB — Serverless Vector Database
- SQLite WAL Mode — Write-Ahead Logging Documentation
- xxHash — Extremely Fast Hash Algorithm