라떼군 이야기


RAG 완벽 가이드: Naive · Advanced · Graph RAG 통합본

하나의 문서로 끝내는 RAG 학습 자료. 이론(왜 필요한가, 어떻게 진화했는가) + 실전 코드(복붙해서 바로 실행 가능) + 최신 동향(Agentic, GraphRAG, Contextual Retrieval) + 한계와 대안 + 의사결정 가이드까지 모두 포함.

RAG는 2023년부터 직접 만들고 운영해보면서 가장 자주 만나는 패턴이 됐다. 자료가 여러 블로그·논문·릴리스 노트에 흩어져 있어서, 다음에 다시 들여다볼 때 한 페이지에서 끝낼 수 있도록 내가 쓰던 노트와 검증된 예제를 한 곳에 묶었다. 입문자에겐 학습 순서로, 실무자에겐 옵션 비교 테이블로 쓰이길 바란다.


목차

Part I — 기초

  1. RAG란 무엇인가
  2. 왜 RAG가 필요한가
  3. RAG의 3세대 진화: Naive → Advanced → Modular → Graph

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개 ≈ 11.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-m3BAAI라는 조직이 만든 bge-m3라는 이름의 모델이다.

표기풀어쓰기
BAAI/bge-m3BAAI (Beijing Academy of AI, 중국 AI 연구소)가 만든 BGE-M3 모델. 다국어 임베딩 강자.
BAAI/bge-reranker-v2-m3같은 BAAI의 재랭커 (cross-encoder) 모델
intfloat/multilingual-e5-large연구자 intfloatE5 다국어 임베딩
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 RetrievalDense는 벡터(밀집) 검색, Sparse는 BM25 같은 단어 기반(희소) 검색. 대부분 0으로 채워진 벡터로 표현되어 “희소"라 부른다.
  • ANN (Approximate Nearest Neighbor) — 수백만 벡터에서 가장 가까운 것을 대략 빠르게 찾는 알고리즘. HNSW, IVF-PQ 등이 대표 변형. 사실상 모든 벡터 DB가 내부에서 사용.
  • Bi-encoder vs Cross-encoderBi-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 지원
벡터QdrantRust 기반, 셀프호스팅 친화
벡터Chroma가장 가벼움, 프로토타이핑 1순위 (본 문서 예제에서 사용)
벡터Milvus수십억 벡터 대규모 환경
벡터 (확장)pgvectorPostgreSQL 확장으로 끼워쓰기
벡터+키워드Elasticsearch / OpenSearch둘 다 지원, 운영 노하우 풍부
그래프Neo4j그래프 DB의 사실상 표준. 쿼리 언어 Cypher 사용
그래프MemgraphNeo4j 호환, 더 빠름
그래프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. 자주 보는 약어

약어풀어쓰기
APIApplication Programming Interface
LLMLarge Language Model
RAGRetrieval-Augmented Generation
KGKnowledge Graph
NERNamed Entity Recognition
DBDatabase
MQMessage Queue
IaCInfrastructure as Code
PRPull Request
PoCProof of Concept
PMProject Manager
MCPModel Context Protocol (Anthropic의 도구 연동 표준)
PIIPersonally Identifiable Information
RRFReciprocal Rank Fusion
MMRMaximal Marginal Relevance
BFSBreadth-First Search
ASTAbstract Syntax Tree

Part II — 구현

4. Naive RAG: 기본 5단계

가장 단순한 RAG의 흐름:

  1. 로드(Load): PDF, 웹, DB, Notion 등 원본 수집
  2. 청킹(Chunk): 긴 문서를 검색 단위로 분할
  3. 임베딩(Embed): 각 청크를 벡터로 변환
  4. 검색(Retrieve): 질문 임베딩과 유사한 청크 K개 추출
  5. 생성(Generate): 검색 결과를 프롬프트에 넣어 LLM이 답변

청킹 파라미터 권장

항목권장값비고
chunk_size256~1024 토큰너무 작으면 맥락 손실, 너무 크면 노이즈
chunk_overlapchunk_size의 10~20%경계 정보 손실 방지
법률 문서조항 단위도메인 구조 우선
기술 문서섹션(헤더) 단위마크다운 헤더 splitter
FAQQ&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, Cohere embed-v3, Voyage voyage-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 RewritingLLM이 질문을 명확히 재작성“걔네 정책 뭐야?” → “ACME사의 환불 정책은?”
Query Expansion동의어·관련어 추가“퇴사” + “사직, 이직, 퇴직”
HyDELLM이 가상 답변 생성 → 답변을 임베딩해 검색답변끼리가 답변-질문보다 더 유사
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 대비

측면NaiveAdvanced
동의어/의역약함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는:

  1. 그래프를 구축한 뒤 Leiden 알고리즘으로 커뮤니티(밀집 연결된 노드 그룹)를 탐지
  2. 각 커뮤니티에 대해 LLM이 요약문을 미리 생성
  3. 글로벌 질문은 → 커뮤니티 요약들을 map-reduce로 통합해 답변
  4. 로컬 질문은 → 특정 엔티티 주변 서브그래프를 답변

