RAG 완벽 가이드: Naive · Advanced · Graph RAG 통합본
하나의 문서로 끝내는 RAG 학습 자료. 이론(왜 필요한가, 어떻게 진화했는가) + 실전 코드(복붙해서 바로 실행 가능) + 최신 동향(Agentic, GraphRAG, Contextual Retrieval) + 한계와 대안 + 의사결정 가이드까지 모두 포함.
RAG는 2023년부터 직접 만들고 운영해보면서 가장 자주 만나는 패턴이 됐다. 자료가 여러 블로그·논문·릴리스 노트에 흩어져 있어서, 다음에 다시 들여다볼 때 한 페이지에서 끝낼 수 있도록 내가 쓰던 노트와 검증된 예제를 한 곳에 묶었다. 입문자에겐 학습 순서로, 실무자에겐 옵션 비교 테이블로 쓰이길 바란다.
목차
Part I — 기초
Part II — 구현 4. Naive RAG: 기본 5단계 5. 예제 1: Naive RAG (LangChain + Chroma) 6. Advanced RAG 심층 분석 7. 예제 2: Advanced RAG (Hybrid + Rerank + 쿼리 변환 + 인용 + Self-Eval) 8. Graph RAG 심층 분석 9. 예제 3: Graph RAG (엔티티/관계 추출 + 그래프 탐색)
Part III — 운영과 의사결정 10. 평가 방법과 지표 11. 최신 기술 동향 (2024–2026) 12. 한계와 대안 13. 3종 비교 및 의사결정 가이드 14. 실전 체크리스트 15. 실행 환경 준비
Part IV — Beyond RAG 16. LLM Wiki: 검색이 아니라 축적하는 지식 시스템 17. 예제 4: LLM Wiki (자가 유지 위키 에이전트)
Part I — 기초
1. RAG란 무엇인가
RAG(Retrieval-Augmented Generation) 는 Retrieval(검색) + Augmented(증강) + Generation(생성) 의 합성어로, 2020년 Lewis et al. (Facebook AI Research)이 정립한 개념이다.
핵심은 한 줄로 요약된다.
LLM 파라미터에 모든 지식을 넣지 말고, 필요할 때 외부에서 가져와 답하게 하자.
LLM은 추론기(reasoner), 외부 지식 베이스는 참고서(reference). 시험으로 비유하면 폐쇄형 시험이 아니라 오픈북 시험을 보게 하는 것이다.
[사용자 질문] → [검색 시스템] → 관련 문서 K개 → [질문 + 문서] → LLM → [근거 기반 답변]2. 왜 RAG가 필요한가
LLM 단독 사용 시 발생하는 세 가지 본질적 문제를 RAG가 해결한다.
2.1 지식 신선도 문제 (Freshness)
LLM은 학습 컷오프 이후 사건을 모른다. 사내 정책이 어제 바뀌었다면 모델은 결코 알 수 없다. RAG는 인덱스만 갱신하면 즉시 반영된다.
2.2 사적 지식 문제 (Private Knowledge)
회사 위키, 고객 티켓, 의료 차트, 법률 계약서 같은 비공개 데이터는 모델 학습에 포함되지 않는다. 학습시키려면 막대한 비용 + 보안 이슈. RAG는 데이터를 모델 외부에 두므로 가중치를 건드리지 않는다.
2.3 환각 문제 (Hallucination)
LLM은 모르는 것에도 그럴듯한 답을 만든다. RAG는 “검색 문서에 근거해서만 답하라"는 제약 + 인용으로 환각을 크게 줄이고 검증 가능성을 부여한다.
2.4 부수 이점
- 비용: 작은 모델 + 좋은 RAG가 큰 모델 단독보다 저렴할 때가 많음
- 권한: 검색 단계에서 사용자별 권한 적용 가능
- 추적성: 어떤 문서를 보고 답했는지 감사 로그 남김 — 규제 산업 필수
3. RAG의 3세대 진화: Naive → Advanced → Modular → Graph
RAG는 2020년 이후 빠르게 진화해왔다. 학계·업계에서 일반적으로 받아들여지는 분류는 다음과 같다.
3.1 Naive RAG (1세대, ~2023 초)
“질문을 그대로 벡터 검색해서, 결과를 바로 LLM에 던진다.”
가장 단순한 형태. 청킹 → 임베딩 → 유사도 검색 → 생성. 입문 튜토리얼 대부분이 이 형태.
한계: 애매한 질문, 동의어 부족, 다중 홉 추론, 키워드 매칭이 중요한 경우 모두 약함.
3.2 Advanced RAG (2세대, 2023~2024)
“검색 전·중·후를 모두 정교화한다.”
검색 전(pre-retrieval): 의미론적 청킹, 메타데이터 강화, 쿼리 재작성/확장/분해, HyDE. 검색 중(retrieval): Hybrid(Dense + Sparse), Multi-vector, ColBERT. 검색 후(post-retrieval): Reranking, Contextual compression, MMR, 인용 강제.
핵심 메시지: “검색을 더 똑똑하게.” 데이터 표현은 여전히 청크 + 임베딩.
3.3 Modular RAG (2.5세대)
“각 단계를 모듈화·교체 가능하게, 라우팅·반복·도구 사용을 자유롭게.”
라우터(Router)가 질문 유형별로 다른 서브-RAG를 호출하고, 결과가 부족하면 재시도(loop)하며, 외부 도구(SQL/API/웹)도 호출한다. Self-RAG, CRAG, Adaptive RAG, Agentic RAG가 여기 속한다.
3.4 Graph RAG (관계 중심 진화판)
“문서를 청크가 아니라 엔티티-관계 그래프로 표현한다.”
LLM이 문서에서 (엔티티, 관계, 엔티티) 트리플을 추출해 그래프 DB에 적재. 질의 시 그래프 탐색으로 다중 홉 정보를 모음. Microsoft GraphRAG (2024), LightRAG (2024), Neo4j-LangChain 통합 등이 대표 사례.
강점: 멀티홉 추론, 관계가 중요한 도메인. 약점: 그래프 구축 비용, 스키마 설계 부담.
한눈 비교
| 세대 | 핵심 아이디어 | 데이터 표현 | 강점 | 약점 |
|---|---|---|---|---|
| Naive | 단순 검색→생성 | 청크 + 임베딩 | 구현 간단 | 정확도 낮음 |
| Advanced | 검색 파이프라인 정교화 | 청크 + 임베딩 + 메타 | 검색 정확도 ↑ | 파이프라인 복잡 |
| Modular | 라우팅·반복·도구 | 다양한 인덱스 조합 | 유연성·자율성 | 운영 난이도 ↑ |
| Graph | 관계 그래프 활용 | 노드 + 엣지 (+ 임베딩) | 멀티홉·관계 추론 | 그래프 구축 비용 |
시작 전에: 용어 사전 (Glossary)
본격적인 구현에 들어가기 전, 본 문서에서 자주 만나게 될 용어와 도구 이름을 한자리에 정리한다. 이미 익숙한 항목은 건너뛰어도 좋고, 본문을 읽다가 헷갈릴 때 다시 돌아오면 된다.
A. 기본 개념
- LLM (Large Language Model) — GPT, Claude, Gemini 같은 대형 언어 모델. 본 문서에선 답변을 생성하는 추론기 역할.
- 토큰(token) — LLM이 텍스트를 처리하는 최소 단위. 영어 단어 1개 ≈ 1
1.5토큰, 한국어 글자 1개 ≈ 13토큰. 모델의 컨텍스트 한계는 토큰 수로 표시된다 (예: “Claude 200K 토큰”). - 컨텍스트 윈도우(context window) — 모델이 한 번에 입력으로 받을 수 있는 최대 토큰 수.
- 임베딩(embedding) — 텍스트를 숫자 벡터(예: 1024차원 실수 배열)로 변환한 결과. 의미가 비슷하면 벡터 거리가 가깝다는 성질을 이용해 검색의 기반이 된다.
- 벡터(vector) — 여기선 단순히 숫자들의 배열. 문장을 임베딩하면 N차원 벡터가 나온다.
- 벡터 DB (Vector Database) — 임베딩 벡터를 저장하고 유사한 벡터를 빠르게 찾는 전용 데이터베이스. 예: Chroma, Pinecone.
- 유사도(similarity) — 두 벡터의 비슷한 정도. *코사인 유사도(cosine similarity)*가 가장 흔하며, 1에 가까울수록 비슷하다.
- top-k — 검색 결과 상위 k개. “top-5 문서” = 가장 관련 있는 5개.
- chunk(청크) — 긴 문서를 검색 단위로 쪼갠 조각.
- chunk_size / chunk_overlap — 한 청크의 크기 / 인접 청크 간 겹치는 길이.
B. HuggingFace 모델 경로 — BAAI/bge-m3 가 뭔가요?
HuggingFace는 AI 모델 공유 플랫폼이다(GitHub의 ML 버전이라 생각하면 된다). 모델은 조직명/모델이름 형식으로 식별된다. 즉 BAAI/bge-m3는 BAAI라는 조직이 만든 bge-m3라는 이름의 모델이다.
| 표기 | 풀어쓰기 |
|---|---|
BAAI/bge-m3 | BAAI (Beijing Academy of AI, 중국 AI 연구소)가 만든 BGE-M3 모델. 다국어 임베딩 강자. |
BAAI/bge-reranker-v2-m3 | 같은 BAAI의 재랭커 (cross-encoder) 모델 |
intfloat/multilingual-e5-large | 연구자 intfloat의 E5 다국어 임베딩 |
nlpai-lab/KURE-v1 | 한국 NLP AI 연구실의 한국어 특화 임베딩 |
sentence-transformers/all-MiniLM-L6-v2 | 가벼운 영어 임베딩 (테스트용으로 인기) |
자주 보는 모델 시리즈
- BGE (BAAI General Embedding) — BAAI의 임베딩 시리즈.
bge-m3(다국어),bge-large-en(영어),bge-reranker(재랭커) 등. - E5 — Microsoft 연구진의 임베딩.
multilingual-e5-large,e5-mistral-7b-instruct등. - GTE — Alibaba의 임베딩 시리즈.
- ColBERT — late interaction 기반 검색 모델.
코드에서 HuggingFaceEmbeddings(model_name="BAAI/bge-m3") 라고 쓰면, 처음 한 번 HuggingFace에서 모델을 자동 다운로드해 캐시(~/.cache/huggingface)에 저장하고, 이후 실행에선 캐시에서 빠르게 로드한다.
C. 검색 알고리즘 / 기법
- BM25 — 정보검색의 표준 키워드 매칭 점수 함수(1990년대 정립). “이 문서에 그 단어가 얼마나 자주, 얼마나 특이하게 등장하나” 를 계산. 정확한 식별자(에러 코드, 고유명사)에 강함.
- Dense / Sparse Retrieval — Dense는 벡터(밀집) 검색, Sparse는 BM25 같은 단어 기반(희소) 검색. 대부분 0으로 채워진 벡터로 표현되어 “희소"라 부른다.
- ANN (Approximate Nearest Neighbor) — 수백만 벡터에서 가장 가까운 것을 대략 빠르게 찾는 알고리즘. HNSW, IVF-PQ 등이 대표 변형. 사실상 모든 벡터 DB가 내부에서 사용.
- Bi-encoder vs Cross-encoder — Bi-encoder는 질문/문서를 따로 임베딩 후 비교 (빠름, 1차 검색용). Cross-encoder는 둘을 함께 입력해 점수 계산 (정확, 느림, 재랭킹용).
- RRF (Reciprocal Rank Fusion) — 여러 검색기 결과를 결합하는 표준 방식. 각 검색기 순위의 역수를 합산. §6.4 참조.
- MMR (Maximal Marginal Relevance) — top-k의 다양성을 확보. 비슷한 청크들이 자리를 다 차지하는 걸 방지.
- HyDE (Hypothetical Document Embeddings) — LLM이 가짜 답변을 먼저 만들고 그 답변을 임베딩해 검색. 답변끼리가 답변-질문보다 더 가까운 경향을 활용.
D. 라이브러리 / 프레임워크
- LangChain — LLM 앱 프레임워크 (Python/JS). 본 문서 모든 예제의 뼈대.
- LCEL (LangChain Expression Language) — LangChain의
|파이프 문법.prompt | llm | parser처럼 컴포넌트를 수직으로 파이프 해 실행. 유닉스 셸의cat file | grep ... | wc -l과 같은 발상이다. - Runnable — LCEL에서
|로 연결할 수 있는 컴포넌트의 공통 인터페이스.RunnablePassthrough()는 입력을 그대로 다음 단계로 전달. - LlamaIndex — LangChain의 라이벌 격. 인덱싱·지식 그래프에 더 특화.
- sentence-transformers — 임베딩 + cross-encoder 둘 다 지원하는 가장 보편적 파이썬 라이브러리.
- NetworkX — 파이썬 인메모리 그래프 라이브러리. 본 문서 예제 3에서 그래프 DB 대체로 사용.
- rank_bm25 — BM25를 파이썬으로 구현한 가벼운 패키지.
E. 벡터 DB / 그래프 DB
| 카테고리 | 이름 | 한 줄 |
|---|---|---|
| 벡터 (매니지드) | Pinecone | 가장 쉬운 클라우드형, 비용 있음 |
| 벡터 | Weaviate | 하이브리드 검색 내장, GraphQL 지원 |
| 벡터 | Qdrant | Rust 기반, 셀프호스팅 친화 |
| 벡터 | Chroma | 가장 가벼움, 프로토타이핑 1순위 (본 문서 예제에서 사용) |
| 벡터 | Milvus | 수십억 벡터 대규모 환경 |
| 벡터 (확장) | pgvector | PostgreSQL 확장으로 끼워쓰기 |
| 벡터+키워드 | Elasticsearch / OpenSearch | 둘 다 지원, 운영 노하우 풍부 |
| 그래프 | Neo4j | 그래프 DB의 사실상 표준. 쿼리 언어 Cypher 사용 |
| 그래프 | Memgraph | Neo4j 호환, 더 빠름 |
| 그래프 | NebulaGraph | 대규모 분산 그래프 |
- Cypher — Neo4j의 쿼리 언어. 예:
MATCH (p:Person)-[:WORKS_AT]->(c:Company) RETURN p, c. SQL의 그래프 버전이라 생각하면 됨.
F. 평가 / 벤치마크
- MTEB (Massive Text Embedding Benchmark) — HuggingFace의 임베딩 모델 종합 평가 리더보드. 임베딩 모델 고를 때 가장 먼저 보는 곳.
- RAGAS — RAG 시스템 자동 평가 프레임워크. Faithfulness, Answer Relevance, Context Precision 등을 LLM-as-judge로 측정.
- TruLens / DeepEval / ARES — RAGAS 대체/보완 평가 도구.
- LLM-as-judge — 다른 LLM에게 “이 답변이 좋은가?” 를 물어 평가하게 하는 방식.
- Faithfulness / Hallucination — 충실도(Faithfulness): 답변이 검색 컨텍스트에 충실한가. 환각(Hallucination): 근거 없이 그럴듯하게 지어낸 답.
G. 도구 / 서비스 (특히 Part IV에서 등장)
- Obsidian — 마크다운 기반 개인 지식관리 앱. 위키링크
[[페이지명]], 그래프 뷰 지원. 무료. - Web Clipper — 웹페이지를 마크다운으로 변환해 Obsidian에 저장하는 브라우저 확장.
- Dataview — Obsidian 플러그인. 페이지의 YAML frontmatter를 SQL스럽게 질의해 동적 표/리스트 생성.
- Marp — 마크다운으로 슬라이드를 만드는 도구. Obsidian 플러그인 있음.
- qmd — 마크다운 폴더 전용 로컬 검색 엔진(BM25 + 벡터 + LLM 재랭킹). CLI + MCP 서버 제공.
- Claude Code / Codex — 파일 시스템과 셸을 직접 조작하는 에이전트형 코딩 도구. LLM Wiki와 잘 맞음.
- CLAUDE.md / AGENTS.md — 위 에이전트 도구가 읽도록 작성하는 프로젝트 사용 지침서. “이 저장소의 구조는 이렇고, 이렇게 작업해 달라” 를 자연어로 적은 메타 문서. §16.2 참조.
- Microsoft GraphRAG — Microsoft Research의 GraphRAG 공식 구현체.
- LightRAG — 홍콩대(HKU)의 더 가벼운 GraphRAG 변형.
H. 자주 보는 약어
| 약어 | 풀어쓰기 |
|---|---|
| API | Application Programming Interface |
| LLM | Large Language Model |
| RAG | Retrieval-Augmented Generation |
| KG | Knowledge Graph |
| NER | Named Entity Recognition |
| DB | Database |
| MQ | Message Queue |
| IaC | Infrastructure as Code |
| PR | Pull Request |
| PoC | Proof of Concept |
| PM | Project Manager |
| MCP | Model Context Protocol (Anthropic의 도구 연동 표준) |
| PII | Personally Identifiable Information |
| RRF | Reciprocal Rank Fusion |
| MMR | Maximal Marginal Relevance |
| BFS | Breadth-First Search |
| AST | Abstract Syntax Tree |
Part II — 구현
4. Naive RAG: 기본 5단계
가장 단순한 RAG의 흐름:
- 로드(Load): PDF, 웹, DB, Notion 등 원본 수집
- 청킹(Chunk): 긴 문서를 검색 단위로 분할
- 임베딩(Embed): 각 청크를 벡터로 변환
- 검색(Retrieve): 질문 임베딩과 유사한 청크 K개 추출
- 생성(Generate): 검색 결과를 프롬프트에 넣어 LLM이 답변
청킹 파라미터 권장
| 항목 | 권장값 | 비고 |
|---|---|---|
| chunk_size | 256~1024 토큰 | 너무 작으면 맥락 손실, 너무 크면 노이즈 |
| chunk_overlap | chunk_size의 10~20% | 경계 정보 손실 방지 |
| 법률 문서 | 조항 단위 | 도메인 구조 우선 |
| 기술 문서 | 섹션(헤더) 단위 | 마크다운 헤더 splitter |
| FAQ | Q&A 쌍 | 질문이 검색 단위 |
| 코드 | 함수/클래스 | AST 기반 splitter |
임베딩 모델 선택 (2026년 기준)
참고:
BAAI/bge-m3같은 표기가 낯설다면 위 용어 사전 §B를 먼저 보고 오면 좋다 — 조직명/모델이름 형식의 HuggingFace 모델 경로다.
- 다국어/한국어:
BAAI/bge-m3,intfloat/multilingual-e5-large,nlpai-lab/KURE-v1 - 영어 폐쇄형: OpenAI
text-embedding-3-large, Cohereembed-v3, Voyagevoyage-3 - MTEB 리더보드 점수 + 도메인 적합성 + 비용/지연 종합 판단
예제 1: Naive RAG (LangChain + Chroma)
사내 HR 위키 시나리오. 5단계를 가장 단순하게 구현. 환경 준비는 §15 참조.
ANTHROPIC_API_KEY필요.
"""example_1_naive_rag.py — Naive RAG 5단계 최소 구현"""
from langchain_core.documents import Document
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.vectorstores import Chroma
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_anthropic import ChatAnthropic
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser
# ── 1) 문서 (실무에선 PDF/Notion/DB에서 로드) ─────────────
raw = [
Document(page_content=(
"ACME사 연차 정책: 정규직은 입사 1년이 지나면 연 15일의 연차가 부여된다. "
"근속 3년 이상 16일, 5년 이상 18일, 10년 이상 20일. 미사용 연차는 다음 해 "
"6월 30일까지 이월 가능, 이후 소멸."),
metadata={"source": "HR/연차정책_v3.md"}),
Document(page_content=(
"특별휴가: 본인 결혼 5일, 자녀 결혼 1일, 배우자 출산 10일, "
"본인/배우자 부모 사망 5일, 조부모 사망 3일. 부모 환갑·칠순 등 일반 가족행사는 "
"별도 특별휴가 없이 연차로 처리한다."),
metadata={"source": "HR/특별휴가.md"}),
Document(page_content=(
"재택근무: 정규직 주 2회 가능. 사전 팀장 승인 필요. 화·목 재택 비권장(전사 회의). "
"신입사원은 입사 후 3개월간 전일 출근."),
metadata={"source": "HR/재택근무정책.md"}),
]
# ── 2) 청킹 ─────────────────────────────────────────────
splitter = RecursiveCharacterTextSplitter(
chunk_size=300, chunk_overlap=50,
separators=["\n\n", "\n", ". ", " ", ""])
chunks = splitter.split_documents(raw)
# ── 3) 임베딩 + 벡터 저장 ────────────────────────────────
emb = HuggingFaceEmbeddings(
model_name="BAAI/bge-m3",
encode_kwargs={"normalize_embeddings": True})
vectordb = Chroma.from_documents(chunks, emb, collection_name="acme_hr")
# ── 4) 검색기 ───────────────────────────────────────────
retriever = vectordb.as_retriever(search_kwargs={"k": 3})
# ── 5) 프롬프트 + LLM + 체인 ────────────────────────────
prompt = ChatPromptTemplate.from_messages([
("system",
"ACME사 HR 어시스턴트. 아래 [참고 문서]만 근거로 답하세요. "
"찾을 수 없으면 '제공된 문서에서 답을 찾을 수 없습니다.'라고 답하세요. "
"각 주장 끝에 [파일명]으로 출처를 다세요."),
("human", "[참고 문서]\n{context}\n\n[질문]\n{question}")])
llm = ChatAnthropic(model="claude-opus-4-7", temperature=0)
def fmt(docs):
return "\n\n".join(f"[{d.metadata['source']}]\n{d.page_content}" for d in docs)
# LCEL: LangChain Expression Language. `|` 파이프로 컴포넌트를 연결한다.
# 유닉스 셸의 `cat file | grep ... | wc -l` 과 같은 발상.
chain = ({"context": retriever | fmt, "question": RunnablePassthrough()}
| prompt | llm | StrOutputParser())
# ── 실행 ───────────────────────────────────────────────
for q in [
"입사 1년차에 부모님 환갑이면 휴가를 며칠 받을 수 있나요?",
"근속 7년차의 연차는?",
"신입사원도 재택근무 가능한가요?",
"회사 점심값 지원되나요?", # 문서에 없음 → 모른다고 답해야 정상
]:
print(f"\n━━━ Q: {q}\n> {chain.invoke(q)}")
무엇을 보여주는가
- 5단계 흐름이 코드 한 화면에 모두 보임
- “문서에 없는 질문"에 대한 거절 동작 확인 가능
- 한국어/영문 혼합 문서에 다국어 임베딩
bge-m3로 대응
한계 (이 예제에서 직접 체감 가능)
- 동의어/의역에 약함 — “연봉” vs “급여”
- 정확한 코드/식별자가 키워드일 때 놓칠 수 있음 — “ERR_404”
- 다중 홉 — “최근 가장 많이 휴가 쓴 직원의 부서장은?” → 단일 청크로 답 불가
- 검색이 틀리면 답이 무조건 틀림
→ 다음 단계 Advanced RAG로 이 한계들을 차례로 보완한다.
6. Advanced RAG 심층 분석
Advanced RAG는 Naive RAG의 검색 파이프라인을 검색 전·중·후의 3단계로 나누어 모두 강화한 형태다. 데이터 표현은 여전히 청크+임베딩이지만, 각 단계에 정교한 기법을 추가해 검색 정확도와 답변 품질을 크게 끌어올린다.
┌─ Pre-retrieval ────┐
원본 문서 ─→ 의미 청킹 ──→│ 메타데이터 부착 │
│ Contextual Embedding │
└──────────┬───────────┘
▼
사용자 질문 ─→ 쿼리 변환 ─→ Hybrid 검색 ──→ Reranking ──→ 문맥 압축 ─→ 프롬프트 ─→ LLM ─→ 답변
(Pre-retrieval) (Retrieval) (Post-retrieval)6.1 의미론적 청킹 (Semantic Chunking)
고정 크기 청킹은 의미 경계를 무시한다. 의미 청킹은 임베딩 유사도가 급변하는 지점을 경계로 삼아 자연스러운 단위로 나눈다.
from langchain_experimental.text_splitter import SemanticChunker
from langchain_community.embeddings import HuggingFaceEmbeddings
splitter = SemanticChunker(
HuggingFaceEmbeddings(model_name="BAAI/bge-m3"),
breakpoint_threshold_type="percentile", # 또는 "standard_deviation", "interquartile"
breakpoint_threshold_amount=95)
chunks = splitter.create_documents([long_text])
비용은 더 들지만, 긴 보고서나 대화록처럼 의미 단위가 불규칙한 자료에서 효과가 크다.
6.2 Contextual Retrieval (Anthropic, 2024)
각 청크 앞에 그 청크가 속한 문서의 요약 맥락을 LLM이 미리 붙여서 임베딩한다.
원본 청크: "매출이 전년 대비 12% 증가했다."
컨텍스추얼 청크:
"이 청크는 ACME사의 2024 Q3 실적 보고서에서 발췌된 것으로,
재무 성과 섹션에 위치한다. — 매출이 전년 대비 12% 증가했다."Anthropic 보고에 따르면 검색 실패율이 35~67% 감소. 인덱싱 시 LLM 비용이 추가되지만 1회성이고, prompt caching과 결합하면 매우 저렴하게 처리 가능하다.
6.3 쿼리 변환 (Query Transformation)
원본 질문이 검색에 적합하지 않을 때 변형한다.
| 기법 | 설명 | 예시 |
|---|---|---|
| Query Rewriting | LLM이 질문을 명확히 재작성 | “걔네 정책 뭐야?” → “ACME사의 환불 정책은?” |
| Query Expansion | 동의어·관련어 추가 | “퇴사” + “사직, 이직, 퇴직” |
| HyDE | LLM이 가상 답변 생성 → 답변을 임베딩해 검색 | 답변끼리가 답변-질문보다 더 유사 |
| Multi-Query | 한 질문을 N개 변형으로 검색 후 통합 | RRF로 결합 |
| Step-Back | 더 일반적 질문으로 추상화 후 검색 | “X 약의 부작용” → “X 약의 작용 기전은?” |
| Decomposition | 복합 질문을 하위 질문으로 분해 | “A vs B 비교” → [“A는?”, “B는?”] |
HyDE 예시 코드
from langchain_core.prompts import ChatPromptTemplate
from langchain_anthropic import ChatAnthropic
llm = ChatAnthropic(model="claude-opus-4-7", temperature=0)
hyde_prompt = ChatPromptTemplate.from_messages([
("system", "다음 질문에 대해 사실 여부와 무관하게 그럴듯한 한 문단의 답변을 작성하세요."),
("human", "{question}")
])
def hyde_search(question: str, retriever):
hypothetical = (hyde_prompt | llm).invoke({"question": question}).content
# 가상 답변을 임베딩해서 검색 (질문 자체로 검색하는 것보다 보통 더 정확)
return retriever.invoke(hypothetical)
6.4 Hybrid 검색
BM25(키워드) + Dense(벡터) 를 결합. 거의 항상 단독 검색보다 낫다.
from langchain_community.retrievers import BM25Retriever
from langchain.retrievers import EnsembleRetriever
bm25 = BM25Retriever.from_documents(chunks); bm25.k = 10
dense = vectordb.as_retriever(search_kwargs={"k": 10})
hybrid = EnsembleRetriever(
retrievers=[bm25, dense],
weights=[0.4, 0.6]) # 도메인에 따라 튜닝 (코드/식별자 많으면 BM25 비중↑)
RRF (Reciprocal Rank Fusion)
여러 검색기 결과를 결합하는 표준 방식. $$\text{RRF}(d) = \sum_{i} \frac{1}{k + \text{rank}_i(d)}$$ 보통 k=60. EnsembleRetriever 내부 동작과 유사.
6.5 재랭킹 (Reranking)
1차 검색이 후보 50100개를 넓게 가져오고, 재랭커가 정밀하게 510개로 좁힌다.
| 종류 | 정확도 | 속도 | 비용 |
|---|---|---|---|
Cross-encoder (bge-reranker-v2-m3) | 높음 | 보통 | 무료 (셀프호스팅) |
| Cohere Rerank-v3 / Voyage Rerank | 매우 높음 | 빠름 | API 과금 |
| ColBERT (late interaction) | 높음 | 빠름 | 무료 |
| LLM-as-reranker (Claude/GPT) | 매우 높음 | 느림 | 매우 비쌈 |
실험적으로 검증된 효과: 재랭킹 추가만으로 답변 정확도가 흔히 10~20%p 상승.
6.6 문맥 압축 (Contextual Compression)
검색된 문서에서 질문과 무관한 부분을 잘라낸다. 토큰 비용 절감 + Lost-in-the-Middle 완화.
from langchain.retrievers.document_compressors import LLMChainExtractor
from langchain.retrievers import ContextualCompressionRetriever
compressor = LLMChainExtractor.from_llm(llm) # 질문에 답하는 데 필요한 부분만 추출
compressed = ContextualCompressionRetriever(
base_compressor=compressor,
base_retriever=hybrid)
6.7 Self-RAG / CRAG (Modular RAG의 시작점)
검색 결과의 품질을 모델이 스스로 평가해서 분기한다.
- Self-RAG (Asai et al., 2023): 모델이
[Retrieve]토큰으로 검색 필요 여부 결정,[IsRel][IsSup][IsUse]토큰으로 검색/답변 품질 자체 평가 - CRAG (Yan et al., 2024): 검색 결과를 평가해 Correct → 사용, Ambiguous → 보강, Incorrect → 폐기 후 웹 검색
6.8 Lost in the Middle 대응
LLM은 컨텍스트 시작/끝의 정보는 잘 활용하고 중간은 잘 놓친다. 대응:
- 가장 중요한 문서는 맨 앞 또는 맨 뒤에 배치
- top-k는 무작정 늘리지 말고 5~10개로 유지
- 재랭커로 상위 순서를 정확히 맞추기
- 문맥 압축으로 분량 자체를 줄이기
예제 2: Advanced RAG
Hybrid 검색 + Reranking + Multi-Query 쿼리 변환 + 문맥 압축 + 인용 강제 + Self-Eval 까지 모두 한 파이프라인에 통합.
"""example_2_advanced_rag.py — 실전형 Advanced RAG 통합"""
from __future__ import annotations
from typing import List
from dataclasses import dataclass
from langchain_core.documents import Document
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.vectorstores import Chroma
from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_community.retrievers import BM25Retriever
from langchain.retrievers import EnsembleRetriever
from langchain_anthropic import ChatAnthropic
from langchain_core.prompts import ChatPromptTemplate
from sentence_transformers import CrossEncoder
# ════════════════════════════════════════════════════════════════
# 0. 데이터 — 엔지니어링 정책 문서 6종
# ════════════════════════════════════════════════════════════════
KB = [
{"source": "ENG/Coding_Standards.md",
"content": "파이썬은 PEP 8과 black 포매터 준수. 라인 100자. 모든 public 함수에 타입힌트. "
"함수명 snake_case, 클래스 PascalCase, 상수 UPPER_SNAKE_CASE."},
{"source": "ENG/Code_Review_Policy.md",
"content": "PR 머지에는 최소 2명 승인 필요. 시니어 1명 필수. 보안 변경은 보안팀 추가 승인. "
"PR 크기 400줄 권장, 1000줄 초과 시 분할 요청. 리뷰는 2영업일 이내."},
{"source": "ENG/Deploy_Process.md",
"content": "프로덕션 배포는 화·수·목 10:00~16:00. 금요일/공휴일 전일 금지. "
"staging에서 24시간 검증 후 배포. 핫픽스는 CTO 승인 시 시간 제약 우회. "
"배포 후 30분 모니터링 대기."},
{"source": "ENG/On_Call_Policy.md",
"content": "1주 단위 온콜 로테이션. P1 15분, P2 1시간 대응 목표. "
"야간(22:00~08:00)/주말 온콜은 시간당 별도 보상. 휴가는 사전 교대 필수."},
{"source": "ENG/Tech_Stack.md",
"content": "백엔드 Python 3.12 + FastAPI 표준. DB: PostgreSQL 16, 캐시: Redis 7, "
"MQ: RabbitMQ. 프론트 TypeScript + React 18. AWS(ECS/RDS/S3) + Terraform."},
{"source": "HR/Remote_Work.md",
"content": "정규직 주 2회 재택. 팀장 승인 필요. 화·목 재택 비권장. "
"신입은 입사 3개월 전일 출근. 해외 원격은 별도 승인 + 세무 검토."},
]
# ════════════════════════════════════════════════════════════════
# 1. 인덱싱 — Hybrid 검색을 위해 Dense + BM25 둘 다 구축
# ════════════════════════════════════════════════════════════════
def build_retrievers():
docs = [Document(page_content=d["content"], metadata={"source": d["source"]}) for d in KB]
splitter = RecursiveCharacterTextSplitter(
chunk_size=350, chunk_overlap=70,
separators=["\n\n", "\n", ". ", " ", ""])
chunks = splitter.split_documents(docs)
emb = HuggingFaceEmbeddings(
model_name="BAAI/bge-m3", encode_kwargs={"normalize_embeddings": True})
vectordb = Chroma.from_documents(chunks, emb, collection_name="adv_rag")
dense = vectordb.as_retriever(search_kwargs={"k": 8})
bm25 = BM25Retriever.from_documents(chunks); bm25.k = 8
hybrid = EnsembleRetriever(retrievers=[bm25, dense], weights=[0.4, 0.6])
return hybrid
# ════════════════════════════════════════════════════════════════
# 2. 쿼리 변환 — Multi-Query 생성으로 질문 다양화
# ════════════════════════════════════════════════════════════════
multi_query_prompt = ChatPromptTemplate.from_messages([
("system", "사용자 질문을 의미는 유지하되 표현·관점이 다른 3개의 검색 쿼리로 재작성하세요. "
"각 줄에 하나씩, 번호 없이 출력하세요."),
("human", "{question}")
])
llm = ChatAnthropic(model="claude-opus-4-7", temperature=0)
def multi_queries(question: str) -> List[str]:
raw = (multi_query_prompt | llm).invoke({"question": question}).content
qs = [q.strip("-•123456789. ").strip() for q in raw.split("\n") if q.strip()]
return [question] + qs[:3] # 원본 + 변형 3개
# ════════════════════════════════════════════════════════════════
# 3. 재랭킹 — Cross-encoder로 후보 정렬
# ════════════════════════════════════════════════════════════════
class Reranker:
def __init__(self, name="BAAI/bge-reranker-v2-m3"):
self.m = CrossEncoder(name, max_length=512)
def __call__(self, query: str, docs: List[Document], top_n=4) -> List[Document]:
if not docs: return []
scores = self.m.predict([(query, d.page_content) for d in docs])
ranked = sorted(zip(scores, docs), key=lambda x: x[0], reverse=True)
# 중복 제거 (page_content 기준)
seen, out = set(), []
for s, d in ranked:
if d.page_content in seen: continue
seen.add(d.page_content)
d.metadata["rerank_score"] = float(s)
out.append(d)
if len(out) == top_n: break
return out
# ════════════════════════════════════════════════════════════════
# 4. 답변 생성 — 인용 강제
# ════════════════════════════════════════════════════════════════
answer_prompt = ChatPromptTemplate.from_messages([
("system",
"ACME사 엔지니어링 어시스턴트. 아래 [참고 문서]만 근거로 답하세요.\n"
"규칙:\n"
"1) 모든 사실 끝에 [번호] 형식 출처 표시.\n"
"2) 여러 문서가 뒷받침하면 [1][3]처럼 다수 표기.\n"
"3) 문서에 없으면 '문서에 명시되어 있지 않습니다'라고 답.\n"
"4) 간결한 한국어로 핵심만."),
("human", "[참고 문서]\n{context}\n\n[질문]\n{question}")
])
@dataclass
class Result:
answer: str
sources: List[Document]
queries_used: List[str]
def make_ctx(docs: List[Document]) -> str:
return "\n\n".join(
f"[{i}] (출처: {d.metadata['source']})\n{d.page_content}"
for i, d in enumerate(docs, 1))
def advanced_rag(question: str, hybrid, reranker) -> Result:
# ① Multi-Query 변환
queries = multi_queries(question)
# ② Hybrid 검색 (각 변형 쿼리마다)
candidates: List[Document] = []
seen = set()
for q in queries:
for d in hybrid.invoke(q):
key = d.page_content
if key not in seen:
seen.add(key); candidates.append(d)
# ③ 재랭킹 (원본 질문 기준으로 정밀 정렬)
top = reranker(question, candidates, top_n=4)
# ④ 답변 생성 (인용 강제)
msg = answer_prompt.invoke({"context": make_ctx(top), "question": question})
ans = llm.invoke(msg).content
return Result(answer=ans, sources=top, queries_used=queries)
# ════════════════════════════════════════════════════════════════
# 5. Self-Evaluation — Faithfulness 자동 점검
# ════════════════════════════════════════════════════════════════
judge_prompt = ChatPromptTemplate.from_messages([
("system", "RAG 답변 충실도(faithfulness) 심사. [참고 문서]에 답변의 모든 사실이 "
"뒷받침되면 PASS, 하나라도 근거 없으면 FAIL. 첫 줄에 PASS/FAIL, 둘째 줄부터 사유."),
("human", "[참고 문서]\n{context}\n\n[답변]\n{answer}\n\n평가:")
])
def judge(res: Result) -> str:
msg = judge_prompt.invoke({"context": make_ctx(res.sources), "answer": res.answer})
return llm.invoke(msg).content
# ════════════════════════════════════════════════════════════════
# 6. 실행
# ════════════════════════════════════════════════════════════════
if __name__ == "__main__":
hybrid = build_retrievers()
rerank = Reranker()
for q in [
"보안 관련 PR 머지하려면 누가 승인해줘야 해?",
"금요일 오후 핫픽스 배포 가능?",
"신입사원 재택근무 신청 가능?",
"DB와 캐시는 뭘 쓰지?",
"회사 점심 메뉴는?", # 문서에 없음
]:
print(f"\n{'='*72}\nQ: {q}")
r = advanced_rag(q, hybrid, rerank)
print(f"\n변형 쿼리들: {r.queries_used}")
print(f"\n검색+재랭킹 top-{len(r.sources)}:")
for i, d in enumerate(r.sources, 1):
print(f" [{i}] {d.metadata['source']:28s} "
f"score={d.metadata.get('rerank_score',0):+.2f}")
print(f"\n> 답변:\n{r.answer}")
print(f"\nSelf-Eval:\n{judge(r)}")
무엇이 좋아졌는가 — Naive 대비
| 측면 | Naive | Advanced |
|---|---|---|
| 동의어/의역 | 약함 | Multi-Query + Hybrid로 극복 |
| 정확한 식별자 | 약함 | BM25 결합으로 강함 |
| 검색 후보 정밀 정렬 | 단순 코사인 점수 | Cross-encoder 재랭킹 |
| 답변 검증 | 없음 | 인용 + Self-Eval |
| Lost in the Middle | 무방비 | 재랭킹으로 상위 정렬 |
한계 (이 단계에서도 어려운 것)
- 다중 홉 추론: “최근 가장 많이 배포한 팀의 코드 리뷰 정책은?” → 단일 청크로 답 불가
- 관계 질문: “프로젝트 X에 참여한 사람들 중 보안팀과 협업하는 사람은?” → 관계 추적 필요
→ 이를 해결하기 위해 다음 단계로 Graph RAG가 등장한다.
8. Graph RAG 심층 분석
Graph RAG는 문서를 청크가 아니라 엔티티-관계 그래프로 표현해 검색하는 방식이다. Microsoft Research가 2024년 발표한 “From Local to Global: A Graph RAG Approach” 논문이 결정적 계기가 되었다.
8.1 왜 그래프인가 — 벡터 RAG의 한계
벡터 RAG는 “이 청크가 이 질문과 의미적으로 가깝다"만 찾을 수 있다. 다음 같은 질문에는 약하다.
- 다중 홉: “프로젝트 알파의 PM이 이전에 일했던 회사는?” → (프로젝트→PM→경력) 연쇄 추적 필요
- 관계 질의: “김철수와 박영희가 함께 참여한 프로젝트는?” → 두 사람의 교집합 필요
- 글로벌 이해: “이 회사의 핵심 인물 5명을 영향력 순으로?” → 전체 그래프 구조 파악 필요
- 시간/원인 연쇄: “사건 A가 사건 C에 영향을 미친 경로는?” → 인과 그래프 탐색 필요
이런 질문은 관계 자체가 정보다. 문서 텍스트 안에 직접 답이 적혀 있지 않을 수도 있다 — 여러 문서의 정보를 조합해야 답이 나온다.
8.2 핵심 아이디어: 인덱싱 단계
문서에서 (주체, 관계, 객체) 트리플을 추출해 그래프 DB(Neo4j, Memgraph, NetworkX 등)에 저장.
문서: "김철수는 ACME사의 CTO이며, 프로젝트 알파를 이끌고 있다.
박영희는 알파 프로젝트의 보안 책임자이다."
추출된 트리플:
(김철수, IS_CTO_OF, ACME사)
(김철수, LEADS, 프로젝트 알파)
(박영희, IS_SECURITY_LEAD_OF, 프로젝트 알파)
→ 그래프에서 "김철수"와 "박영희"는 "프로젝트 알파"를 통해 2-hop 연결됨.추출 방법
- LLM 기반 추출: GPT/Claude에게 “이 문서에서 엔티티와 관계를 추출하라” 프롬프트로 처리. 가장 보편적.
- NER + Relation Extraction 모델: spaCy + REBEL 같은 전용 모델. 도메인 특화 가능.
- 수동 스키마 + 파서: 의료/법률 등 정형성 높은 도메인.
엔티티 정규화 (Entity Resolution)
“김철수”, “Cheolsoo Kim”, “C. Kim”, “the CTO” 같은 표현이 같은 인물을 가리킬 수 있다. 이걸 하나의 노드로 합치는 작업이 그래프 품질을 좌우한다. LLM 임베딩 기반 클러스터링으로 처리.
8.3 Microsoft GraphRAG의 핵심 혁신: 커뮤니티 요약
단순 그래프만으로는 “글로벌” 질문(전체 흐름 이해)에 약하다. Microsoft GraphRAG는:
- 그래프를 구축한 뒤 Leiden 알고리즘으로 커뮤니티(밀집 연결된 노드 그룹)를 탐지
- 각 커뮤니티에 대해 LLM이 요약문을 미리 생성
- 글로벌 질문은 → 커뮤니티 요약들을 map-reduce로 통합해 답변
- 로컬 질문은 → 특정 엔티티 주변 서브그래프를 답변
이것이 단순 그래프 탐색 RAG와 GraphRAG의 결정적 차이다.
8.4 쿼리 단계: Local vs Global
| 종류 | 질문 예시 | 처리 방식 |
|---|---|---|
| Local | “김철수의 직책은?”, “프로젝트 알파의 멤버는?” | 엔티티 식별 → BFS로 N-hop 서브그래프 → 요약 |
| Global | “이 조직의 주요 이슈 5가지는?” | 모든 커뮤니티 요약을 LLM이 통합 → map-reduce |
| Drift | 두 종류가 섞인 질문 | 둘 다 활용 후 결합 |
8.5 GraphRAG vs 벡터 RAG 하이브리드
실무에서는 Hybrid Graph RAG가 표준이다.
[질의 입력]
├─→ 엔티티 추출 → 그래프 탐색 (관계 기반 근거)
└─→ 벡터 검색 (의미 기반 근거)
↓
[근거 결합 + LLM 답변]- 그래프는 관계를, 벡터는 내용을 책임짐
- LangChain의
Neo4jVector+GraphCypherQAChain조합이 대표적 - LlamaIndex의
KnowledgeGraphIndex+VectorStoreIndex조합도 인기
8.6 대표 구현체 비교
| 구현체 | 특징 | 적합한 경우 |
|---|---|---|
| Microsoft GraphRAG | 가장 완성도 높음. 커뮤니티 요약, Leiden 클러스터링 | 정공법, 큰 코퍼스 |
| LightRAG (HKU, 2024) | 더 가볍고 빠름. 듀얼 레벨 검색(저수준 엔티티 + 고수준 키워드) | 빠른 구축 |
| LangChain + Neo4j | LLMGraphTransformer + GraphCypherQAChain | 프로덕션, Cypher 기반 정밀 질의 |
| LlamaIndex KG Index | TripletExtractor + KnowledgeGraphIndex | 빠른 프로토타이핑 |
| NetworkX (in-memory) | DB 없이 학습/실험용 | 본 가이드의 예제 3 |
8.7 Graph RAG의 진짜 비용
- 인덱싱 비용 폭증: 모든 문서를 LLM에 통과시켜 트리플 추출 → 토큰 비용 큼
- 스키마 설계: “엔티티 타입은?”, “관계 타입은?” 정의가 어려움
- 그래프 운영: Neo4j 같은 별도 DB 운영 노하우 필요
- 엔티티 정규화 실패 시 그래프가 너덜너덜: 동의어 처리가 핵심
→ 그래서 “먼저 Advanced RAG로 시작하고, 관계 질문이 실제로 많이 나오면 Graph RAG 추가” 가 보편적인 실무 권장 순서.
예제 3: Graph RAG
Neo4j 같은 외부 DB 없이 NetworkX 인메모리 그래프로 Graph RAG의 핵심 흐름(엔티티 추출 → 그래프 구축 → 그래프 탐색 → 답변)을 보여준다. 실무에서는 Neo4j + LangChain의
LLMGraphTransformer로 갈아끼우면 된다.
"""example_3_graph_rag.py — NetworkX 기반 미니 GraphRAG"""
from __future__ import annotations
import json
from typing import List, Tuple, Dict, Set
from dataclasses import dataclass, field
import networkx as nx
from langchain_anthropic import ChatAnthropic
from langchain_core.prompts import ChatPromptTemplate
# ════════════════════════════════════════════════════════════════
# 0. 데이터 — 인물·프로젝트·조직 관계가 풍부한 가상 회사 위키
# ════════════════════════════════════════════════════════════════
DOCS = [
"김철수는 ACME사의 CTO이며 2019년에 입사했다. 이전에는 BlueTech의 시니어 엔지니어였다. "
"그는 현재 프로젝트 알파를 총괄하고 있으며, 머신러닝 인프라 팀의 디렉터를 겸임한다.",
"박영희는 ACME사의 보안팀장으로, 프로젝트 알파의 보안 책임자를 맡고 있다. "
"이전에 SecureCorp에서 10년간 근무했고, 김철수와는 BlueTech 시절부터 동료였다.",
"이민수는 ACME사 데이터플랫폼 팀의 시니어 엔지니어이다. 프로젝트 알파의 데이터 파이프라인을 담당하며, "
"김철수에게 직접 보고한다. 박영희와는 보안 감사 작업으로 협업 중이다.",
"최지훈은 프로젝트 베타의 PM이다. 베타 프로젝트는 신규 결제 시스템 구축을 목표로 하며, "
"이민수가 베타에도 일부 참여해 데이터 마이그레이션을 지원하고 있다.",
"프로젝트 알파는 ACME사의 차세대 추천 시스템이며 2024년 1월에 시작되었다. "
"프로젝트 베타는 2024년 6월에 시작된 결제 시스템 프로젝트이다. "
"두 프로젝트는 모두 머신러닝 인프라 팀의 지원을 받는다.",
]
# ════════════════════════════════════════════════════════════════
# 1. LLM으로 엔티티/관계 트리플 추출
# ════════════════════════════════════════════════════════════════
llm = ChatAnthropic(model="claude-opus-4-7", temperature=0)
extract_prompt = ChatPromptTemplate.from_messages([
("system",
"다음 문서에서 엔티티와 관계를 추출해 JSON 트리플 리스트로 출력하세요.\n"
"각 트리플은 {\"s\": 주체, \"r\": 관계, \"o\": 객체} 형식.\n"
"엔티티는 사람·조직·프로젝트·역할 등 명확한 개체만 포함.\n"
"관계는 짧은 동사구로 (예: WORKS_AT, LEADS, IS_CTO_OF, REPORTS_TO, COLLABORATES_WITH).\n"
"동일 인물의 다른 표현은 같은 이름으로 통일하세요.\n"
"출력은 순수 JSON 배열, 다른 텍스트 금지."),
("human", "{document}")
])
def _safe_json_parse(text: str, default):
"""LLM 응답에서 JSON 부분만 추출 (markdown fence 등 제거)"""
import re
m = re.search(r"(\[.*\]|\{.*\})", text, re.DOTALL)
if not m:
return default
try:
return json.loads(m.group(1))
except json.JSONDecodeError:
return default
def extract_triples(doc: str) -> List[Dict]:
raw = (extract_prompt | llm).invoke({"document": doc}).content
return _safe_json_parse(raw, default=[])
# ════════════════════════════════════════════════════════════════
# 2. NetworkX 그래프 구축 (+ 원본 문서 인덱스)
# ════════════════════════════════════════════════════════════════
@dataclass
class KnowledgeGraph:
G: nx.MultiDiGraph = field(default_factory=nx.MultiDiGraph)
# 엔티티 → 그 엔티티가 등장한 원본 문서 인덱스 집합
ent2docs: Dict[str, Set[int]] = field(default_factory=dict)
docs: List[str] = field(default_factory=list)
def build_kg(docs: List[str]) -> KnowledgeGraph:
kg = KnowledgeGraph(docs=docs)
for i, d in enumerate(docs):
triples = extract_triples(d)
for t in triples:
s, r, o = t.get("s"), t.get("r"), t.get("o")
if not (s and r and o): continue
kg.G.add_edge(s, o, relation=r, doc_idx=i)
kg.ent2docs.setdefault(s, set()).add(i)
kg.ent2docs.setdefault(o, set()).add(i)
return kg
# ════════════════════════════════════════════════════════════════
# 3. 질의에서 엔티티 추출
# ════════════════════════════════════════════════════════════════
query_ent_prompt = ChatPromptTemplate.from_messages([
("system", "질문에서 등장한 엔티티(사람·조직·프로젝트·역할 등)만 JSON 배열로 추출하세요. "
"예: [\"김철수\", \"프로젝트 알파\"]. 다른 텍스트 금지."),
("human", "{question}")
])
def extract_query_entities(q: str) -> List[str]:
raw = (query_ent_prompt | llm).invoke({"question": q}).content
return _safe_json_parse(raw, default=[])
# ════════════════════════════════════════════════════════════════
# 4. 그래프 탐색 — 질의 엔티티 주변 N-hop 서브그래프
# ════════════════════════════════════════════════════════════════
def find_node(kg: KnowledgeGraph, name: str) -> str | None:
"""정확 매칭 우선, 실패 시 부분 문자열 매칭으로 노드 찾기"""
if name in kg.G: return name
for n in kg.G.nodes:
if name in n or n in name:
return n
return None
def subgraph_around(kg: KnowledgeGraph, entities: List[str], hops: int = 2) -> Tuple[nx.MultiDiGraph, Set[int]]:
"""질의 엔티티들을 시드로 N-hop 이웃까지 모은 서브그래프 + 관련 문서 인덱스"""
seed_nodes = {n for e in entities if (n := find_node(kg, e))}
if not seed_nodes:
return nx.MultiDiGraph(), set()
# 무방향 그래프로 변환해 양방향 BFS
undirected = kg.G.to_undirected()
visited = set(seed_nodes)
frontier = set(seed_nodes)
for _ in range(hops):
next_frontier = set()
for n in frontier:
if n not in undirected: continue
next_frontier.update(undirected.neighbors(n))
frontier = next_frontier - visited
visited |= frontier
sub = kg.G.subgraph(visited).copy()
# 관련 문서 인덱스 모으기
doc_ids = set()
for n in visited:
doc_ids.update(kg.ent2docs.get(n, set()))
return sub, doc_ids
def serialize_subgraph(sub: nx.MultiDiGraph) -> str:
"""서브그래프를 LLM에 전달할 텍스트로 변환"""
if sub.number_of_edges() == 0:
return "(관련 그래프 없음)"
lines = []
for u, v, data in sub.edges(data=True):
lines.append(f"({u}) -[{data['relation']}]-> ({v})")
return "\n".join(sorted(set(lines)))
# ════════════════════════════════════════════════════════════════
# 5. 답변 생성 — 그래프 + 원본 문서 둘 다 컨텍스트로 제공
# ════════════════════════════════════════════════════════════════
graph_answer_prompt = ChatPromptTemplate.from_messages([
("system",
"당신은 사내 지식 어시스턴트입니다. 아래 [지식 그래프]와 [원본 문서]만 근거로 답하세요.\n"
"- 그래프의 관계를 따라가며 다중 홉 추론을 수행하세요.\n"
"- 답변에 사용한 관계는 (A) -[관계]-> (B) 형식으로 인용하세요.\n"
"- 근거가 부족하면 '제공된 정보로는 답할 수 없습니다.'라고 답하세요."),
("human",
"[지식 그래프]\n{graph}\n\n[원본 문서]\n{docs}\n\n[질문]\n{question}")
])
def graph_rag(question: str, kg: KnowledgeGraph) -> str:
ents = extract_query_entities(question)
sub, doc_ids = subgraph_around(kg, ents, hops=2)
graph_text = serialize_subgraph(sub)
doc_text = "\n\n".join(f"[doc{i}] {kg.docs[i]}" for i in sorted(doc_ids)) or "(관련 문서 없음)"
print(f" · 질의 엔티티: {ents}")
print(f" · 서브그래프 노드 {sub.number_of_nodes()}개, 엣지 {sub.number_of_edges()}개")
print(f" · 관련 원본 문서: {sorted(doc_ids)}")
msg = graph_answer_prompt.invoke({
"graph": graph_text, "docs": doc_text, "question": question})
return llm.invoke(msg).content
# ════════════════════════════════════════════════════════════════
# 6. 실행
# ════════════════════════════════════════════════════════════════
if __name__ == "__main__":
print("[1/3] 그래프 인덱싱 중...")
kg = build_kg(DOCS)
print(f" 완료. 노드 {kg.G.number_of_nodes()}개, 엣지 {kg.G.number_of_edges()}개\n")
# 그래프 미리보기
print("[2/3] 추출된 트리플 (전체):")
for u, v, data in kg.G.edges(data=True):
print(f" ({u}) -[{data['relation']}]-> ({v}) (문서{data['doc_idx']})")
# 다중 홉이 진짜로 필요한 질문들
print("\n[3/3] Graph RAG 질의응답:")
for q in [
# 1-hop: 단순 사실
"김철수는 어느 회사의 CTO야?",
# 2-hop: 다중 홉 — 김철수의 이전 회사 동료가 누구야?
"박영희와 김철수는 어떻게 알게 됐어?",
# 관계 교집합: 두 사람의 공통 프로젝트
"이민수와 박영희가 함께 일하는 영역은?",
# 다중 홉 + 집계: 한 명이 여러 프로젝트에 연결
"이민수가 참여한 프로젝트는 어떤 것들이고, 그 프로젝트의 다른 핵심 멤버는 누구야?",
# 그래프에 없는 정보
"김철수의 연봉은?",
]:
print(f"\n━━━ Q: {q}")
print(f"> {graph_rag(q, kg)}")
무엇을 보여주는가
이 예제는 작지만 Graph RAG의 핵심 4단계를 모두 보여준다.
- 엔티티/관계 추출 — LLM으로 트리플 생성 (실무에선 한 번 인덱싱하고 캐시)
- 그래프 구축 — NetworkX 인메모리 (Neo4j로 갈아끼울 수 있게 추상화)
- 그래프 탐색 — 질의 엔티티 → 2-hop 서브그래프 + 관련 문서
- 답변 생성 — 그래프 + 원본 문서 둘 다 컨텍스트로 사용
특히 “박영희와 김철수는 어떻게 알게 됐어?” 같은 질문은 단일 청크에 답이 없다 — 그래프에서 두 사람이 BlueTech를 공통 노드로 연결되어 있어야 답이 나온다. 벡터 RAG로는 매우 어려운 질문이다.
프로덕션으로 가려면
| 컴포넌트 | 본 예제 | 프로덕션 |
|---|---|---|
| 그래프 저장 | NetworkX (in-memory) | Neo4j, Memgraph, NebulaGraph |
| 트리플 추출 | LLM 즉석 호출 | LangChain LLMGraphTransformer, 캐시 |
| 엔티티 정규화 | 부분 문자열 매칭 | 임베딩 클러스터링 + 사람 검수 |
| 질의 | BFS 서브그래프 | Cypher 자동 생성 (GraphCypherQAChain) |
| 글로벌 질의 | 미지원 | 커뮤니티 탐지 + 요약 (Microsoft GraphRAG) |
| 평가 | 수동 | RAGAS 그래프 평가 + golden set |
LangChain으로 넘어갈 때 핵심 변경은 단 두 줄이다.
from langchain_neo4j import Neo4jGraph
from langchain_experimental.graph_transformers import LLMGraphTransformer
graph = Neo4jGraph(url=..., username=..., password=...)
transformer = LLMGraphTransformer(llm=llm)
graph_documents = transformer.convert_to_graph_documents(docs)
graph.add_graph_documents(graph_documents)
이후 GraphCypherQAChain이 자연어 질문을 Cypher로 변환해 Neo4j를 직접 질의한다.
Part III — 운영과 의사결정
10. 평가 방법과 지표
RAG는 검색 품질과 생성 품질을 분리해서 평가해야 한다.
10.1 검색 평가 지표
| 지표 | 설명 |
|---|---|
| Recall@K | 정답 문서가 상위 K 안에 들어왔는가 (재현율) |
| Precision@K | 상위 K 중 정답 비율 |
| MRR (Mean Reciprocal Rank) | 정답이 처음 등장한 순위의 역수 평균 |
| nDCG@K | 순위 가중치 고려한 정규화 점수 |
| Hit Rate@K | 정답이 K 안에 하나라도 있으면 1 |
10.2 생성 평가 지표
| 지표 | 설명 |
|---|---|
| Faithfulness | 답변이 검색 컨텍스트에 충실한가 (환각 여부) |
| Answer Relevance | 답변이 질문에 부합하는가 |
| Context Precision | 컨텍스트 중 실제 답변에 사용된 비율 |
| Context Recall | 정답에 필요한 정보가 컨텍스트에 모두 들어왔는가 |
| Answer Correctness | 정답과 비교한 사실 정확도 |
10.3 평가 도구
- RAGAS: 가장 널리 쓰이는 RAG 평가 프레임워크. LLM-as-judge 기반
- TruLens: 추적 + 평가 통합
- DeepEval: 단위 테스트 스타일, pytest와 통합 좋음
- ARES: 자동화된 RAG 평가, 합성 데이터셋 활용
10.4 RAGAS 사용 예시 (간단)
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy, context_precision, context_recall
from datasets import Dataset
eval_data = Dataset.from_dict({
"question": ["입사 1년차 부모님 환갑 휴가는?"],
"answer": ["일반 가족행사이므로 별도 특별휴가 없이 연차로 처리됩니다 [HR/특별휴가.md]."],
"contexts": [["부모 환갑·칠순 등 일반 가족행사는 별도 특별휴가 없이 연차로 처리한다."]],
"ground_truth": ["연차로 처리해야 합니다."],
})
result = evaluate(eval_data, metrics=[
faithfulness, answer_relevancy, context_precision, context_recall])
print(result)
10.5 운영 단계 모니터링
- 검색 리콜·적중률 (golden set 기반 일배치)
- 컨텍스트 사용 비율 (실제 인용된 문서 / 검색된 문서)
- 환각률 (LLM-as-judge 추정)
- 응답 지연 (P50, P95, P99)
- 토큰 비용 / 쿼리당 비용
- 사용자 피드백 (좋아요/싫어요 + 자유 텍스트)
- 인덱스 신선도 (마지막 갱신 시각)
11. 최신 기술 동향 (2024–2026)
11.1 Agentic RAG의 부상 (2024 ~ )
가장 큰 흐름. “한 번 검색해서 답변” 에서 “에이전트가 검색을 조율” 으로 이동.
- 모델이 검색 필요 여부를 스스로 판단 (Self-RAG)
- 결과 품질이 부족하면 보강하거나 폐기 (CRAG)
- 도구를 상황별로 선택 (벡터/SQL/웹/API)
- 다단계 추론과 검색을 인터리빙 (ReAct)
11.2 GraphRAG와 구조화 검색 (2024)
Microsoft GraphRAG가 “벡터만으론 글로벌 이해와 다중 홉에 약하다” 는 인식을 확산시켰다. LightRAG, HippoRAG 등 후속 연구도 활발.
11.3 Contextual Retrieval (Anthropic, 2024)
각 청크에 문서 레벨 맥락을 LLM이 부여. 검색 실패율 35~67% 감소. Prompt Caching과 결합하면 비용 부담도 작음.
11.4 Long Context vs RAG 논쟁 (정리됨)
Gemini, Claude, GPT가 100만+ 토큰 지원하면서 “RAG가 죽었다” 는 주장이 한때 있었으나, 현실적으로는:
- 비용 (매번 100만 토큰 처리는 비현실적)
- Lost in the Middle 여전히 존재
- Freshness — 실시간 갱신은 RAG가 유리
- 권한 — 사용자별 분리는 검색 단계가 자연스러움
→ 결론: RAG는 살아있고, Long Context는 RAG 컨텍스트 확장에 활용되는 방향으로 정착.
11.5 CAG / TAG / KAG
| 약어 | 풀어쓰기 | 핵심 |
|---|---|---|
| CAG | Cache-Augmented Generation | 자주 쓰는 지식을 KV 캐시로 미리 적재 |
| TAG | Table-Augmented Generation | 표 형태 데이터에 SQL/SPJ 연산 결합 |
| KAG | Knowledge-Augmented Generation | 지식 그래프 기반 추론 강화 |
흐름은 명확하다: “모든 문제를 RAG 하나로 해결하지 말고, 데이터 성격에 맞는 증강 방식을 조합하자.”
11.6 Multimodal RAG
이미지·표·도표·오디오·영상까지 검색 대상으로 확장. CLIP, BLIP-2, ColPali (문서 이미지 자체를 벡터화) 같은 멀티모달 임베딩 발전. PDF의 표·도표가 많은 도메인(재무·의료)에서 큰 효과.
11.7 작은 LLM + RAG의 부상
7B~13B 오픈 모델 + 잘 설계된 RAG 파이프라인이 GPT-4 단독 못지않은 성능을 내는 사례 다수. 비용·프라이버시·온프레미스 운영에서 강력.
12. 한계와 대안
12.1 RAG의 본질적 한계
| 한계 | 설명 |
|---|---|
| 검색이 천장 | 검색 틀리면 생성도 틀림. Garbage in, garbage out |
| 청킹 임의성 | 청크 경계가 의미 단위와 어긋나면 정보 분산 |
| 다중 홉 약함 | 연쇄 추론에는 단순 검색 부족 → Graph RAG 필요 |
| 컨텍스트 비용 | top-k 늘리면 비용·지연 증가 |
| 일관성 부족 | 같은 질문에 검색 결과 약간만 달라도 답이 흔들림 |
| 추론 학습 불가 | 모델의 추론 스타일은 못 바꿈 (그건 파인튜닝의 몫) |
| 보안: 프롬프트 인젝션 | 검색된 문서에 악성 프롬프트가 섞이면 LLM이 휘둘림 |
| 보안: 권한 누출 | 사용자별 권한 분리가 잘못되면 데이터 유출 |
12.2 대안
Fine-tuning
| 항목 | RAG | Fine-tuning |
|---|---|---|
| 지식 갱신 | 즉시 | 재학습 필요 |
| 환각 통제 | 강함 (인용) | 약함 |
| 출처 제시 | 가능 | 불가능 |
| 형식·말투 학습 | 약함 | 강함 |
| 추론 패턴 학습 | 불가 | 가능 |
→ 둘은 대체재가 아니라 보완재. 형식/스타일/추론은 파인튜닝, 사실 지식은 RAG.
Long Context (검색 없이 길게 넣기)
문서가 적고 매번 같은 자료라면 단순 long context가 RAG보다 나을 수 있다. 책 1권 분석, 단일 계약서 검토 등.
Knowledge Graph / Text-to-SQL
엔터프라이즈 데이터처럼 관계가 정형이면 SQL/Cypher 자동 생성이 RAG보다 정확. “지난 분기 가장 매출 큰 고객 5명” → SQL이 정답.
Tool Use / Function Calling
검색 외 도구로 대체. “현재 환율” → 환율 API 호출.
캐시·룰 기반
자주 나오는 질문은 미리 답을 캐시하거나 룰 매칭. FAQ 90%는 100개 정형 답변으로 커버되는 경우가 많음.
12.3 실전 권장: 하이브리드 라우팅
사용자 질문
↓
[1] 캐시/FAQ 매칭 → 적중 시 즉시 응답
↓ (캐시 미스)
[2] 의도 분류기
├─ 정형/수치/집계 → SQL/API
├─ 관계 추론 → Graph RAG
├─ 비정형 문서 → Advanced RAG (Hybrid + Rerank)
└─ 일반 잡담 → LLM 단독
↓
[3] 답변 + 인용 + 가드레일
↓
[4] 평가 + 로깅 + 피드백 루프13. 3종 비교 및 의사결정 가이드
13.1 Naive vs Advanced vs Graph 한눈 비교
| 항목 | Naive RAG | Advanced RAG | Graph RAG |
|---|---|---|---|
| 데이터 표현 | 청크 + 임베딩 | 청크 + 임베딩 + 메타 | 노드 + 엣지 (+ 임베딩) |
| 검색 방식 | 벡터 유사도 | Hybrid + Rerank | 그래프 탐색 (+ 벡터) |
| 쿼리 변환 | X | Multi-Query, HyDE 등 | 엔티티 추출 + Cypher |
| 다중 홉 | X | △ | O |
| 글로벌 이해 | X | △ | O (커뮤니티 요약) |
| 구현 난이도 | 낮음 | 보통 | 매우 높음 |
| 인덱싱 비용 | 낮음 | 중간 | 높음 (LLM 트리플 추출) |
| 운영 비용 | 낮음 | 중간 | 높음 (그래프 DB 운영) |
| 적합 데이터 | 일반 문서, FAQ | 기술 문서, 사내 위키 | 인물·조직·사건·관계 |
13.2 한 줄 결정 가이드
“답이 어디에 있는가?”
- 비정형 문서 안에 → Advanced RAG
- 문서들의 관계 안에 → Graph RAG
- 정형 DB 안에 → Text-to-SQL
- 모델 가중치 안에 → Fine-tuning
- 외부 API/도구 안에 → Tool Use
- 매번 같은 짧은 자료 → Long Context
- 시간이 지나며 축적되는 개인/연구 지식 → LLM Wiki (§16)
13.3 단계별 도입 권장 순서
대부분의 조직에 권장하는 순서:
- Naive RAG로 PoC — 1~2주, 가능성/한계 파악
- Advanced RAG로 본격 배포 — Hybrid + Rerank + 인용 강제
- 평가 셋 + 모니터링 구축 — 골든 셋 100~200개, RAGAS 자동 평가
- 관계 질문이 많이 나오면 Graph RAG 추가 — 보통 Advanced의 30~50% 보조 형태로
- Modular/Agentic으로 확장 — 라우팅, 도구 호출, Self-RAG/CRAG
14. 실전 체크리스트
14.1 설계
- 사용 사례 명확화 (Q&A? 요약? 분석?)
- 정답이 어디 있는지 매핑 (문서? DB? API? 관계?)
- 데이터 권한·보안 모델 정의
- 갱신 주기 정의
- 평가용 골든 셋 100~200건
14.2 인덱싱
- 모든 포맷 처리되는 로더
- 표·이미지 처리 전략 (Unstructured, LlamaParse 등)
- 메타데이터 스키마 표준화
- 청킹 파라미터 도메인별 조정
- 임베딩 모델 언어/도메인 적합성 확인
- 벡터DB 백업·롤백
- (Graph) 엔티티 정규화 절차
14.3 검색
- Dense 단독 → Hybrid로 단계적 확장
- BM25 인덱스 분리 운영
- Reranker 도입 (정확도 vs 비용 트레이드오프)
- 메타데이터 필터링
- MMR로 다양성 확보
- (Graph) Cypher 자동 생성 검증
14.4 생성
- “근거 없으면 모른다고 답하라” 프롬프트
- 인용 강제
- 답변 거절 정책
- 가드레일 (PII, 부적절 콘텐츠)
- 프롬프트 인젝션 방어 (검색된 문서 내용을 시스템 지시로 해석하지 않음)
14.5 운영
- 검색 지표 (Recall@K, MRR)
- 생성 지표 (Faithfulness, Relevance)
- 응답 지연 P95
- 토큰 비용 추적
- 사용자 피드백 + 재학습 루프
- 인덱스 신선도
- 회귀 테스트 자동화
15. 실행 환경 준비
본 문서의 세 예제(example_1_naive_rag, example_2_advanced_rag, example_3_graph_rag)를 모두 실행하려면:
15.1 가상환경 + 패키지
python3 -m venv .venv && source .venv/bin/activate
pip install \
langchain>=0.3.0 \
langchain-community>=0.3.0 \
langchain-anthropic>=0.3.0 \
langchain-text-splitters>=0.3.0 \
langchain-experimental>=0.3.0 \
chromadb>=0.5.0 \
sentence-transformers>=3.0.0 \
rank_bm25>=0.2.2 \
networkx>=3.2 \
tiktoken>=0.7.0
15.2 API 키
export ANTHROPIC_API_KEY="sk-ant-..."
15.3 첫 실행 시 자동 다운로드
BAAI/bge-m3(임베딩, 약 2.3GB)BAAI/bge-reranker-v2-m3(재랭커, 약 600MB)
GPU가 없어도 CPU에서 동작하나 reranker는 CPU에서 약간 느릴 수 있음.
15.4 코드 분리해서 사용하고 싶다면
이 문서의 코드 블록을 각각 example_1_naive_rag.py, example_2_advanced_rag.py, example_3_graph_rag.py, example_4_llm_wiki.py로 저장한 후 실행:
python example_1_naive_rag.py
python example_2_advanced_rag.py
python example_3_graph_rag.py
python example_4_llm_wiki.py
Part IV — Beyond RAG
16. LLM Wiki: 검색이 아니라 축적하는 지식 시스템
지금까지 다룬 RAG 계열(Naive, Advanced, Graph)은 모두 한 가지 공통점이 있다.
매 질문마다 지식을 처음부터 다시 끌어모은다.
청크가 5개든 10만 개든, 그래프 노드가 1만 개든, LLM은 매 쿼리마다 검색 → 읽기 → 종합을 반복한다. 즉 검색 시점에 지식을 재유도(re-derive) 한다. 같은 질문을 두 번 해도 매번 같은 작업을 처음부터 다시 한다. 이전 쿼리에서 발견된 통찰, 종합, 모순은 어디에도 누적되지 않는다.
LLM Wiki는 다른 발상이다.
지식은 한 번 컴파일되어 마크다운 파일들로 저장되고, 새 자료가 들어올 때마다 점진적으로 유지된다. LLM은 검색기가 아니라 위키 편집자가 된다.
이는 RAG의 변형이 아니라 다른 패러다임이다. Claude Code, OpenAI Codex 같은 파일 시스템에 직접 쓰는 에이전틱 도구의 부상과 맞물려 빠르게 확산되고 있다.
핵심 인용:
“위키는 지속적으로 복리로 쌓이는 산출물(persistent compounding artifact)이다. 교차 참조는 이미 있다. 모순은 이미 표시되어 있다. 종합은 이미 모든 자료를 반영하고 있다.”
16.1 RAG vs LLM Wiki — 본질적 차이
| 항목 | RAG | LLM Wiki |
|---|---|---|
| 지식의 형태 | 청크 + 임베딩 (검색용) | 구조화된 마크다운 페이지 |
| 누적성 | X 매 쿼리마다 재유도 | O 점진적 축적 |
| 교차 참조 | 검색 시점에 시도 | 명시적 위키링크로 사전 존재 |
| 모순 감지 | 어려움 | Lint으로 자동 발견 |
| 합의·통합 비용 | 매 쿼리 | 인덱싱 1회 |
| LLM 호출 시점 | 매 쿼리 | 인덱싱 + 쿼리 |
| 사람이 읽기 | 청크는 거의 안 읽음 | 위키 자체가 읽을 수 있는 산출물 |
| 적합 규모 | 수천~수백만 문서 | 수십~수백 소스 |
| 패턴 성숙도 | 매우 성숙 (2020~) | 신생 (2024~) |
| 결정성 | 비교적 높음 | 낮음 (페이지 구조가 매번 다름) |
핵심 통찰: 위키의 모든 교차 참조, 모순 표시, 종합은 다음 쿼리에 그대로 활용된다. 자료가 추가될 때마다 위키는 더 풍부해지고, 쿼리는 더 빨라지고 정확해진다.
16.2 3계층 아키텍처
┌─────────────┐
│ Raw 소스 │ 변경 불가. 사용자가 큐레이션. (PDF, 마크다운, 이미지, 데이터)
└──────┬──────┘
│ LLM이 읽기만 함
▼
┌─────────────┐
│ Wiki │ LLM이 *전적으로 소유*. 페이지 작성·갱신·교차참조.
│ (markdown) │ 당신은 읽음, LLM이 씀.
└──────┬──────┘
│ 규칙 정의
▼
┌─────────────┐
│ Schema │ CLAUDE.md / AGENTS.md.
│ (메타문서) │ "어떻게 위키를 유지할지" 규칙. 사용자와 LLM이 공동 진화.
└─────────────┘- Raw: 진실의 원천. LLM은 읽기만, 절대 수정하지 않음.
- Wiki: 인덱스, 엔티티 페이지, 개념 페이지, 종합문, 비교표. 마크다운 파일들의 git 저장소.
- Schema: LLM에게 “이 위키는 이렇게 구성된다, 새 소스가 들어오면 이런 단계를 따른다” 를 가르치는 메타 문서. 시간이 지나며 사용자와 LLM이 함께 발전시킨다. 이게 핵심 설정 파일이다 — 일반 챗봇과 위키 유지자의 차이를 만든다.
16.3 핵심 운영 — Ingest / Query / Lint
Ingest (소스 수집)
새 자료를 raw에 추가하면:
- LLM이 자료를 읽고 사용자와 핵심 논점 토론
- 위키에 요약 페이지 작성
- 인덱스 갱신
- 영향받는 엔티티/개념 페이지 모두 갱신 (한 번에 10~15개 파일을 건드릴 수도 있음)
- 로그에 한 줄 추가
한 자료를 인덱싱하는데 15개 페이지를 건드리는 게 LLM Wiki의 본질이다. 사람은 절대 못 한다 (지루해서 안 한다). LLM은 지치지 않으니 한다.
Query (질문)
- 인덱스 → 관련 페이지 선택 → 페이지 읽기 → 인용 포함 답변 생성
- 중요한 통찰: 좋은 답변은 위키에 새 페이지로 되돌려 저장할 수 있다. 발견한 비교, 분석, 연결은 채팅 히스토리에서 사라지지 말고 위키의 자산이 되어야 한다. 이렇게 하면 탐색 자체가 누적된다.
Lint (위키 건강 검사)
주기적으로 LLM에게 위키 점검을 시킨다:
- 페이지 간 모순
- 새 자료가 갱신해야 할 오래된 주장
- 인바운드 링크 없는 고아 페이지
- 반복 등장하지만 자체 페이지가 없는 중요 개념
- 빠진 교차 참조
- 웹 검색으로 채울 수 있는 데이터 공백
LLM은 새로 조사할 질문과 찾아볼 자료를 제안하는 데 능하다. Lint가 위키를 건강하게 유지한다.
16.4 인덱스와 로그
위키가 커질수록 두 특수 파일이 LLM의 길잡이가 된다.
| 파일 | 성격 | 역할 |
|---|---|---|
| index.md | 콘텐츠 중심 | 모든 페이지 카탈로그 (링크 + 한 줄 요약 + 메타). 카테고리별 구성. 매 ingest마다 갱신. 쿼리 시 LLM은 인덱스부터 읽고 드릴다운. |
| log.md | 시간 중심 | append-only. ingest/query/lint 기록. ## [2026-04-02] ingest | 기사 제목 같은 일관된 prefix를 쓰면 grep "^## \[" log.md | tail -5로 최근 5건 추출 가능. |
수백 페이지 규모까진 임베딩 RAG 인프라 없이 인덱스 파일만으로 충분하다. 이게 LLM Wiki가 작은 규모에서 RAG보다 나은 이유 중 하나다.
16.5 RAG vs LLM Wiki — 언제 무엇을?
| 상황 | 권장 | 이유 |
|---|---|---|
| 수만~수백만 문서, 일회성 질문 다수 | RAG | 컴파일 비용 회수 안 됨 |
| 수십~수백 소스, 깊이 누적되는 주제 | LLM Wiki | 합성·교차 참조 가치 큼 |
| 책 한 권 정독하며 동반 위키 | LLM Wiki | 점진 누적이 핵심 |
| 한 사람의 장기 리서치 (수개월~수년) | LLM Wiki | 매번 재유도하지 않음 |
| 사내 위키 (자주 갱신, 다수 사용자) | 둘 다 | 컴파일된 위키를 RAG 소스로 |
| 실시간 변하는 데이터 (주가, 로그) | RAG / 도구 호출 | 컴파일 따라잡지 못함 |
| 평가·재현성이 중요 | RAG | 결정성 더 높음 |
| 권한 분리가 핵심 | RAG | 인덱스 단계 권한이 자연스러움 |
적합한 사용 사례 (실전)
- 개인: 자기 목표·건강·심리·자기계발 추적. 일기, 기사, 팟캐스트 노트로 자기 자신에 대한 구조화된 그림을 시간에 걸쳐 구축
- 연구: 한 주제를 몇 주~몇 달간 깊게. 논문·기사·보고서 → 진화하는 종합 위키
- 책 정독: 챕터 단위 인덱싱, 인물·테마·플롯 페이지 자동 작성. 끝나면 Tolkien Gateway 같은 동반 위키 완성
- 회사/팀: Slack 스레드, 회의록, 프로젝트 문서, 고객 통화 → LLM이 유지하는 사내 위키. 사람이 검토. 위키가 항상 최신 — 아무도 하기 싫은 유지보수를 LLM이 함
- 경쟁사 분석, 실사, 여행 기획, 강의 노트, 취미 심층 — 시간이 지나며 누적되고 정리될 가치가 있는 모든 것
16.6 도구 생태계
| 도구 | 역할 |
|---|---|
| Obsidian | 위키 IDE. 그래프 뷰, 위키링크 자동완성, Dataview 플러그인 |
| Obsidian Web Clipper | 웹페이지 → 마크다운 변환 (브라우저 확장) |
| Marp | 마크다운 기반 슬라이드 (Obsidian 플러그인 있음) — 위키 페이지에서 바로 발표 자료 |
| qmd | 마크다운 폴더용 로컬 검색 엔진. BM25 + 벡터 + LLM 재랭킹. CLI + MCP 서버 |
| git | 위키는 그냥 마크다운 git 저장소다. 버전 관리·브랜칭·협업 무료 |
| Claude Code / Codex | 파일 시스템에 직접 쓰는 에이전트. LLM Wiki와 가장 잘 맞음 |
작업 흐름의 전형: 한쪽에 LLM 에이전트, 다른 쪽에 Obsidian. LLM이 대화에 따라 파일을 편집하고, 사람은 실시간으로 결과를 본다 — 링크 따라가기, 그래프 뷰 확인, 갱신된 페이지 읽기. Obsidian이 IDE, LLM이 프로그래머, 위키가 코드베이스다.
16.7 왜 작동하는가 — Memex 비전과의 연결
지식 베이스 유지의 진짜 어려움은 읽기나 생각이 아니다 — 부기(bookkeeping) 다. 교차 참조 갱신, 요약 최신화, 모순 메모, 수십 페이지의 일관성 유지. 인간은 위키를 포기한다 — 유지 비용이 가치보다 빠르게 자란다.
LLM은 지루해하지 않고, 교차 참조를 잊지 않고, 한 번에 15개 파일을 만질 수 있다. 유지 비용이 0에 가까우니 위키가 살아남는다.
이는 1945년 Vannevar Bush의 Memex 비전과 직접 맞닿아 있다 — 개인이 큐레이션한 지식 저장고에 문서 간 연관 흔적(associative trails) 을 남기는 시스템. Bush가 풀지 못한 문제는 “누가 그 유지보수를 하느냐” 였다. LLM이 그 답이다.
16.8 LLM Wiki의 한계
- 결정성 낮음: 같은 ingest를 두 번 돌리면 페이지 구조가 미묘하게 달라짐 — 평가·재현 어려움
- 스키마 표류(schema drift): 명시적 규칙이 약하면 페이지 형식이 일관성을 잃음 → 정기 lint 필수
- 수백 소스 이상에서 토큰 비용: 매 ingest 시 다수 페이지를 LLM 컨텍스트에 보여줘야 함 → 비용 누적
- 사용자 의존: 좋은 위키는 좋은 큐레이션과 좋은 질문에서 나옴. 자동화에 한계
- 다중 사용자/권한: 인덱스 단계 권한 분리가 RAG처럼 자연스럽지 않음
- 검색 정밀도: 대규모에선 벡터 검색이 인덱스 파일보다 정확
16.9 위키 + RAG 결합 (현실적 종착지)
가장 흥미로운 진화 방향: “LLM이 유지하는 위키 자체를 RAG의 소스로”
[Raw 소스]
│
│ LLM이 컴파일 (Ingest)
▼
[Wiki 마크다운] ◀── 사람이 직접 읽음 (Obsidian)
│
│ RAG 인덱싱
▼
[벡터 DB / BM25]
│
│ 검색
▼
[빠른 Q&A]- 깊이(위키의 합의)와 속도(RAG의 검색)를 둘 다
- 사내 위키 시스템에서 가장 현실적인 종착지
- 위키는 사람이 직접 보고 검수 가능 — 환각 위험 RAG보다 낮음
예제 4: LLM Wiki
외부 도구 없이 Python + Anthropic API + 파일 시스템만으로 LLM Wiki 패턴의 핵심(ingest → 페이지 자동 작성/갱신 → index/log 유지 → query)을 보여준다. 가상 회사의 시간순 문서 3개를 차례로 인덱싱하면서 위키가 풍부해지는 과정을 직접 관찰할 수 있다.
"""example_4_llm_wiki.py — LLM Wiki 패턴 미니 구현
==================================================
단일 파일에서 다음을 시연한다:
1) 시간순 소스 3개를 차례로 ingest
2) LLM이 매 ingest마다 entities/projects/concepts 페이지를 작성·갱신
3) index.md / log.md 자동 유지
4) 위키 자체를 컨텍스트로 한 시간 진화 질의
"""
from __future__ import annotations
import json, re, datetime, shutil
from pathlib import Path
from typing import Dict
from langchain_anthropic import ChatAnthropic
from langchain_core.prompts import ChatPromptTemplate
# ════════════════════════════════════════════════════════════════
# 0. 샘플 raw 소스 — 가상 회사의 시간순 3문서
# ════════════════════════════════════════════════════════════════
SAMPLE_SOURCES = {
"2024Q3_strategy.md": """# Q3 2024 전략 회의 요약
CTO 김철수가 발표한 Q3 우선순위:
1. 프로젝트 알파(추천 시스템 개편) 11월 출시 목표
2. 데이터 인프라 팀 인원 50% 증원
3. 보안팀과의 협업 강화
알파 프로젝트는 박영희가 보안 책임자로 합류.
이민수가 데이터 파이프라인을 담당한다.""",
"2024Q4_alpha_launch.md": """# 프로젝트 알파 출시 회고 (2024-11-30)
11월 15일 출시. 트래픽 30% 증가, 클릭률 12% 상승.
핵심 기여자:
- 김철수 (총괄)
- 박영희 (보안 검수)
- 이민수 (데이터 파이프라인)
- 정현우 (UI/UX, 신규 합류)
실패 요인: 초기 캐시 미스 폭증 → Redis 클러스터 증설로 해결
다음 단계: 베타 프로젝트(결제 시스템) 시작.""",
"2025Q1_orgchange.md": """# 2025년 1월 조직 개편
- 김철수: CTO 유지. 머신러닝 인프라 팀 디렉터 겸임
- 박영희: 보안팀장으로 승진
- 이민수: 데이터플랫폼 팀 리더로 승진
- 정현우: 알파 팀에서 베타 팀으로 이동
- 최지훈: 베타 팀 PM으로 영입 (외부 입사)
알파 프로젝트는 유지보수 모드로 전환. 베타 프로젝트가 신규 우선순위."""
}
# ════════════════════════════════════════════════════════════════
# 1. 프롬프트 — Ingest용(액션 계획) / Query용
# ════════════════════════════════════════════════════════════════
INGEST_PROMPT = ChatPromptTemplate.from_messages([
("system",
"당신은 위키 편집자입니다. 새 소스 문서를 보고 위키 페이지들을 어떻게 갱신할지 결정합니다.\n"
"현재 존재하는 위키 페이지 전체와 그 내용이 함께 주어집니다.\n\n"
"출력은 순수 JSON 배열이어야 합니다 (다른 설명 금지):\n"
' [{"op":"create","path":"entities/김철수.md","content":"전체 마크다운"},\n'
' {"op":"append","path":"projects/알파.md","content":"추가할 마크다운"}]\n\n'
"규칙:\n"
"- 사람: entities/이름.md 프로젝트: projects/이름.md 개념: concepts/이름.md\n"
"- 새 정보가 있을 때만 액션. 단순 중복이면 빈 배열.\n"
"- 페이지 본문에 [[다른_페이지명]] 형식 위키링크를 적극 활용.\n"
"- 페이지 끝에 '> 출처: [소스파일명]' 표기 (append 시에도).\n"
"- 모순되는 정보가 있으면 'TODO: 모순 검토 - ...' 메모 추가."),
("human",
"[새 소스: {source_name}]\n{source_text}\n\n"
"[현재 위키]\n{existing_pages}\n\n"
"위 소스를 반영할 액션 JSON 배열:")
])
QUERY_PROMPT = ChatPromptTemplate.from_messages([
("system",
"당신은 위키 어시스턴트입니다. 아래 [위키 페이지들]만 근거로 질문에 답하세요.\n"
"각 사실 끝에 [[페이지명]] 형식으로 출처 페이지를 인용하세요.\n"
"근거가 부족하면 '위키에 정보가 부족합니다'라고 답하세요."),
("human", "[위키 페이지들]\n{pages}\n\n[질문]\n{question}")
])
# ════════════════════════════════════════════════════════════════
# 2. WikiAgent — 핵심 로직
# ════════════════════════════════════════════════════════════════
class WikiAgent:
def __init__(self, root: str):
self.root = Path(root)
self.raw_dir = self.root / "raw"
self.wiki_dir = self.root / "wiki"
self.llm = ChatAnthropic(model="claude-opus-4-7", temperature=0)
# ── 셋업: 디렉터리 + 샘플 소스 작성 + 빈 인덱스/로그 ──
def setup(self):
if self.root.exists():
shutil.rmtree(self.root)
self.raw_dir.mkdir(parents=True)
self.wiki_dir.mkdir(parents=True)
for name, content in SAMPLE_SOURCES.items():
(self.raw_dir / name).write_text(content, encoding="utf-8")
(self.wiki_dir / "index.md").write_text("# 위키 인덱스\n\n", encoding="utf-8")
(self.wiki_dir / "log.md").write_text("# 운영 로그\n\n", encoding="utf-8")
# ── 현재 위키 페이지 모두 (path → content). 인덱스/로그 제외 ──
def _list_pages(self) -> Dict[str, str]:
out = {}
for p in self.wiki_dir.rglob("*.md"):
rel = p.relative_to(self.wiki_dir).as_posix()
if rel in ("index.md", "log.md"):
continue
out[rel] = p.read_text(encoding="utf-8")
return out
@staticmethod
def _parse_json_array(text: str):
m = re.search(r"\[.*\]", text, re.DOTALL)
if not m: return []
try: return json.loads(m.group(0))
except json.JSONDecodeError: return []
# ── Ingest: 한 소스를 위키에 흡수 ──
def ingest(self, source_name: str):
print(f"\nIngest: {source_name}")
source_text = (self.raw_dir / source_name).read_text(encoding="utf-8")
pages = self._list_pages()
existing = "(아직 페이지 없음)" if not pages else "\n\n".join(
f"### {path}\n{content}" for path, content in pages.items())
msg = INGEST_PROMPT.invoke({
"source_name": source_name,
"source_text": source_text,
"existing_pages": existing,
})
actions = self._parse_json_array(self.llm.invoke(msg).content)
# 액션 실행
for a in actions:
target = self.wiki_dir / a["path"]
target.parent.mkdir(parents=True, exist_ok=True)
if a["op"] == "create":
target.write_text(a["content"].rstrip() + "\n", encoding="utf-8")
print(f" CREATE {a['path']}")
elif a["op"] == "append":
cur = target.read_text(encoding="utf-8") if target.exists() else ""
target.write_text(cur.rstrip() + "\n\n" + a["content"].rstrip() + "\n",
encoding="utf-8")
print(f" APPEND {a['path']}")
self._update_index()
self._append_log(f"ingest | {source_name} | actions={len(actions)}")
# ── 인덱스 재생성: 카테고리별 그룹 + 한 줄 요약 ──
def _update_index(self):
pages = self._list_pages()
groups: Dict[str, list] = {}
for path in sorted(pages):
cat = path.split("/")[0] if "/" in path else "root"
groups.setdefault(cat, []).append(path)
lines = ["# 위키 인덱스",
f"\n_갱신: {datetime.date.today()}_ / 페이지 {len(pages)}개\n"]
for cat, paths in groups.items():
lines.append(f"\n## {cat}")
for p in paths:
first = pages[p].splitlines()[0].lstrip("# ").strip()
lines.append(f"- [[{p[:-3]}]] — {first}")
(self.wiki_dir / "index.md").write_text("\n".join(lines) + "\n", encoding="utf-8")
# ── 로그 append ──
def _append_log(self, msg: str):
line = f"## [{datetime.date.today()}] {msg}\n"
with (self.wiki_dir / "log.md").open("a", encoding="utf-8") as f:
f.write(line)
# ── Query: 위키 전체를 컨텍스트로 답변 (소규모 데모) ──
def query(self, question: str) -> str:
# 프로덕션에선 인덱스 먼저 보고 페이지 선별 (= LLM이 도구로 page를 읽음)
# 데모는 소규모이므로 모든 페이지를 한 번에 컨텍스트로
pages = self._list_pages()
joined = "\n\n".join(f"### [[{p[:-3]}]]\n{c}" for p, c in pages.items())
msg = QUERY_PROMPT.invoke({"pages": joined, "question": question})
return self.llm.invoke(msg).content
# ════════════════════════════════════════════════════════════════
# 3. 메인 실행 — 시간순 ingest 후 진화 질의
# ════════════════════════════════════════════════════════════════
if __name__ == "__main__":
agent = WikiAgent("./demo_wiki")
agent.setup()
# 3개 소스를 시간순으로. 위키가 점점 풍부해지는 걸 관찰.
for name in ["2024Q3_strategy.md", "2024Q4_alpha_launch.md", "2025Q1_orgchange.md"]:
agent.ingest(name)
# 최종 위키 트리
print("\n최종 위키 구조:")
for p in sorted(Path("./demo_wiki/wiki").rglob("*.md")):
rel = p.relative_to("./demo_wiki/wiki")
print(f" {rel} ({p.stat().st_size}B)")
# 시간 진화에 대한 질의 — RAG로는 매우 어려운 종류
# (여러 소스에 걸친 인물·역할 변화를 통합해야 함)
print("\n참고: 위키 쿼리:")
for q in [
"프로젝트 알파의 핵심 멤버는 시간이 지나며 어떻게 변했어?",
"박영희의 역할은 어떻게 진화했지?",
"이민수가 알파와 베타 양쪽에 모두 관여하나? 어떻게?",
]:
print(f"\n━━━ Q: {q}")
print(f"> {agent.query(q)}")
무엇을 보여주는가
이 작은 예제가 LLM Wiki 패턴의 핵심 4가지를 모두 시연한다.
- 점진적 축적 — 3개 소스를 차례로 인덱싱하면서 같은 인물의 페이지가 append로 두꺼워진다. 첫 ingest에서 김철수는 “CTO"였지만, 세 번째 ingest 후엔 그의 페이지에 “머신러닝 인프라 팀 디렉터 겸임” 이 추가되어 있다.
- 자동 교차 참조 —
[[알파]],[[김철수]]같은 위키링크가 LLM에 의해 자동 생성된다. Obsidian에 띄우면 그래프 뷰로 즉시 시각화된다. - 인덱스 자동 유지 — 카테고리별로 정리된
index.md가 매 ingest마다 갱신된다. 수백 페이지 규모까지 이것만으로 RAG 없이 충분. - 시간 진화 질의 능력 — “박영희의 역할은 어떻게 진화했지?” 같은 질문은 RAG로 매우 어렵다 (정답이 한 청크에 없고 시간순 통합이 필요). LLM Wiki는 이미 누적된 페이지에서 자연스럽게 답한다.
디렉터리 출력 예시 (실행 후)
demo_wiki/
├── raw/
│ ├── 2024Q3_strategy.md
│ ├── 2024Q4_alpha_launch.md
│ └── 2025Q1_orgchange.md
└── wiki/
├── index.md
├── log.md
├── entities/
│ ├── 김철수.md ← 3번 갱신됨 (CTO → +디렉터 겸임)
│ ├── 박영희.md ← 3번 갱신됨 (보안 책임자 → 보안팀장)
│ ├── 이민수.md ← 3번 갱신됨
│ ├── 정현우.md ← Q4부터 등장
│ └── 최지훈.md ← 2025년에 신규
└── projects/
├── 알파.md ← 3번 갱신됨 (계획 → 출시 → 유지보수)
└── 베타.md ← Q4 도입, 25Q1 본격화프로덕션으로 가려면
| 컴포넌트 | 본 예제 | 프로덕션 |
|---|---|---|
| 에이전트 실행 | 단일 스크립트 | Claude Code / Codex (파일 직접 편집) |
| 위키 IDE | 파일 시스템만 | Obsidian + 그래프 뷰 + Dataview |
| 검색 | 모든 페이지를 컨텍스트로 (소규모) | qmd MCP 서버 (BM25 + 벡터 + LLM 재랭킹) |
| 액션 종류 | create / append만 | + update (mid-file patch), + delete, + rename |
| Schema | 프롬프트에 인라인 | CLAUDE.md / AGENTS.md 별도 파일 |
| Lint | 미구현 | 정기 cron 또는 사용자 트리거 |
| 버전 관리 | 없음 | git 저장소 (모든 변경 commit) |
| 다중 모달 | 텍스트만 | 이미지 다운로드 + LLM이 별도 view |
| 답변 되저장 | 없음 | “이 답변을 위키에 저장할까요?” UX |
한 단계 더 — Schema 파일
프로덕션에선 위키 루트에 CLAUDE.md(또는 AGENTS.md)를 두고 LLM에게 사전 학습시킨다. 예시:
# Schema for the LLM Wiki
## Directory layout
- `raw/` — immutable source documents
- `wiki/entities/` — person and organization pages
- `wiki/projects/` — project pages
- `wiki/concepts/` — conceptual / topic pages
- `wiki/index.md` — auto-maintained catalog
- `wiki/log.md` — append-only operation log
## Conventions
- Every fact ends with `> 출처: [filename]`
- Wiki links: `[[Page Name]]` (no .md extension)
- Person pages: H1 = full name, H2 sections: 직책 / 이력 / 프로젝트 / 관계
- Contradictions: insert `TODO: 모순 검토 — ...`
## Ingest workflow
1. Discuss key takeaways with the user first
2. Plan actions (create/append/update)
3. Execute actions
4. Update `index.md`
5. Append a line to `log.md` like `## [YYYY-MM-DD] ingest | <source>`
## Query workflow
1. Read `index.md` first
2. Open only relevant pages (don't load everything)
3. Cite each fact with `[[Page Name]]`
4. Offer to save the answer back as a new wiki page
이 한 파일이 LLM을 일반 챗봇에서 훈련된 위키 편집자로 바꾼다. 사용자와 LLM이 시간이 지나며 함께 발전시키는 살아있는 문서다.
마무리
RAG는 “모델이 모르는 것을 외부에서 안전하게 가져오는 장치” 다. 단순한 개념이지만 좋은 RAG를 만들려면 청킹·임베딩·검색·재랭킹·프롬프트·평가까지 전 단계를 정교하게 다뤄야 한다.
Naive → Advanced → Graph 의 진화는 단순한 기능 추가가 아니라 “무엇을 답할 수 있는가” 의 질적 확장이다. Naive는 “이 문서에 뭐라고 적혀 있나”, Advanced는 “이 문서들 중 가장 관련 있는 부분”, Graph는 “여러 문서를 연결하면 어떤 답이 나오나” 를 풀 수 있다.
그리고 RAG의 옆에는 LLM Wiki라는 다른 패러다임이 자라고 있다. RAG가 매 쿼리마다 지식을 재유도한다면, LLM Wiki는 지식을 한 번 컴파일해 누적시킨다. Claude Code, Codex 같은 파일 시스템에 직접 쓰는 에이전트의 등장이 이 패턴을 현실화시키고 있다. 둘은 경쟁하지 않는다 — 위키를 RAG의 소스로 삼는 결합이 실용적 종착지다.
2024–2026년의 흐름은 분명하다.
- RAG는 죽지 않았다. Long Context와 공존.
- 단순 RAG에서 Agentic RAG로 진화 중.
- 벡터 + 키워드 + 그래프 + 도구 가 결합된 하이브리드가 표준.
- 데이터 성격에 따라 증강 방식을 골라 쓰는 시대.
- 검색의 시대 옆에 축적의 시대 — LLM Wiki 패턴은 개인 리서치·책 정독·사내 위키 같은 시간이 지나며 누적되는 지식에 새로운 실용적 옵션을 제공한다.
본 문서의 네 예제(example_1 ~ example_4)를 직접 실행하며 비교해보면 각 단계의 차이를 가장 빠르게 체감할 수 있다. 특히 예제 4의 박영희의 역할은 어떻게 진화했지? 같은 시간 진화 질의는 RAG 계열로는 매우 어렵고 — LLM Wiki에서는 자연스럽다. 이 차이를 직접 보면 두 패러다임이 대체가 아닌 상보임이 분명해진다.
RAG·LLM Wiki 분야는 빠르게 진화하므로 라이브러리 버전과 모델 명세는 별도 확인 필요.