-
3,200개 청크에 맥락을 심다 — Contextual Retrieval 최적화 삽질기IT 2026. 3. 29. 21:00
로컬 RAG 시스템을 운영하면서 Anthropic이 제안한 Contextual Retrieval 기법을 적용하기로 했다. 각 청크에 "이 청크가 문서 전체에서 어떤 위치에 있는지" 설명하는 짧은 접두사를 LLM으로 생성하여 임베딩에 포함시키는 기법이다.
문제는 1,381개 파일에서 만들어진 3,200개 이상의 청크에 이걸 적용해야 한다는 것이었다. 순진하게 접근하면 반나절이 걸릴 작업을, 어떻게 2시간 안에 끝냈는지 정리해본다.
환경
- 하드웨어: NVIDIA DGX Spark (GB10 GPU, 128GB 통합 메모리)
- 벡터 DB: Qdrant (Python local mode)
- 임베딩: Qwen3-Embedding-8B (4096차원)
- Context 생성 LLM: Ollama로 구동
- 대상: Obsidian 지식 금고 — 경영학 강의노트, 기술문서, 캘린더 노트, 음성 전사본
기법 1: 모델 다운사이징 (30B → 8B)
원래 계획은 Qwen3:30b-a3b (18.6GB)로 맥락 설명을 생성하는 것이었다. 하지만 태스크 특성을 다시 생각해보면:
- 출력은 50토큰 이하의 짧은 한국어 요약
- 입력은 문서 전체(최대 6,000자) + 청크 텍스트
- 창의성이 아닌 정확한 위치 파악이 핵심
이 정도 태스크에 30B 모델은 과잉이다. Qwen3:8b (5.2GB)로 교체했다.
효과
항목 30B 8B 모델 크기 18.6GB 5.2GB 청크당 추론 시간 ~10초 ~2.5초 GPU 메모리 여유 부족 충분 (병렬 처리 가능) 모델 로드 시간도 줄고, 남는 GPU 메모리로 병렬 처리의 문을 열 수 있었다.
기법 2: 병렬 요청 (4-worker)
Ollama는
OLLAMA_NUM_PARALLEL환경변수로 동시 요청 수를 조절할 수 있다. 8B 모델은 메모리가 충분해서 4개 동시 요청이 가능했다.Python 쪽에서는
ThreadPoolExecutor로 같은 문서의 청크들을 병렬로 처리:from concurrent.futures import ThreadPoolExecutor, as_completed def generate_contexts_batch(document_text, chunks): contexts = [""] * len(chunks) with ThreadPoolExecutor(max_workers=4) as executor: future_to_idx = { executor.submit(generate_context, document_text, chunk): i for i, chunk in enumerate(chunks) } for future in as_completed(future_to_idx): idx = future_to_idx[future] contexts[idx] = future.result() return contexts효과
순차 처리에서는 10초에 1개씩이었지만, 병렬 처리 후 10초에 4개가 동시에 완료된다.
단, GPU 메모리 대역폭을 공유하므로 개별 요청의 latency는 약간 증가한다. 그래도 전체 throughput은 확실히 올라간다:
- 순차: ~6 chunks/min
- 병렬 4: ~24 chunks/min (4배 throughput)
실제 로그에서 같은 초에 4개 응답이 묶여서 돌아오는 것을 확인할 수 있었다.
기법 3: 2-Pass 파이프라인
3,200개 청크를 처리하려면 두 종류의 모델이 필요하다:
- Context LLM (Qwen3:8b) — 맥락 설명 생성
- Embedding 모델 (Qwen3-Embedding-8B) — 벡터화
Ollama에서 모델을 교체하면 언로드 → 로드 시간이 10~30초 소요된다. 만약 파일별로 "context 생성 → 임베딩"을 반복하면, 1,381개 파일마다 모델 스왑이 2번씩 — 총 2,762번의 스왑이 발생한다.
이를 2-Pass 방식으로 해결했다:
Pass 1: 전체 파일의 context 생성 (Qwen3:8b 한 번 로드) ↓ context_cache.json에 중간 저장 (중단 복구 가능) Pass 2: 전체 파일의 임베딩 + Qdrant upsert (Embedding 모델 한 번 로드)모델 스왑은 전체 배치에서 딱 1회만 발생한다.
중단 복구
Pass 1에서 50파일마다
context_cache.json에 중간 저장한다. 프로세스가 죽어도 이어서 처리할 수 있다.기법 4: 야간 배치 큐
처음에는 Git post-commit hook에서 즉시 인덱싱을 실행했다. 하지만 문제가 있었다:
- Ollama가 꺼져 있으면 임베딩 실패 → 제로 벡터가 DB에 삽입됨
- 파일 하나마다 모델 스왑 발생 (context LLM ↔ embedding)
- 다른 GPU 작업(VLM 분석 등)과 충돌
이를 큐 + 야간 배치로 전환했다:
[Post-commit Hook] [매일 새벽 2시 Cron] git commit 큐 읽기 + 비우기 ↓ ↓ index_queue.txt에 경로 append GPU acquire (VLM 중지) (flock, GPU 무관) ↓ Ollama 서버 대기 ↓ Pass 1: context 일괄 생성 ↓ Pass 2: 임베딩 일괄 처리 ↓ 정합성 검사 (누락/고아 정리) ↓ GPU release (VLM 자동 재시작)캘린더 자동 동기화(새벽 1시) → RAG 배치(새벽 2시) → 가족앨범 VLM(새벽 3시) 순서로 자연스럽게 이어진다.
실제 결과
항목 값 처리 파일 1,381개 생성 포인트 3,664개 Pass 1 (Context) 89분 (2,600+ LLM 호출) Pass 2 (임베딩) 25분 총 소요 시간 114분 순진한 접근(30B 순차, 파일별 스왑)이었다면 예상 시간은 8~10시간. 최적화로 약 5배 단축했다.
사이드이펙트: Think 태그 오염
모든 게 순조로웠으면 좋았겠지만, 결과를 확인해보니 context의 61%가 오염되어 있었다.
Qwen3 모델은 "thinking mode"가 있어서,
/no_think프롬프트를 넣어도 간헐적으로<think>...</think>블록이 출력된다. 특히 8B 모델에서 이 문제가 심했다:→ creepy\n</think>\n3년차부터 누적 마진 양수... → swingerclub\n원가 분류와 배부 기준... → ZR\nOkay, I need to figure out the context...패턴은 세 가지: 1. garbage 토큰 +
</think>+ 정상 설명 — 가장 흔함 2. 영어 chain-of-thought 전체 노출 —/no_think완전 무시 3. 의미 없는 비ASCII 문자 —ᅠ,⅀등해결:
_clean_think_tags()강화기존 정리 로직은
<think>...</think>패턴만 제거했다. 하지만 닫히지 않은</think>앞의 garbage나, 열린<think>뒤의 chain-of-thought는 잡지 못했다.5단계 정리 로직으로 강화했다:
def _clean_think_tags(text): # 1. 완전한 <think>...</think> 블록 제거 text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL) # 2. </think> 앞의 모든 garbage 제거 text = re.sub(r"^.*?</think>", "", text, flags=re.DOTALL) # 3. 열린 <think> 이후 전부 제거 text = re.sub(r"<think>.*$", "", text, flags=re.DOTALL) # 4. 단독 태그 잔해 제거 text = re.sub(r"</?think>", "", text) # 5. 한국어/영어 5글자 미만이면 garbage로 판정 korean_or_english = re.findall(r"[\uAC00-\uD7A3a-zA-Z]", text) if len(korean_or_english) < 5: return "" return text.strip()이 로직은
context_generator.py에 통합되어, 이후 생성되는 모든 context에 자동 적용된다.교훈
- 태스크에 맞는 모델을 쓰자. 50토큰 요약에 30B는 낭비다. 8B로도 충분하고, 남는 리소스로 병렬 처리가 가능해진다.
- 모델 스왑을 최소화하자. 같은 모델을 쓰는 작업끼리 묶는 것만으로 엄청난 시간을 아낄 수 있다.
- 즉시 처리보다 배치가 안전하다. GPU 작업은 환경 의존성이 높다. 큐에 넣고 야간에 일괄 처리하면 실패 복구도 쉽고 다른 작업과의 충돌도 방지된다.
- 작은 모델의 부작용을 예측하자. 8B 모델은 thinking mode 제어가 불안정하다. 이런 부작용은 사전에 샘플 테스트로 잡아야 한다.
- 중간 저장은 생명이다. 2시간짜리 작업이 90분 지점에서 죽으면 처음부터 다시다.
context_cache.json같은 체크포인트가 필수다.
이 글은 생성형 AI의 도움을 받아 작성되었습니다. 원본 자료를 기반으로 AI가 초안을 생성하고, 작성자가 검토·편집하였습니다.
'IT' 카테고리의 다른 글
Context7 분석 (4) 5단계 품질 파이프라인 (0) 2026.04.01 Context7 분석 (3) 서버 사이드 리랭킹 (0) 2026.04.01 Context7 분석 (2) 코드 스니펫 vs 정보 스니펫 (0) 2026.03.31 Context7 분석 (1) 문서 특화 청킹 전략 (1) 2026.03.31 모니터 없는 서버에서 브라우저를 띄우는 법 — Xvfb와 Playwright의 만남 (0) 2026.03.30 로컬 RAG 시스템의 두뇌 삼형제 — Ollama 모델 3종 역할 분담기 (0) 2026.03.28 RAG 성능 측정의 핵심: RAGAS Ground Truth 준비 완벽 가이드 (0) 2026.03.27 RAGAS로 RAG 시스템 평가하기 — 지표별 의미와 Python 실전 사용법 (0) 2026.03.27 Qdrant 벡터 검색에서 Reranking까지, 실전 코드 (0) 2026.03.26 Bi-Encoder vs Cross-Encoder, 왜 둘 다 필요한가 (1) 2026.03.26