이것이 단순 그래프 탐색 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 + Neo4jLLMGraphTransformer + GraphCypherQAChain프로덕션, Cypher 기반 정밀 질의
LlamaIndex KG IndexTripletExtractor + 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단계를 모두 보여준다.

  1. 엔티티/관계 추출 — LLM으로 트리플 생성 (실무에선 한 번 인덱싱하고 캐시)
  2. 그래프 구축 — NetworkX 인메모리 (Neo4j로 갈아끼울 수 있게 추상화)
  3. 그래프 탐색 — 질의 엔티티 → 2-hop 서브그래프 + 관련 문서
  4. 답변 생성 — 그래프 + 원본 문서 둘 다 컨텍스트로 사용

특히 “박영희와 김철수는 어떻게 알게 됐어?” 같은 질문은 단일 청크에 답이 없다 — 그래프에서 두 사람이 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

약어풀어쓰기핵심
CAGCache-Augmented Generation자주 쓰는 지식을 KV 캐시로 미리 적재
TAGTable-Augmented Generation표 형태 데이터에 SQL/SPJ 연산 결합
KAGKnowledge-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
항목RAGFine-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 RAGAdvanced RAGGraph RAG
데이터 표현청크 + 임베딩청크 + 임베딩 + 메타노드 + 엣지 (+ 임베딩)
검색 방식벡터 유사도Hybrid + Rerank그래프 탐색 (+ 벡터)
쿼리 변환XMulti-Query, HyDE 등엔티티 추출 + Cypher
다중 홉XO
글로벌 이해XO (커뮤니티 요약)
구현 난이도낮음보통매우 높음
인덱싱 비용낮음중간높음 (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 단계별 도입 권장 순서

대부분의 조직에 권장하는 순서:

  1. Naive RAG로 PoC — 1~2주, 가능성/한계 파악
  2. Advanced RAG로 본격 배포 — Hybrid + Rerank + 인용 강제
  3. 평가 셋 + 모니터링 구축 — 골든 셋 100~200개, RAGAS 자동 평가
  4. 관계 질문이 많이 나오면 Graph RAG 추가 — 보통 Advanced의 30~50% 보조 형태로
  5. 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 — 본질적 차이

항목RAGLLM 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에 추가하면:

  1. LLM이 자료를 읽고 사용자와 핵심 논점 토론
  2. 위키에 요약 페이지 작성
  3. 인덱스 갱신
  4. 영향받는 엔티티/개념 페이지 모두 갱신 (한 번에 10~15개 파일을 건드릴 수도 있음)
  5. 로그에 한 줄 추가

한 자료를 인덱싱하는데 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가지를 모두 시연한다.

  1. 점진적 축적 — 3개 소스를 차례로 인덱싱하면서 같은 인물의 페이지가 append로 두꺼워진다. 첫 ingest에서 김철수는 “CTO"였지만, 세 번째 ingest 후엔 그의 페이지에 “머신러닝 인프라 팀 디렉터 겸임” 이 추가되어 있다.
  2. 자동 교차 참조[[알파]], [[김철수]] 같은 위키링크가 LLM에 의해 자동 생성된다. Obsidian에 띄우면 그래프 뷰로 즉시 시각화된다.
  3. 인덱스 자동 유지 — 카테고리별로 정리된 index.md가 매 ingest마다 갱신된다. 수백 페이지 규모까지 이것만으로 RAG 없이 충분.
  4. 시간 진화 질의 능력“박영희의 역할은 어떻게 진화했지?” 같은 질문은 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년의 흐름은 분명하다.

  1. RAG는 죽지 않았다. Long Context와 공존.
  2. 단순 RAG에서 Agentic RAG로 진화 중.
  3. 벡터 + 키워드 + 그래프 + 도구 가 결합된 하이브리드가 표준.
  4. 데이터 성격에 따라 증강 방식을 골라 쓰는 시대.
  5. 검색의 시대 옆에 축적의 시대 — LLM Wiki 패턴은 개인 리서치·책 정독·사내 위키 같은 시간이 지나며 누적되는 지식에 새로운 실용적 옵션을 제공한다.

본 문서의 네 예제(example_1 ~ example_4)를 직접 실행하며 비교해보면 각 단계의 차이를 가장 빠르게 체감할 수 있다. 특히 예제 4의 박영희의 역할은 어떻게 진화했지? 같은 시간 진화 질의는 RAG 계열로는 매우 어렵고 — LLM Wiki에서는 자연스럽다. 이 차이를 직접 보면 두 패러다임이 대체가 아닌 상보임이 분명해진다.


RAG·LLM Wiki 분야는 빠르게 진화하므로 라이브러리 버전과 모델 명세는 별도 확인 필요.

제품 기획·개발 파트너 찾으시나요? 개인·팀·기업 모두 환영. 문제 정의부터 출시까지 함께합니다.