[CloudNeta] RAG와 Planning으로 구현하는 Knowledge Agent

관련 글

이 글은 Chapter 4의 Knowledge Agent 실습을 분리한 문서입니다.
프로덕션 LLM 애플리케이션 서빙

실습 목표

이 실습은 PDF 기반 RAG와 LLM Planning을 결합한 Knowledge Agent를 구현합니다. 목표는 하나의 사용자 질문이 검색과 여러 모델 호출로 확장되는 과정을 코드에서 확인하는 것입니다.

확인할 내용은 다음과 같습니다.

현재 구현은 OpenAI의 LLM·임베딩 API를 사용합니다. GPU 배치, KV cache, autoscaling 같은 Core Inference 영역은 API 제공자에게 맡깁니다.

저자의 원본 코드와 제가 작성한 코드

아래의 링크를 참고 부탁드립니다.

전체 구조

knowledge_files/*.pdf
        │
        ▼
build_index.py
        │  추출 → 청킹 → 임베딩
        ▼
vector_db/index.json
        │
        ▼
main.py
        │
        ▼
Agent
  ├─ Planner
  ├─ RAGSystem
  ├─ ActionExecutor
  └─ LLMManager

주요 구성 요소는 다음과 같습니다.

구성 요소 역할
Agent 계획, 검색, Action 실행 조율
Planner LLM 계획 생성과 fallback
ActionExecutor 등록된 Action 실행
RAGSystem PDF 추출, 청킹, 임베딩, 검색, 저장
LLMManager 프롬프트 생성과 모델 호출
Container 설정과 객체 의존성 조립

containers.py가 의존성을 조립합니다. 구성 요소는 협력 객체를 직접 만들지 않고 생성자로 받습니다. 테스트에서는 OpenAI client와 설정을 가짜 객체로 교체할 수 있습니다.

실행 준비

Python 3.10~3.12와 OpenAI API 키가 필요합니다.

uv sync
cp env_example.txt .env

.env에 OPENAI_API_KEY를 입력합니다. 주요 설정은 다음과 같습니다.

환경변수 기본값 의미
LLM_MODEL gpt-5.6-luna 계획·답변 모델
EMBEDDING_MODEL text-embedding-3-small 임베딩 모델
CHUNK_SIZE 1000 청크 토큰 수
CHUNK_OVERLAP 200 인접 청크 중첩
SEARCH_SCORE_THRESHOLD 0.28 컨텍스트에 넣을 최소 점수
USE_PLANNING false 기본 실행에서 Planner 사용 여부

인덱스 빌드

오프라인과 온라인 분리

원본 샘플은 Agent 실행 흐름 안에서 지식베이스를 구성하는 단순한 형태였습니다. 현재 코드는 반복 임베딩 비용을 피하기 위해 빌드와 질의를 분리했습니다.

uv run python build_index.py
uv run python main.py

main.py는 인덱스를 새로 만들지 않습니다. 인덱스가 없거나 설정·PDF와 맞지 않으면 빌드 명령을 안내하고 종료합니다.

PDF 처리

RAGSystem.load_pdfs()는 다음 순서로 문서를 처리합니다.

  1. PDF에서 텍스트를 추출합니다.
  2. 줄바꿈 하이픈을 정리합니다.
  3. 토큰 단위로 청크를 만듭니다.
  4. 목차로 판단한 청크를 제외합니다.
  5. 남은 청크에 ID를 다시 부여합니다.

긴 점선 구간이 세 번 이상 나타나는 청크는 목차나 그림 목록으로 간주합니다. 목차는 문서의 주요 제목을 한꺼번에 포함해 실제 본문보다 검색 점수가 높게 나올 수 있습니다.

이 규칙은 형식 기반 휴리스틱입니다. 점선이 없는 목차를 찾지 못하고, 본문에 점선 목록이 많으면 잘못 제외할 수 있습니다.

청킹

텍스트는 글자 수가 아니라 토큰 수로 나눕니다.

window = chunk_size
step = chunk_size - chunk_overlap

작은 청크는 검색 정밀도가 높지만 문맥이 잘릴 수 있습니다. 큰 청크는 주변 문맥이 풍부하지만 관련 없는 내용이 섞일 수 있습니다.

chunk_overlap은 chunk_size보다 작아야 합니다. 그렇지 않으면 청킹 위치가 앞으로 이동하지 않습니다. 설정을 읽을 때 이 조건을 검증합니다.

임베딩과 저장

청크 목록은 한 번의 embedding 요청에 배열로 전달합니다. 반환된 임베딩은 다음 조건을 검증합니다.

인덱스에는 문서, 임베딩, 스키마 버전, 지문을 저장합니다. 지문은 추출기 버전, 모델, 청킹 설정, PDF 해시를 포함합니다. 값이 바뀌면 기존 인덱스를 재사용하지 않습니다.

저장은 임시 파일을 완성한 뒤 index.json으로 교체합니다. 저장 중 실패가 기존 정상 인덱스를 손상시킬 가능성을 줄이기 위한 방식입니다.

검색과 임계값

질문이 들어오면 질문 임베딩과 저장된 청크 임베딩의 코사인 유사도를 계산합니다.

질문
  ↓ embedding
질문 벡터
  ↓ cosine similarity
rank(): 상위 k개와 원점수
  ↓ threshold
search(): 실제 사용할 SearchHit

rank()는 임계값 적용 전 결과를 반환합니다. 점수 분포와 임계값을 평가할 때 사용합니다. search()는 SEARCH_SCORE_THRESHOLD 이상의 결과만 남깁니다.

검색 결과에는 본문, 출처, 청크 ID, 점수가 들어갑니다. 모델에 전달할 때는 다음 형태로 직렬화합니다.

Document 1 (Source: filename.pdf):
청크 본문

임계값을 넘은 결과가 없으면 빈 문자열 대신 NO_CONTEXT를 반환합니다. 빈 문자열을 정상 컨텍스트처럼 전달해 모델이 답을 만들어내는 문제를 막기 위한 계약입니다.

Planner와 Actions

Planner

Planner가 사용할 수 있는 Action은 네 개입니다.

[
    "query_rag_with_context",
    "generate_profile_based_response",
    "generate_summary",
    "generate_analysis",
]

Planner는 질문과 Action 목록을 LLM에 전달하고 JSON 계획을 요청합니다.

{
  "plan": ["query_rag_with_context", "generate_summary"],
  "reasoning": "Retrieve evidence, then summarize it",
  "estimated_steps": 2
}

계획은 다음 조건을 검증합니다.

계획 생성이나 검증이 실패하면 질문의 키워드로 fallback 계획을 만듭니다.

ActionExecutor

ActionExecutor는 Action 이름을 실제 함수에 연결합니다. 현재 Action은 모두 검색 근거를 사용하는 LLM 호출입니다.

query_rag_with_context
generate_profile_based_response
generate_summary
generate_analysis

실제 도구 호출, MCP, 파일 변경, 데이터베이스 질의는 구현돼 있지 않습니다.

검색 원문 유지

Agent는 질문을 한 번 검색하고 같은 원문을 모든 Action에 전달합니다.

retrieved_context: 모든 Action의 사실 근거
previous_result: 다음 Action의 보조 입력

이전 Action 결과가 검색 원문을 대체하지 않습니다. 요약이나 분석도 같은 원문을 근거로 수행합니다.

Direct와 Planning 비교

Direct

질문 → 검색 → query_rag_with_context → 최종 답변

Direct 경로는 계획 호출 없이 답변 Action 하나를 실행합니다.

Planning

질문 → Planner → 검색 → Action 1 → Action 2 ... → 최종 답변

Planning 경로는 계획을 먼저 생성하고, 검색 결과가 있으면 계획의 Action을 순서대로 실행합니다.

경로 질문 임베딩 계획 생성 답변 생성
Direct, 근거 있음 1 0 1
Planning, 근거 있음 1 1 Action 수만큼
Direct, 근거 없음 1 0 0
Planning, 근거 없음 1 1 0

Planning은 유연하지만 호출 수와 지연이 늘어납니다. Direct는 단순하고 비용을 예측하기 쉽습니다. 두 경로의 비교 자체가 에이전트 워크로드 실습의 핵심입니다.

코드에서는 경로를 명시적으로 선택할 수 있습니다.

result = agent.process_query(
    "Analyze what makes a 57-bit address canonical.",
    use_planning=True,
)

OOD 처리

OOD(Out-of-Distribution)는 지식베이스 범위를 벗어난 질문입니다.

예를 들어 지식베이스가 페이징, 데이터베이스, SAL 문서로 구성됐는데 피자 반죽 비율을 묻는다면 OOD 질문입니다.

검색 결과가 없는데 답변 LLM을 호출하면 모델이 일반 지식으로 답하거나 내용을 지어낼 수 있습니다. Agent는 이 경우 Action 실행을 중단합니다.

검색 결과 없음
→ 답변 LLM 호출 없음
→ NO_CONTEXT
→ success=True
→ grounded=False

검색 결과 없음은 시스템 오류가 아닙니다. API 오류나 실행 실패는 success=False로 구분합니다.

Planning은 검색보다 먼저 실행되므로 OOD 질문에도 계획 호출 한 번은 발생합니다. 검색 후 답변 생성은 수행하지 않습니다.

검색과 답변 평가

기본 테스트는 실제 API를 호출하지 않습니다.

uv run ruff check .
uv run pytest

실제 검색과 답변 품질은 별도 스크립트로 평가합니다.

uv run python eval_search.py --strict
uv run python eval_answers.py --mode both --strict

검색 평가

eval_search.py는 임계값 탐색용 calibration 질문과 최종 확인용 held-out 질문을 분리합니다.

측정 항목은 다음과 같습니다.

rank()의 원점수를 사용하므로 인덱스를 다시 만들지 않고 임계값을 비교할 수 있습니다.

답변 평가

eval_answers.py는 Direct와 Planning을 따로 실행합니다.

이 평가는 모델의 의미를 완전히 판정하지는 못합니다. 핵심어 기반 평가는 같은 뜻의 다른 표현을 놓칠 수 있습니다. 사례 수도 실제 사용자 질문 전체를 대표하지 않습니다. 회귀를 빠르게 찾는 기준으로 사용합니다.

현재 한계

이 실습은 프로덕션 서빙 플랫폼 전체가 아닙니다. 에이전트가 검색, 계획, 반복 모델 호출을 통해 어떤 워크로드를 만드는지 관찰하는 애플리케이션 수준의 예제입니다.

정리

Knowledge Agent는 RAG로 외부 근거를 찾고 Planner로 Action 순서를 구성합니다. Direct와 Planning을 비교하면 자율성이 호출 수, 지연, 비용을 어떻게 바꾸는지 확인할 수 있습니다.

근거가 없을 때 생성을 중단하고, 모든 Action에 같은 검색 원문을 전달하며, 계획과 실행 결과를 검증하는 것은 에이전트 워크로드를 안전하게 다루기 위한 애플리케이션 정책입니다.

GPU 배치, KV cache, 모델 최적화는 이 코드 뒤의 모델 서빙 계층에 있습니다. 이 경계부터는 메인 Chapter 4 글의 계층형 아키텍처와 성능 지표로 이어집니다.