[RAG] Single-shot RAG 에서 Agentic RAG 전환

Woong·2026년 4월 17일

ElasticSearch

목록 보기
31/31

개요

  • 사내 HR FAQ 챗봇의 검색 품질을 개선하기 위해 기존 single-shot RAG 파이프라인을 Agentic RAG 로 전환한 내용 정리
    • 기존: 사용자 질문 → embedding → OpenSearch 검색 1회 → LLM 답변 생성
    • 변경: 사용자 질문 → Claude 가 검색 전략을 판단 → tool 호출로 검색 → 결과 평가 → 필요 시 재검색 → 답변 생성
  • 핵심은 검색을 누가 제어하느냐의 변경.
    • 검색 엔진(OpenSearch) 자체는 동일

기존 구조 — Single-shot RAG

  • 파이프라인 흐름
사용자 질문 → query embedding 생성 → OpenSearch 검색 (semantic + vector) → 결과를 prompt 에 삽입 → gpt-4o-mini 답변 생성
  • 한계
    • query rewriting 부재: 사용자 입력("연차 며칠?")이 그대로 검색 쿼리로 사용. HR 문서 제목/본문과의 lexical gap 발생
    • 검색 결과 fusion 미흡: semantic search 와 vector search 결과를 단순 concat. score normalization 이나 deduplication 없음
    • 재검색 불가: 검색 결과가 부적절해도 그대로 LLM 에 전달. 결과 품질을 평가하는 단계가 없음
    • 필터 미사용: category, company 등 structured metadata 를 활용한 필터링 로직이 하드코딩되어 있지 않음

개선 구조 — Agentic RAG

  • 파이프라인 흐름
사용자 질문 → Claude API 호출 (system prompt + tools + messages)
  → stop_reason == "tool_use" → OpenSearch 검색 실행 → 결과를 messages 에 추가 → Claude API 재호출
  → stop_reason == "end_turn" → 최종 답변 반환
  • Claude 가 검색 쿼리를 직접 구성하고, 결과를 평가하여 재검색 여부를 판단하는 agentic loop 구조
  • 주요 변경 사항
    • OpenSearch 검색을 Claude tool 로 정의. Claude 가 query, category, company 파라미터를 직접 구성
    • RRF (Reciprocal Rank Fusion) 로 semantic + vector 검색 결과를 rank 기반으로 합산
    • category/company post-filter 적용. 필터 결과가 없으면 전체 반환 (fail-safe)
    • 대화 히스토리를 Claude messages 포맷으로 전달
    • LLM: gpt-4o-mini → Claude Sonnet 으로 교체

tool 정의

  • search_hr_docs — Claude 가 호출하는 검색 tool
TOOLS = [
    {
        "name": "search_hr_docs",
        "description": (
            "HR 문서를 검색합니다. "
            "query에 검색 키워드를 넣고, 선택적으로 category나 company로 필터링할 수 있습니다."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {
                    "type": "string",
                    "description": "검색 키워드. 사용자 질문을 검색에 적합한 형태로 변환하여 입력.",
                },
                "category": {
                    "type": "string",
                    "description": "카테고리 필터 (선택). 예: 급여, 복리후생, 휴가, 평가 등.",
                },
                "company": {
                    "type": "string",
                    "description": "회사 필터 (선택).",
                },
                "top_k": {
                    "type": "integer",
                    "description": "반환할 검색 결과 수. 기본값 5.",
                    "default": 5,
                },
            },
            "required": ["query"],
        },
    },
]
  • tool 을 이렇게 정의하면 Claude 가 사용자 의도를 분석하여 파라미터를 자동으로 구성
    • ex) "메포 육아휴직 기간" → query="육아휴직 기간", company="SGP" 로 분리하여 호출

Agentic Loop 구현

  • Claude API 호출 → stop_reason 에 따라 분기하는 loop 패턴
async def _answer_query_traced(self, query: str, user_id: str) -> HRAgentResponse:
    # 메시지 구성 (히스토리 + 현재 질문)
    messages = self.get_recent_history(user_id) + [
        {"role": "user", "content": query}
    ]

    for iteration in range(self.max_tool_iterations):
        response = await self._call_claude_api(iteration, messages)

        # 최종 응답
        if response.stop_reason == "end_turn":
            reply = next(
                (b.text for b in response.content if hasattr(b, "text")), ""
            )
            return HRAgentResponse(response=reply, referenced_docs=unique_docs)

        # tool_use 처리
        if response.stop_reason == "tool_use":
            messages.append({"role": "assistant", "content": response.content})

            tool_blocks = [b for b in response.content if b.type == "tool_use"]
            tool_results = []

            for block in tool_blocks:
                result_text = await asyncio.get_event_loop().run_in_executor(
                    None, self._execute_tool, block.name, block.input
                )
                tool_results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": result_text,
                })

            messages.append({"role": "user", "content": tool_results})
  • max_tool_iterations 로 최대 반복 횟수를 제한. 기본값 10
  • OpenSearch 검색은 sync 함수이므로 run_in_executor 로 async 호환

검색 품질 개선 — RRF

  • 기존에는 semantic search 결과와 vector search 결과를 단순 concat. 동일 문서가 중복되거나 score 체계가 다른 두 결과의 순위가 뒤섞이는 문제가 있었음
  • RRF (Reciprocal Rank Fusion) 를 적용하여 두 검색 결과를 rank 기반으로 합산
def _rrf_merge(self, results_a, results_b, top_k=5, k=60):
    scores = {}
    doc_map = {}

    for rank, doc in enumerate(results_a):
        doc_id = doc["_id"]
        scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank + 1)
        doc_map[doc_id] = doc

    for rank, doc in enumerate(results_b):
        doc_id = doc["_id"]
        scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank + 1)
        doc_map[doc_id] = doc

    sorted_ids = sorted(scores, key=lambda x: scores[x], reverse=True)[:top_k]
    return [doc_map[doc_id] for doc_id in sorted_ids]
  • RRF 의 핵심: 각 검색 결과에서의 순위(rank) 를 기반으로 점수를 합산. 원래 score 의 scale 차이에 영향을 받지 않음
  • k=60 은 RRF 논문의 기본 상수. rank 가 낮은 문서에 과도한 가중치가 부여되는 것을 방지

reference

0개의 댓글