Study/RAG(Retrieval-Augmented Generation)

3. RAG 커스텀 최적화 - 3가지 시나리오별 맞춤 전략

bluebamus 2026. 2. 22.

03_RAG_커스텀_최적화_3가지_시나리오.md
0.14MB
03_RAG_커스텀_최적화_3가지_시나리오_new.md
0.15MB
03_RAG_커스텀_최적화_3가지_시나리오_new.md
0.15MB

1. 개요

   - 동일한 RAG 파이프라인이라도 도메인, 문서 특성, 사용자 패턴에 따라 완전히 다른 최적화가 필요하다. 본 문서에서는 세 가지 현실적인 시나리오를 통해 각 RAG 단계의 커스텀 최적화 전략을 비교한다.

 

   1.1. 왜 시나리오별 최적화가 필요한가

   - RAG 파이프라인은 문서 로딩, 청킹, 임베딩, 검색, 생성의 단계로 구성되는데, 각 단계의 최적 설정은 도메인에 따라 크게 달라진다. 예를 들어 법률 문서에서는 조항 단위의 정밀한 검색이 필수적이지만, FAQ 챗봇에서는 짧은 질문-답변 쌍의 빠른 매칭이 더 중요하다. 기술 문서에서는 코드 블록과 텍스트 설명을 함께 검색해야 하는 고유한 요구사항이 있다.

[Naive RAG 파이프라인]
문서 로딩 → 청킹 → 임베딩 → 벡터 DB → 검색 → 생성
   ↑         ↑        ↑         ↑        ↑       ↑
   └─────────┴────────┴─────────┴────────┴───────┘
   모든 단계가 도메인 특성에 따라 커스텀 최적화 대상

[시나리오별 최적화 포인트]
1) 시나리오 A (법률): 조항 파싱 → 계층적 청킹 → 고차원 임베딩 
→ 키워드 변환 + MultiQuery → 법적 근거 프롬프트

2) 시나리오 B (FAQ):  Q&A 구조화 → 분할 없음    → 저차원 임베딩 
→ 하이브리드 + 확장    → 간결한 프롬프트

3) 시나리오 C (기술): 코드 분리  → 코드 보존    → 접두사 임베딩 
→ 병렬 검색 + 필터     → 코드 포함 프롬프트


   - 실무 권장: 새로운 RAG 프로젝트를 시작할 때, 먼저 자신의 도메인이 아래 세 시나리오 중 어디에 가장 가까운지 판단하고, 해당 시나리오의 최적화 전략을 기본 템플릿으로 활용하라. 완전히 새로운 도메인이라도 세 시나리오의 전략을 조합하여 출발점을 잡을 수 있다.

 

   1.2. 커스텀 최적화의 핵심 원칙

      - 커스텀 최적화는 다섯 가지 핵심 원칙을 기반으로 한다.

 

      1.2.1. 첫째, 도메인 특성 반영이다.

         - 문서의 구조와 용어 체계를 파이프라인 설계에 직접 내장해야 한다. 법률 문서라면 조항 번호 패턴을 메타데이터로 추출하고, FAQ라면 질문-답변 쌍을 분리하지 않고 하나의 단위로 취급하는 방식이 대표적이다. 도메인 특성을 파이프라인이 모른다면 어떤 최적화를 해도 근본적인 한계가 생긴다.

 

      1.2.2. 둘째, 사용자 패턴 대응이다.

         - 사용자가 어떤 방식으로 질문하고 어떤 형식의 답변을 기대하는지를 파이프라인에 반영해야 한다. 짧고 비정형적인 질문이 많은 서비스라면 질의 확장 단계를 추가하고, 출처를 중시하는 전문가 대상 시스템이라면 참조 조항 인용 규칙을 프롬프트에 명시하는 식이다.

 

      1.2.3. 셋째, 정확도-비용 균형이다.

         - 도메인마다 정확도에 대한 요구 수준이 다르므로 이를 비용과 균형 있게 고려해야 한다. 법률 상담처럼 오류가 법적 책임으로 이어지는 도메인에서는 비용이 높더라도 정확도를 최우선으로 하지만, 고객센터 FAQ처럼 하루 수만 건의 트래픽을 처리하는 서비스에서는 비용 효율이 더 중요한 설계 기준이 된다.

 

      1.2.4. 넷째, 단계별 독립 최적화다. 

         - RAG 파이프라인의 각 단계(로딩, 청킹, 임베딩, 검색, 생성)를 독립적으로 교체하거나 개선할 수 있도록 설계해야 한다. 임베딩 모델만 교체하거나 검색 방식만 변경해도 나머지 단계에 영향을 주지 않는 구조가 이상적이다. 이렇게 해야 품질 문제를 발생한 단계에서 정확히 해결할 수 있다.

      1.2.5. 다섯째, 평가 기반 튜닝이다. 

         - 최적화 방향은 감(感)이 아닌 정량적 지표로 결정해야 한다. 관련 문서가 검색 결과에 포함된 비율인 Retrieval Recall, 답변이 검색 문서에 근거한 비율인 Answer Faithfulness 등의 지표를 정기적으로 측정하고 그 결과를 바탕으로 파이프라인의 어느 단계를 개선할지 결정한다.

 

2. 시나리오 정의

   - 시나리오 A는 법률/세법 상담 시스템이다. 소득세법, 법인세법처럼 조, 항, 호의 계층 구조를 가진 장문 문서를 다루며, 하나의 조항이 다른 조항을 참조하는 상호 참조 관계가 복잡하게 얽혀 있다. 사용자는 "직장인 세금"처럼 일상 용어로 질문하지만 문서에는 "거주자의 근로소득세"처럼 전문 법률 용어가 기술되어 있어 어휘 불일치 문제가 심각하다. 부정확한 세율이나 잘못된 조항 인용은 법적 책임으로 직결되므로 정확도 요구가 가장 높고, 모든 답변에는 반드시 관련 조항 번호와 법적 근거가 포함되어야 한다.

 

   - 시나리오 B는 고객센터 FAQ 챗봇이다. 데이터가 이미 질문-답변 쌍으로 구조화되어 있으며, 배송, 결제, 환불 등의 카테고리로 분류된다. 사용자 질의는 "안 돼요", "환불"처럼 매우 짧고 비정형적인 표현이 많아 의도를 정확히 파악하는 것이 핵심 과제다. 하루 수천~수만 건의 대량 트래픽을 처리해야 하므로 비용 효율이 중요하고, 고객 만족을 위해 3문장 이내의 간결하고 친절한 답변이 요구된다.

 

   - 시나리오 C는 기술 문서 검색 시스템이다. 마크다운 형식의 문서에 코드 블록, API 명세, 설정 파일이 혼재하며, v1, v2처럼 버전별로 서로 다른 문서가 공존한다. 사용자는 "Python에서 리스트 정렬하는 방법"처럼 자연어와 기술 키워드를 혼합하여 질문하며, 답변에는 반드시 실행 가능한 코드 예시와 함께 개념 설명이 포함되어야 한다. 코드의 정확성이 개발자의 생산성에 직결되므로 정확도 요구가 높다.

 

   2.1. 시나리오 선택 가이드

      - 자신의 프로젝트가 어떤 시나리오에 해당하는지 빠르게 판단하기 위한 체크리스트이다.

질문 A (법률) B (FAQ) C (기술)
문서에 조항/항/호 구조가 있는가? O X X
문서가 질문-답변 쌍으로 구성되어 있는가? X O X
문서에 코드 블록이 포함되어 있는가? X X O
응답에 법적 근거/출처 명시가 필수인가? O X X
사용자가 짧고 비정형적인 질문을 하는가? X O X
응답에 실행 가능한 코드가 필요한가? X X O
비용보다 정확도가 절대적으로 중요한가? O X O


   - 실무 권장: 실제 프로젝트에서는 하나의 시나리오에 완전히 들어맞지 않는 경우가 많다. 예를 들어 "기술 규격 문서 검색 시스템"은 시나리오 A(구조적 문서)와 C(코드 포함)의 특성을 모두 가질 수 있다. 이런 경우 두 시나리오의 전략을 조합하여 사용한다.

 

3. 시나리오 A: 법률/세법 상담 시스템

   - 법률/세법 도메인은 RAG 최적화에서 가장 까다로운 영역 중 하나이다. 문서 자체의 구조가 복잡하고(조, 항, 호의 계층 구조), 용어가 전문적이며, 답변에는 반드시 법적 근거가 명시되어야 한다. 또한 사용자는 전문 용어가 아닌 일상 용어로 질문하는 경우가 많아, 질의-문서 간 어휘 불일치(vocabulary mismatch) 문제가 심각하다.

 

   3.1. 법률 도메인 RAG의 핵심 과제

      - 법률 도메인 RAG는 다섯 가지 핵심 과제를 해결해야 한다.

 

      3.1.1. 첫 번째 과제는 구조적 문서 파싱이다.

         - 한국 법률 문서는 조(條), 항(項), 호(號)의 3단계 계층 구조로 구성된다. 단순 텍스트 추출로는 이 계층 구조가 사라지므로, "제47조 제1항 제3호"와 같은 위치 정보를 메타데이터로 명시적으로 저장해야 한다. 이 메타데이터는 검색 시 필터링과 답변 시 출처 인용에 직접 활용된다.

 

      3.1.2. 두 번째 과제는 상호 참조 처리다.

         - 법률 문서에서는 "제50조에서 규정하는 바에 따라..."처럼 한 조항이 다른 조항을 참조하는 구조가 빈번하다. 특정 조항을 검색했을 때 그 조항이 참조하는 다른 조항들도 함께 포함하지 않으면 불완전한 법적 근거만 제공하게 된다. 참조 관계를 추적하는 로직이 없으면 검색 결과가 충분해도 답변의 법적 완결성이 떨어진다.

 

      3.1.3. 세 번째 과제는 어휘 불일치다.

         - 일반 사용자는 "직장인 세금"이라고 질문하지만 법률 문서에는 "거주자의 근로소득세"라고 기술되어 있다. 벡터 임베딩만으로는 이 간극을 완전히 극복하기 어렵기 때문에, 일상 용어와 법률 전문 용어를 연결하는 키워드 사전을 별도로 구축하여 질의 변환에 활용해야 한다.

 

      3.1.4. 네 번째 과제는 정확성 필수다.

         - 세율이나 공제 한도 수치를 한 자리만 틀려도 납세자에게 실질적인 피해가 발생하고 법적 책임 문제로 이어진다. 컨텍스트에 없는 내용을 추측하거나 수치를 근사값으로 생성하는 것이 절대 허용되지 않으므로, 프롬프트에 이를 명시적으로 금지하는 규칙이 반드시 포함되어야 한다.

 

      3.1.5. 다섯 번째 과제는 출처 추적이다. 

         - 모든 답변에는 "소득세법 제47조에 따르면..."과 같이 법적 근거가 되는 조항 번호가 반드시 명시되어야 한다. 이는 단순한 UX 요소가 아니라 법적 신뢰성의 핵심 조건으로, 사용자가 답변의 근거를 독립적으로 확인하고 검증할 수 있어야 한다.

 

   3.2. 요구 명세 (Requirements Specification)

      - 시나리오 A의 RAG 시스템을 구축하기 전에, 시스템이 충족해야 할 정량적 요구사항을 먼저 정의한다. 이 요구 명세는 이후 모든 설계 결정의 기준이 된다.

 

항목 요구사항 비고
대상 문서 소득세법, 법인세법, 부가가치세법 등 세법 본문 + 시행령 + 시행규칙 총 약 3,000~5,000개 조항
문서 규모 약 500만~1,000만 자 (5~10MB 텍스트) .docx, .pdf 혼합
동시 사용자 최대 50명 (법률 사무소/세무법인 내부 시스템) 피크 시간: 09~18시
일일 질의량 500~1,000건/일 세무 상담 시즌(1~3월)에 2배 증가
응답 시간 평균 5초 이내, 최대 15초 LLM 생성 시간 포함
검색 정확도 Retrieval Recall@6 ≥ 85% 관련 조항 6개 중 정답 포함 비율
답변 정확도 Answer Faithfulness ≥ 90% 답변이 검색된 문서에 기반한 비율
출처 명시율 100% 모든 답변에 조항 번호 포함 필수
환각률 ≤ 5% 검색 문서에 없는 내용 생성 비율
가용성 99.5% (월간 가동시간) 계획된 유지보수 제외
월간 비용 한도 $300 이내 (OpenAI API 기준) 임베딩 + LLM 호출 합계


      - 설계 의도: 법률 도메인에서는 정확도가 비용보다 절대적으로 우선한다. 부정확한 세율이나 잘못된 조항 인용은 법적 책임 문제로 직결되므로, 비용을 높이더라도 `gpt-4o` + `text-embedding-3-large` 조합을 사용하고, 검색 k 값을 넉넉히 설정한다. 반면 응답 시간 요구는 비교적 관대한데, 법률 상담은 실시간 채팅이 아닌 질의-응답 형태이므로 5초 이내면 충분하다.

 

   3.1. A.1 문서 로딩 최적화

      - 문제: 법률 문서는 조항, 항, 호 구조가 복잡하고, 표와 수식이 포함되어 있다.

      - 일반적인 문서 로더(`Docx2txtLoader`, `PyPDFLoader` 등)는 법률 문서의 조항 구조를 인식하지 못한다. 단순히 텍스트를 추출하면 "제47조"와 같은 조항 번호가 어떤 내용에 속하는지, 어떤 항과 호가 포함되는지의 구조 정보가 손실된다. 이 구조 정보는 나중에 답변에서 "소득세법 제47조 제1항"과 같은 정확한 출처를 명시하는 데 필수적이다.

 

      3.1.1. 일반 로더 vs 커스텀 법률 로더 비교

항목 일반 로더 (Docx2txtLoader) 커스텀 법률 로더 (LegalDocumentLoader)
텍스트 추출 전체 텍스트를 하나의 Document로 조항 단위로 개별 Document 생성
메타데이터 source(파일 경로)만 저장 article(조항 번호), type, has_table 등
조항 구조 구조 정보 손실 조/항/호 패턴을 파싱하여 보존
표 처리 텍스트로 평탄화 마크다운 테이블로 변환하여 구조 보존
검색 시 장점 없음 조항 번호로 필터링, 출처 자동 추적

 

         3.1.1.1. 커스텀 전략:

import logging
from langchain_core.documents import Document
import re

logger = logging.getLogger(__name__)

class LegalDocumentLoader:
    """법률 문서 전용 로더 - 조항 구조를 메타데이터로 보존

    일반 로더와 달리, 법률 문서의 '제N조' 패턴을 인식하여
    조항 단위로 Document 객체를 생성한다.
    각 Document의 metadata에 조항 번호, 문서 유형, 표 포함 여부를 저장하여
    검색 시 필터링과 출처 추적에 활용할 수 있다.
    """

    # 조항 번호 패턴: '제47조', '제47조의2' 등
    ARTICLE_PATTERN = r'(제\d+조(?:의\d+)?)'

    def __init__(self, file_path: str):
        # 로딩할 법률 문서 파일 경로 (.docx 형식)
        self.file_path = file_path

    def load(self) -> list[Document]:
        """법률 문서를 조항 단위 Document 리스트로 변환

        Returns:
            list[Document]: 조항별 Document 리스트. 로딩 실패 시 빈 리스트 반환.

        Raises:
            FileNotFoundError: 파일이 존재하지 않을 때
        """
        import os
        if not os.path.exists(self.file_path):
            raise FileNotFoundError(f"법률 문서 파일이 존재하지 않습니다: {self.file_path}")

        try:
            # 원본 텍스트 로딩 (python-docx 라이브러리 사용)
            from docx import Document as DocxDoc
            doc = DocxDoc(self.file_path)
        except ImportError:
            logger.error("python-docx 라이브러리가 설치되지 않았습니다. pip install python-docx")
            return []
        except Exception as e:
            logger.error(f"문서 로딩 실패 ({self.file_path}): {e}")
            return []

        # 모든 문단(paragraph)의 텍스트를 줄바꿈으로 연결
        full_text = "\n".join([p.text for p in doc.paragraphs])

        if not full_text.strip():
            logger.warning(f"문서가 비어 있습니다: {self.file_path}")
            return []

        # 조/항/호 패턴으로 분할
        # (?:의\d+)? 부분은 '의2', '의3' 같은 가지 조항을 매칭
        sections = re.split(self.ARTICLE_PATTERN, full_text)

        documents = []
        current_article = ""  # 현재 처리 중인 조항 번호를 추적

        for section in sections:
            if re.match(self.ARTICLE_PATTERN, section):
                # 조항 번호 패턴에 매칭되면 현재 조항 번호 업데이트
                current_article = section
            elif section.strip():
                # 조항 번호가 아닌 실제 내용 부분을 Document로 생성
                # page_content에 조항 번호를 접두사로 포함하여
                # 검색 시 조항 번호도 함께 매칭되도록 함
                documents.append(Document(
                    page_content=f"{current_article} {section.strip()}",
                    metadata={
                        "source": self.file_path,        # 원본 파일 경로
                        "article": current_article,       # 조항 번호 (예: "제47조")
                        "type": "legal_article",          # 문서 유형 식별자
                        "has_table": "│" in section or "|" in section  # 표 포함 여부
                    }
                ))

        logger.info(f"법률 문서 로딩 완료: {len(documents)}개 조항 추출 ({self.file_path})")
        return documents


   - 파라미터 정의 기준: `article_pattern`의 정규표현식 `r'(제\d+조(?:의\d+)?)'`는 한국 법률의 조항 번호 체계에 맞춰 설계되었다. `제\d+조`는 "제1조", "제47조" 등을 매칭하고, `(?:의\d+)?`는 선택적으로 "의2", "의3" 같은 가지 조항(枝條)을 매칭한다. 법률에 따라 "제47조의2 제1항" 같은 더 세밀한 패턴이 필요하면 정규표현식을 확장해야 한다.

 

      3.1.2. 항/호 단위 세분화 확장

         - 위의 기본 로더는 조(條) 단위로 분할하지만, 실무에서는 항(項)과 호(號) 단위까지 세분화해야 하는 경우가 많다. 특히 하나의 조항이 여러 항을 포함하고 각 항이 독립적인 의미를 가질 때 유용하다.

def parse_sub_articles(article_text: str, article_number: str) -> list[dict]:
    """조항 내부의 항(①②③...)과 호(1. 2. 3.)를 파싱하는 함수

    법률 문서에서 항은 원문자(①②③)로, 호는 숫자+마침표(1. 2. 3.)로 표기된다.
    이를 파싱하여 각 항/호의 내용과 번호를 구조화된 딕셔너리로 반환한다.
    """
    sub_articles = []

    # 항(①②③...) 패턴 분할
    # 유니코드 원문자: ① \u2460, ② \u2461, ...
    paragraph_pattern = r'([①②③④⑤⑥⑦⑧⑨⑩])'
    paragraphs = re.split(paragraph_pattern, article_text)

    current_paragraph = ""
    for part in paragraphs:
        if re.match(paragraph_pattern, part):
            current_paragraph = part  # 현재 항 번호 업데이트
        elif part.strip() and current_paragraph:
            sub_articles.append({
                "article": article_number,      # 소속 조항 (예: "제47조")
                "paragraph": current_paragraph, # 항 번호 (예: "①")
                "content": part.strip(),        # 항의 실제 내용
                "full_reference": f"{article_number} {current_paragraph}"  # 완전한 참조 표기
            })

    return sub_articles


   - 실무 권장: 법률 문서의 조항 구조가 복잡할수록 메타데이터를 세밀하게 설정해야 한다. 최소한 `article`(조항 번호)은 반드시 메타데이터에 포함하고, 가능하면 `paragraph`(항 번호)까지 포함하라. 이 메타데이터는 검색 시 필터링과 답변 시 출처 명시에 직접 활용된다.

 

         3.1.2.1. 표 데이터 마크다운 변환:

            - 법률 문서에는 세율 표, 공제 한도 표 등 표 형태의 데이터가 많이 포함된다. 이런 표를 단순 텍스트로 추출하면 구조가 손실되어 LLM이 정확히 해석하기 어렵다. 마크다운 테이블로 변환하면 구조를 보존하면서도 LLM이 이해하기 쉬운 형태가 된다.

def convert_tax_table_to_markdown(table_text: str) -> str:
    """세율 표를 마크다운 테이블로 변환하여 구조 보존

    법률 문서에서 추출한 표 텍스트를 마크다운 형식으로 변환한다.
    예: "1200만원 이하 6% / 1200~4600만원 15%" 등의 텍스트를
    마크다운 테이블로 변환하여 LLM이 구조를 이해할 수 있게 한다.

    Args:
        table_text: 원본 표 텍스트 (줄바꿈으로 행 구분, 공백으로 열 구분)

    Returns:
        마크다운 테이블 문자열. 변환 불가 시 원본 반환.
    """
    lines = table_text.strip().split("\n")
    if len(lines) < 2:
        # 2행 미만이면 테이블로 변환할 수 없으므로 원본 반환
        return table_text

    # 첫 번째 행을 헤더로 사용
    markdown = "| " + " | ".join(lines[0].split()) + " |\n"
    # 헤더와 본문을 구분하는 구분선 추가
    markdown += "| " + " | ".join(["---"] * len(lines[0].split())) + " |\n"
    # 나머지 행을 본문으로 추가
    for line in lines[1:]:
        markdown += "| " + " | ".join(line.split()) + " |\n"

    return markdown


      3.1.3. 변환 결과 예시

[원본 텍스트]
과세표준 세율 누진공제
1200만원이하 6% 0
1200~4600만원 15% 108만원
4600~8800만원 24% 522만원

[마크다운 변환 결과]
| 과세표준 | 세율 | 누진공제 |
| --- | --- | --- |
| 1200만원이하 | 6% | 0 |
| 1200~4600만원 | 15% | 108만원 |
| 4600~8800만원 | 24% | 522만원 |


   - 실무 권장: 표 데이터를 마크다운으로 변환할 때, 숫자와 단위(만원, %)가 분리되지 않도록 주의하라. 공백 기반 열 분리가 정확하지 않은 경우, 정규표현식이나 `python-docx`의 테이블 파싱 기능을 직접 사용하는 것이 더 안정적이다.

 

   3.2. A.2 청킹 최적화

      - 문제: 법률 조항은 서로 참조하며, 단순 크기 기반 분할 시 조항이 중간에 잘린다.

 

      - 법률 문서에서 `RecursiveCharacterTextSplitter`를 그대로 사용하면 "제47조 ①항"의 내용이 두 청크에 걸쳐 분할될 수 있다. 이렇게 되면 해당 조항을 검색했을 때 완전한 내용을 얻을 수 없고, 답변에서 부분적인 정보만 제공하게 된다.

 

      3.2.1. 일반 청킹 vs 법률 전용 청킹 비교

항목 일반 청킹 (RecursiveCharacterTextSplitter) 법률 전용 청킹 (LegalHierarchicalChunker)
분할 기준 문자 수 (chunk_size) 조항 단위 (제N조)
경계 처리 문단/줄/단어 경계에서 분할 조항 경계에서만 분할
구조 보존 구조 무시 조/항/호 계층 구조 보존
메타데이터 기본 (source만) 포함 조항 목록, chunk_type
오버랩 문자 수 기반 중복 조항 수 기반 중복
적합 상황 일반 텍스트 조항 구조가 있는 법률/규정 문서


         3.2.1.1. 커스텀 전략: 조항 기반 계층적 청킹

class LegalHierarchicalChunker:
    """법률 문서의 조/항/호 계층 구조를 보존하는 청커

    일반 텍스트 분할기와 달리, 법률 문서의 '제N조' 패턴을 인식하여
    조항 단위로 청크를 생성한다. 하나의 조항이 max_chunk_size를 초과하지 않는 한
    조항 중간에서 분할하지 않으며, 여러 조항을 하나의 청크에 묶어
    문맥을 보존한다.

    Args:
        max_chunk_size: 청크 최대 문자 수. 이 크기를 초과하면 새 청크 시작.
                       법률 문서는 1500~2000이 적합 (하나의 조항이 평균 500~1000자)
        overlap_articles: 인접 청크 간 중복할 조항 수.
                         조항 간 참조 관계가 있을 때 문맥 연속성 보장.
                         0이면 중복 없음, 1이면 마지막 1개 조항이 다음 청크에도 포함
    """

    def __init__(self, max_chunk_size: int = 2000, overlap_articles: int = 0):
        self.max_chunk_size = max_chunk_size
        self.overlap_articles = overlap_articles

    def chunk(self, documents: list[Document]) -> list[Document]:
        chunks = []
        for doc in documents:
            text = doc.page_content
            # '제N조' 패턴을 기준으로 분할
            # (?=제\d+조) 는 전방탐색(lookahead)으로, 패턴 자체는 소비하지 않고
            # 해당 위치에서 분할만 수행
            articles = re.split(r'(?=제\d+조)', text)

            current_chunk = ""       # 현재 청크에 누적된 텍스트
            current_articles = []    # 현재 청크에 포함된 조항 번호 목록

            for article in articles:
                # 현재 청크에 이 조항을 추가하면 max_chunk_size를 초과하는지 확인
                if len(current_chunk) + len(article) > self.max_chunk_size and current_chunk:
                    # 현재 청크를 Document로 저장
                    chunks.append(Document(
                        page_content=current_chunk.strip(),
                        metadata={
                            **doc.metadata,  # 원본 Document의 메타데이터 상속
                            "articles_included": current_articles.copy(),  # 포함된 조항 목록
                            "chunk_type": "legal_section"  # 청크 유형 식별
                        }
                    ))

                    # 오버랩 처리: 마지막 N개 조항을 다음 청크에 포함
                    # 조항 간 참조 관계가 있을 때 문맥 연속성을 보장
                    if self.overlap_articles > 0:
                        overlap_text = "\n".join(
                            articles[max(0, len(current_articles)-self.overlap_articles):]
                        )
                        current_chunk = overlap_text
                    else:
                        current_chunk = ""
                    current_articles = []

                # 현재 조항을 청크에 추가
                current_chunk += article + "\n"
                # 조항 번호 추출하여 목록에 추가
                article_match = re.match(r'(제\d+조(?:의\d+)?)', article)
                if article_match:
                    current_articles.append(article_match.group(1))

            # 마지막 남은 청크 처리
            if current_chunk.strip():
                chunks.append(Document(
                    page_content=current_chunk.strip(),
                    metadata={
                        **doc.metadata,
                        "articles_included": current_articles,
                        "chunk_type": "legal_section"
                    }
                ))

        return chunks


            - 파라미터 정의 기준: `max_chunk_size=2000`은 법률 문서에서 하나의 조항이 평균 500~1000자인 점을 고려하여, 2~3개 관련 조항이 하나의 청크에 포함되도록 설정한 값이다. `overlap_articles=1`로 설정하면 인접 청크 간 마지막 조항이 중복되어, "제47조에서 규정하는 바에 따라..."와 같은 조항 간 참조를 처리할 때 문맥이 유지된다.

 

      3.2.2. 청킹 결과 검증 방법

         - 법률 문서의 청킹이 올바르게 수행되었는지 확인하는 것은 품질의 기초이다.

ef verify_legal_chunks(chunks: list[Document]) -> dict:
    """법률 청킹 결과를 검증하는 유틸리티 함수

    각 청크의 크기, 포함 조항 수, 조항 누락 여부를 검사하여
    청킹 품질에 대한 리포트를 반환한다.
    """
    report = {
        "total_chunks": len(chunks),         # 총 청크 수
        "avg_size": 0,                        # 평균 청크 크기 (문자 수)
        "max_size": 0,                        # 최대 청크 크기
        "min_size": float('inf'),             # 최소 청크 크기
        "articles_covered": set(),            # 포함된 모든 조항 번호
        "oversized_chunks": [],               # max_chunk_size 초과 청크
    }

    total_size = 0
    for i, chunk in enumerate(chunks):
        size = len(chunk.page_content)
        total_size += size
        report["max_size"] = max(report["max_size"], size)
        report["min_size"] = min(report["min_size"], size)

        # 포함된 조항 추적
        articles = chunk.metadata.get("articles_included", [])
        report["articles_covered"].update(articles)

        # 과대 청크 경고
        if size > 2500:  # 임계값은 임베딩 모델의 토큰 제한 고려
            report["oversized_chunks"].append((i, size, articles))

    report["avg_size"] = total_size / len(chunks) if chunks else 0
    report["articles_covered"] = sorted(report["articles_covered"])

    return report


         - 실무 권장: 청킹 후 반드시 `verify_legal_chunks()`와 같은 검증 함수를 실행하여 조항 누락이 없는지, 과대/과소 청크가 없는지 확인하라. 특히 원본 문서의 전체 조항 목록과 청킹 결과의 조항 목록을 비교하여 누락된 조항이 없는지 검사하는 것이 중요하다.

   3.3. A.3 임베딩 최적화

      - 권장 설정:

         - 모델: `text-embedding-3-large` (최대 차원 3072)
         - 차원 축소 하지 않음 (법률 용어의 미세한 차이 구분 필요)

      - 법률 도메인에서 임베딩 모델의 선택은 특히 중요하다. "소득세"와 "법인세", "소득공제"와 "세액공제"처럼 비슷하지만 법적으로 완전히 다른 개념을 정확히 구분해야 하기 때문이다. 차원이 높을수록 이런 미세한 의미 차이를 더 잘 포착할 수 있다.

 

      3.3.1. 법률 도메인 임베딩 모델 비교

모델 차원 한국어 법률 적합도 비용 선택 기준
text-embedding-3-large 3072 높음 높음 법률 용어 미세 구분이 중요할 때 (권장)
text-embedding-3-small 1536 중간 낮음 프로토타입 단계에서 비용 절감 시
solar-embedding-1-large (Upstage) 4096 매우 높음 중간 한국어 법률 문서 특화 시
voyage-3 (Voyage AI) 1024 높음 중간 Claude 생태계 사용 시

 

from langchain_openai import OpenAIEmbeddings

# 법률 도메인 임베딩 설정
# text-embedding-3-large는 최대 3072 차원으로
# 법률 용어 간 미세한 의미 차이를 구분하는 데 가장 적합
embedding = OpenAIEmbeddings(
    model="text-embedding-3-large",
    # dimensions 파라미터를 생략하여 최대 차원(3072) 사용
    # 법률 도메인에서는 차원 축소 시 "소득공제"와 "세액공제" 같은
    # 미세한 차이가 손실될 수 있으므로 축소하지 않음
)


         3.3.1.1. 파라미터 정의 기준:

            - `text-embedding-3-large`의 `dimensions` 파라미터를 생략하면 기본 최대 차원(3072)이 사용된다. OpenAI의 Matryoshka Representation Learning 기술 덕분에 `dimensions=1024`나 `dimensions=256`으로 축소해도 동작하지만, 법률 도메인에서는 미세한 의미 구분이 중요하므로 축소하지 않는 것을 권장한다. 비용이 우려된다면 먼저 `dimensions=1536`으로 테스트하여 품질 저하가 허용 범위 내인지 확인한다.

 

         3.3.1.2. 실무 권장: 

            - 한국어 법률 문서를 다룬다면 Upstage의 `solar-embedding-1-large`도 강력한 대안이다. 이 모델은 한국어에 특화되어 있어, 한국 법률의 고유 표현("과세표준", "필요경비", "원천징수" 등)을 더 정확하게 임베딩할 수 있다. 다만 차원이 4096으로 벡터 DB 저장/검색 비용이 증가하므로 트레이드오프를 고려해야 한다.
3.4. A.4 검색 최적화

 

         3.3.1.3. 문제:

            - 사용자가 "직장인 세금"이라고 검색하면 "거주자의 근로소득세"를 찾아야 한다.

 

      3.3.2. 법률 도메인에서 검색이 특히 어려운 이유는 어휘 불일치(vocabulary mismatch) 문제 때문이다.

            - 일반 사용자는 "직장인", "세금", "연말정산"과 같은 일상 용어를 사용하지만, 법률 문서에는 "거주자", "근로소득세", "근로소득 연말정산"과 같은 전문 용어가 사용된다. 벡터 임베딩만으로는 이 차이를 완전히 해소하기 어렵다.

 

      3.3.3. 이 문제를 해결하기 위해 세 가지 전략을 조합한다:

[검색 최적화 전략 흐름]

사용자 질의: "직장인 세금 줄이는 법"
         ↓
[1단계: 키워드 사전 변환]
         → "거주자 근로소득자의 소득세 절감 방법"
         ↓
[2단계: 다중 관점 질의 생성 (MultiQuery)]
         → 질의1: "근로소득자의 소득세 절세 전략"
         → 질의2: "근로소득에 대한 소득공제 방법"
         → 질의3: "연말정산을 통한 세액공제 활용법"
         ↓
[3단계: 각 질의로 벡터 검색 + 결과 합산 + 중복 제거]
         → 최종 관련 문서 목록


      3.3.4. 커스텀 전략: 키워드 사전 + 다중 질의 + 부모 문서 검색

from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_openai import ChatOpenAI
from langchain_community.vectorstores import Chroma

# [전제 조건] 벡터 DB 초기화 (A.1~A.3 단계의 결과물)
# documents = LegalDocumentLoader("소득세법.docx").load()
# chunks = LegalHierarchicalChunker(max_chunk_size=2000).chunk(documents)
# db = Chroma.from_documents(chunks, embedding=OpenAIEmbeddings(model="text-embedding-3-large"))

# ==============================
# 1단계: 키워드 사전 변환
# ==============================
# 일상 용어 → 법률 전문 용어 매핑 사전
# 이 사전은 도메인 전문가와 협력하여 작성하며,
# 사용자 질의 로그를 분석하여 지속적으로 업데이트해야 함
legal_dictionary = """
직장인, 월급쟁이 → 거주자, 근로소득자
세금 → 소득세, 법인세, 부가가치세
연말정산 → 근로소득세 연말정산, 소득공제
프리랜서 → 사업소득자, 자유직업소득자
알바, 아르바이트 → 일용근로자, 단시간근로자
"""

# 키워드 사전을 활용한 질의 변환 체인
# LLM이 사전을 참고하여 일상 용어를 법률 용어로 자동 변환
dict_chain = (
    ChatPromptTemplate.from_template(
        "법률 용어 사전을 참고하여 질문의 일상 용어를 전문 법률 용어로 변환하세요.\n"
        "사전:\n{dictionary}\n\n질문: {question}\n변환된 질문:"
    )
    | ChatOpenAI(model="gpt-4o-mini", temperature=0)  # temperature=0으로 결정적 변환
    | StrOutputParser()
)

# ==============================
# 2단계: 다중 관점 질의 생성
# ==============================
# MultiQueryRetriever는 하나의 질의를 LLM으로 여러 관점의 변형 질의로 확장하여
# 각각에 대해 검색을 수행하고 결과를 합산 (중복 자동 제거)
# 법률 도메인에서는 하나의 질의가 여러 관련 조항을 참조할 수 있으므로
# 다중 관점 검색이 특히 유효함
from langchain.retrievers.multi_query import MultiQueryRetriever

multi_retriever = MultiQueryRetriever.from_llm(
    retriever=db.as_retriever(search_kwargs={"k": 6}),  # 넓은 법적 근거를 위해 k=6
    llm=ChatOpenAI(model="gpt-4o-mini", temperature=0.3)  # 약간의 변형을 위해 temperature=0.3
)


            - 파라미터 정의 기준: `search_kwargs={"k": 6}`에서 k=6은 법률 도메인의 특성을 반영한 값이다. 하나의 법률 질의에 대해 관련 조항이 여러 개일 수 있고(예: 소득세법 제47조, 제50조, 시행령 제100조 등), 답변에 모든 관련 조항을 포함해야 하므로 일반적인 k=4보다 높게 설정한다. `temperature=0.3`은 MultiQuery의 변형 질의가 원본 의도에서 크게 벗어나지 않으면서도 다양한 관점을 포함하도록 하는 균형점이다.

 

   3.4. 키워드 사전 설계 가이드

      - 키워드 사전의 품질이 검색 품질에 직접 영향을 미치므로, 체계적으로 설계해야 한다.

설계 원칙 설명 예시
다대다 매핑 하나의 일상 용어가 여러 법률 용어에 대응 가능 "세금" → "소득세, 법인세, 부가가치세"
양방향 고려 법률 용어의 일상적 표현도 포함 "원천징수" ← "세금 떼는 것, 세금 공제"
동의어 그룹 같은 의미의 여러 일상 표현을 하나의 그룹으로 "직장인, 월급쟁이, 회사원" → "근로소득자"
맥락 의존 매핑 동일 용어라도 맥락에 따라 다른 매핑 "공제" → 문맥에 따라 "소득공제" 또는 "세액공제"
정기 업데이트 사용자 질의 로그 분석으로 사전 확장 새로 발견된 일상 표현 추가


         - 실무 권장: 키워드 사전은 초기에 도메인 전문가와 협력하여 50~100개의 핵심 매핑을 작성하고, 이후 사용자 질의 로그를 분석하여 지속적으로 확장하라. 검색 실패 로그(관련 문서를 찾지 못한 질의)를 정기적으로 분석하면 사전에 추가해야 할 매핑을 발견할 수 있다.

 

   3.5. A.5 생성 최적화

      - 커스텀 프롬프트: 법적 근거 필수 포함

 

      - 법률 도메인의 생성 프롬프트는 일반적인 RAG 프롬프트와 크게 다르다. 핵심 차이점은 다음과 같다:

항목 일반 RAG 프롬프트 법률 RAG 프롬프트
출처 명시 선택 사항 필수 (법 조항 번호)
인용 형식 자유 형식 큰따옴표로 원문 인용
수치 정확도 대략적 허용 정확한 수치 필수
불확실 처리 "~일 수 있습니다" "관련 정보를 찾을 수 없습니다"
답변 구조 자유 형식 해석 + 근거 + 참조 조항

 

legal_prompt = ChatPromptTemplate.from_template("""
당신은 한국 세법 전문 상담사입니다. 다음 규칙을 반드시 준수하세요:

1. 답변에는 반드시 관련 법 조항 번호를 명시하세요 (예: 소득세법 제47조)
2. 법 조항의 원문을 인용할 때는 큰따옴표로 감싸세요
3. 수치나 세율은 정확히 기재하세요
4. 주어진 컨텍스트에 없는 내용은 "관련 정보를 찾을 수 없습니다"라고 답하세요
5. 답변 마지막에 [참조 조항] 섹션을 추가하세요

컨텍스트:
{context}

질문: {question}

답변:
""")


      - 파라미터 정의 기준: 프롬프트의 각 규칙은 법률 상담의 품질 기준에 직접 대응한다. 규칙 1(조항 번호 명시)은 답변의 신뢰성을 위해 필수이며, 규칙 4(컨텍스트 외 답변 금지)는 환각(hallucination)을 방지한다. 규칙 5([참조 조항] 섹션)는 사용자가 답변의 근거를 독립적으로 확인할 수 있게 한다.

 

      3.5.1. 프롬프트 설계 시 주의사항

# [잘못된 예] - 법률 도메인에 부적합한 프롬프트
bad_prompt = ChatPromptTemplate.from_template("""
다음 문서를 참고하여 질문에 답하세요.
문서: {context}
질문: {question}
답변:
""")
# 문제점:
# - 출처 명시 규칙 없음 → LLM이 조항 번호를 생략할 수 있음
# - 불확실 시 처리 규칙 없음 → 환각으로 존재하지 않는 조항을 인용할 수 있음
# - 수치 정확도 규칙 없음 → 세율을 부정확하게 기재할 수 있음

# [올바른 예] - 법률 도메인에 최적화된 프롬프트
good_prompt = ChatPromptTemplate.from_template("""
당신은 한국 세법 전문 상담사입니다.

[필수 규칙]
1. 모든 답변에 관련 법 조항 번호를 명시하세요 (예: 소득세법 제47조)
2. 법 조항 원문은 큰따옴표로 인용하세요
3. 세율, 공제 한도 등 수치는 컨텍스트의 값을 정확히 기재하세요
4. 컨텍스트에 없는 내용은 절대 추측하지 말고 "관련 정보를 찾을 수 없습니다"라고 답하세요
5. 답변 마지막에 [참조 조항] 섹션을 추가하세요

[답변 형식]
- 질문에 대한 해석과 답변
- 관련 법 조항 인용
- [참조 조항] 목록

컨텍스트:
{context}

질문: {question}

답변:
""")


   3.6. A.6 전체 파이프라인 조립

      - 법률 RAG 시스템의 전체 파이프라인은 키워드 변환 → 다중 관점 검색 → 법적 근거 포함 생성의 흐름으로 구성된다.

[법률 RAG 파이프라인 전체 흐름]

사용자 질의: "직장인 세금 줄이는 법"
         ↓
[dict_chain: 키워드 사전 변환]
         → "근로소득자의 소득세 절세 방법"
         ↓
[multi_retriever: 다중 관점 검색]
         → 소득세법 제47조, 제50조, 시행령 제100조 등 관련 문서
         ↓
[format_docs: 문서 포맷팅]
         → "[제47조]\n내용...\n---\n[제50조]\n내용..." 형태로 정리
         ↓
[legal_prompt: 법적 근거 필수 프롬프트]
         → 시스템 규칙과 컨텍스트를 결합
         ↓
[ChatOpenAI(gpt-4o): 고품질 LLM 생성]
         → 법적 근거가 포함된 정확한 답변
         ↓
[StrOutputParser: 최종 응답]
from langchain_core.runnables import RunnablePassthrough

# 문서 포맷팅 함수: 검색된 문서를 조항 번호와 함께 포맷
# 각 문서 앞에 [조항 번호]를 표시하여 LLM이 출처를 명확히 인용하도록 유도
def format_docs(docs):
    return "\n\n---\n\n".join([
        f"[{doc.metadata.get('article', '출처 미상')}]\n{doc.page_content}"
        for doc in docs
    ])

# 전체 체인 조립 (LCEL - LangChain Expression Language)
# 파이프(|) 연산자로 각 단계를 연결하여 선언적으로 파이프라인 구성
legal_rag_chain = (
    {
        # "question" 키: 사전 변환된 질의를 사용
        "question": dict_chain,
        # "context" 키: 변환된 질의로 검색 → 문서 포맷팅
        "context": dict_chain | multi_retriever | format_docs,
    }
    | legal_prompt                              # 법적 근거 필수 프롬프트 적용
    | ChatOpenAI(model="gpt-4o", temperature=0) # gpt-4o: 정확한 법률 해석 필요
    | StrOutputParser()                          # 문자열로 최종 출력
)

 

      - 파라미터 정의 기준: `model="gpt-4o"`를 사용하는 이유는 법률 해석에 높은 추론 능력이 필요하기 때문이다. `gpt-4o-mini`는 비용이 낮지만 복잡한 법률 조항 간 관계를 해석하는 능력이 부족할 수 있다. `temperature=0`은 동일한 질의에 대해 항상 동일한 답변을 생성하도록 하여, 법률 상담의 일관성을 보장한다.

 

      - 실무 권장: 법률 RAG에서 `dict_chain`이 두 번 호출되는 것(question과 context 모두에서)을 주목하라. 이는 질의 변환의 결과를 검색과 프롬프트 양쪽에 모두 사용하기 위함이다. 중복 호출을 피하려면 `RunnablePassthrough.assign()`을 사용하여 변환 결과를 한 번만 계산하고 재사용할 수 있다.

 

   3.7. A.7 실행 예시 및 동작 흐름

      - 법률 RAG 파이프라인의 실행 과정을 구체적인 입출력 예시와 함께 추적한다.

# ============================================================
# 법률 RAG 파이프라인 실행 예시
# ============================================================

# 테스트 질의
test_query = "직장인이 세금을 줄이려면 어떻게 해야 하나요?"

# [1단계] 키워드 사전 변환 (dict_chain)
# 입력: "직장인이 세금을 줄이려면 어떻게 해야 하나요?"
# 출력: "거주자인 근로소득자가 소득세를 절감하기 위한 방법은 무엇인가요?"
converted_query = dict_chain.invoke({
    "dictionary": legal_dictionary,
    "question": test_query
})
print(f"[1단계] 변환된 질의: {converted_query}")

# [2단계] 다중 관점 검색 (MultiQueryRetriever)
# 내부적으로 3개 변형 질의 생성 후 각각 검색 → 결과 합산 → 중복 제거
# 변형 질의 예시:
#   - "근로소득자의 소득세 절세 전략은?"
#   - "근로소득에 대한 소득공제 항목과 방법"
#   - "연말정산을 통한 세액공제 활용 방법"
docs = multi_retriever.invoke(converted_query)
print(f"[2단계] 검색된 문서: {len(docs)}개")
for i, doc in enumerate(docs):
    article = doc.metadata.get("article", "N/A")
    print(f"  - 문서 {i+1}: {article} ({len(doc.page_content)}자)")

# [3단계] 최종 답변 생성
# 입력: 변환된 질의 + 검색된 문서(포맷팅)
# 출력: 법적 근거가 포함된 상세 답변
response = legal_rag_chain.invoke({
    "dictionary": legal_dictionary,
    "question": test_query
})
print(f"\n[3단계] 최종 답변:\n{response}")


      1) 예상 출력 예시:

[1단계] 변환된 질의: 거주자인 근로소득자가 소득세를 절감하기 위한 방법은 무엇인가요?
[2단계] 검색된 문서: 6개
  - 문서 1: 제47조 (1,245자)
  - 문서 2: 제50조 (987자)
  - 문서 3: 제52조 (1,102자)
  - 문서 4: 제59조의4 (856자)
  - 문서 5: 제73조 (634자)
  - 문서 6: 제140조 (723자)

[3단계] 최종 답변:
근로소득자(직장인)가 소득세를 절감하기 위한 주요 방법은 다음과 같습니다.

1. **인적공제 활용** (소득세법 제50조)
   부양가족 1인당 150만원의 기본공제를 받을 수 있습니다.
   "거주자의 기본공제대상자에 해당하는 사람에 대해서는 1명당 연 150만원을 공제한다."

2. **특별소득공제** (소득세법 제52조)
   건강보험료, 주택자금 등의 특별공제를 활용할 수 있습니다.

3. **세액공제** (소득세법 제59조의4)
   의료비, 교육비, 기부금 등에 대한 세액공제를 적용받을 수 있습니다.

[참조 조항]
- 소득세법 제47조 (종합소득공제)
- 소득세법 제50조 (기본공제)
- 소득세법 제52조 (특별소득공제)
- 소득세법 제59조의4 (특별세액공제)


      3.7.1. 동작 흐름 요약

입력 → dict_chain(사전변환) → MultiQueryRetriever(3개 변형질의×k=6 검색)
     → format_docs(조항 포맷팅) → legal_prompt(규칙 적용) → gpt-4o → 응답

API 호출 내역:
  [1] gpt-4o-mini: 키워드 사전 변환 (1회)
  [2] gpt-4o-mini: 다중 관점 질의 생성 (1회)
  [3] text-embedding-3-large: 질의 임베딩 (3~4회)
  [4] gpt-4o: 최종 답변 생성 (1회)
  총: LLM 3회 + 임베딩 3~4회 ≈ $0.003/질의


   3.8. A.8 결과 검증 및 품질 평가

      - 법률 RAG의 답변 품질을 체계적으로 검증하기 위한 자동 평가 함수이다.

import re
from typing import Optional

def evaluate_legal_response(
    question: str,
    response: str,
    expected_articles: list[str] = None,
    context_docs: list[Document] = None
) -> dict:
    """법률 RAG 답변의 품질을 정량적으로 평가

    평가 항목:
    1. 조항 인용 여부: 답변에 법 조항 번호가 포함되었는지
    2. 참조 조항 섹션: [참조 조항] 섹션이 존재하는지
    3. 기대 조항 포함률: 예상 정답 조항 중 실제 인용된 비율
    4. 환각 감지: 검색 컨텍스트에 없는 조항 번호를 인용했는지
    5. 수치 포함 여부: 세율, 금액 등 수치가 포함되었는지

    Args:
        question: 원본 질의
        response: RAG 파이프라인의 응답 텍스트
        expected_articles: 정답으로 기대되는 조항 번호 리스트 (선택)
        context_docs: 검색에 사용된 Document 리스트 (선택, 환각 검증용)

    Returns:
        dict: 평가 결과 딕셔너리
    """
    result = {
        "question": question,
        "has_article_reference": False,   # 조항 번호 포함 여부
        "has_reference_section": False,   # [참조 조항] 섹션 여부
        "cited_articles": [],             # 인용된 조항 번호 목록
        "article_recall": 0.0,           # 기대 조항 포함률
        "hallucinated_articles": [],      # 환각으로 의심되는 조항
        "has_numeric_data": False,        # 수치 데이터 포함 여부
        "response_length": len(response), # 응답 길이 (문자 수)
        "score": 0.0                      # 종합 점수 (0~1)
    }

    # 1. 조항 인용 검사
    cited = re.findall(r'제\d+조(?:의\d+)?', response)
    result["cited_articles"] = list(set(cited))
    result["has_article_reference"] = len(cited) > 0

    # 2. [참조 조항] 섹션 검사
    result["has_reference_section"] = "[참조 조항]" in response or "참조 조항" in response

    # 3. 기대 조항 포함률 (Recall)
    if expected_articles:
        found = sum(1 for a in expected_articles if a in response)
        result["article_recall"] = found / len(expected_articles)

    # 4. 환각 검증: 컨텍스트에 없는 조항 인용 감지
    if context_docs and cited:
        context_articles = set()
        for doc in context_docs:
            context_articles.update(re.findall(r'제\d+조(?:의\d+)?', doc.page_content))
        result["hallucinated_articles"] = [a for a in set(cited) if a not in context_articles]

    # 5. 수치 데이터 포함 여부 (세율, 금액 등)
    result["has_numeric_data"] = bool(re.search(r'\d+[%만원]', response))

    # 종합 점수 계산 (가중 평균)
    score = 0.0
    score += 0.25 if result["has_article_reference"] else 0
    score += 0.15 if result["has_reference_section"] else 0
    score += 0.30 * result["article_recall"]  # 최대 0.3
    score += 0.15 if not result["hallucinated_articles"] else 0
    score += 0.15 if result["has_numeric_data"] else 0
    result["score"] = round(score, 2)

    return result

# 사용 예시
eval_result = evaluate_legal_response(
    question="직장인 세금 줄이는 법",
    response=response,  # legal_rag_chain의 출력
    expected_articles=["제47조", "제50조", "제52조", "제59조의4"]
)
print(f"평가 점수: {eval_result['score']}")
print(f"인용 조항: {eval_result['cited_articles']}")
print(f"조항 포함률: {eval_result['article_recall']:.0%}")
print(f"환각 조항: {eval_result['hallucinated_articles']}")


   3.9. A.9 고도화 제안

      - 법률 RAG 시스템의 품질과 기능을 한 단계 높이기 위한 고도화 방안을 우선순위와 구현 난이도로 정리한다.

 

      3.9.1. 1순위는 조항 간 상호 참조 그래프 구축이다.

         - 구현 난이도는 중간 수준이며, 적용 시 검색 Recall이 약 15% 향상된다. "제50조에서 규정하는 바에 따라"와 같은 참조 패턴을 정규표현식으로 파싱하여 조항 간 연결 관계를 NetworkX 그래프로 구성한다. 이후 검색된 조항이 참조하는 다른 조항들을 검색 결과에 자동으로 포함시킴으로써, 관련 조항이 분산된 경우에도 완전한 법적 근거를 제공할 수 있다.

 

      3.9.2. 2순위는 판례 및 유권해석 통합이다.

         - 구현 난이도는 중간이며, 답변의 법적 신뢰도가 크게 향상된다. 법률 조항만 인용하는 것에서 나아가, 각 조항의 메타데이터에 관련 판례 번호를 연결해두고 답변 생성 시 조항 인용과 함께 관련 판례도 제시하는 구조다. 실무에서 법 조항의 실제 적용 방식을 이해하려면 판례 해석이 필수이므로, 이 기능은 전문 사용자의 만족도를 크게 높인다.

 

      3.9.3. 3순위는 연도별 법률 변경 추적이다.

         - 구현 난이도가 높아 도입 시 충분한 준비가 필요하지만, 특정 시점 기준의 정확한 답변이 가능해진다. 법률은 매년 개정되므로 2022년 세율과 2024년 세율이 다를 수 있다. 법률 개정 이력을 메타데이터에 기록하고, 사용자가 "2024년 기준으로 알려줘"처럼 시점을 지정했을 때 해당 버전의 조항만 검색에 사용하는 방식으로 시점별 정확도를 보장한다.

 

      3.9.4. 4순위는 dict_chain 중복 호출 제거다.

         - 구현 난이도가 낮아 즉시 적용 가능하며, 응답 시간을 약 30% 단축할 수 있다. 현재 파이프라인에서는 키워드 변환 체인(dict_chain)이 질의 변환과 컨텍스트 검색 양쪽에서 각각 LLM을 호출하는 중복이 발생한다. `RunnablePassthrough.assign(converted=dict_chain)` 패턴을 사용하면 변환을 한 번만 실행하고 그 결과를 두 곳에서 재사용하여 불필요한 API 호출을 제거할 수 있다.

 

      3.9.5. 5순위는 사용자 피드백 루프 구축이다.

         - 구현 난이도는 중간이며, 장기적으로 시스템 품질이 데이터에 기반하여 지속 향상된다. 답변 화면에 만족도 평가 기능을 추가하고, 낮은 평점의 질의 패턴을 주기적으로 분석하여 키워드 사전을 자동으로 확장하고 프롬프트를 정기적으로 튜닝하는 데이터 중심 개선 사이클을 구축한다.

 

      3.9.6. 6순위는 스트리밍 응답이다. 

         - 구현 난이도가 낮으며, 체감 응답 속도가 크게 향상된다. `legal_rag_chain.stream()`으로 토큰을 생성되는 즉시 전달하고, SSE(Server-Sent Events)로 브라우저에 실시간 스트리밍하면 실제 처리 시간은 동일해도 사용자가 느끼는 대기 시간이 현저히 줄어들어 법률 상담 서비스의 UX가 개선된다.

# [고도화 예시] 조항 간 상호 참조 자동 포함
def expand_with_references(docs: list[Document], all_docs: dict) -> list[Document]:
    """검색된 조항이 참조하는 다른 조항을 자동으로 포함

    "제50조에서 규정하는 바에 따라..." 같은 참조 패턴을 감지하여
    참조 대상 조항을 검색 결과에 추가한다.

    Args:
        docs: 원본 검색 결과 Document 리스트
        all_docs: 전체 조항 딕셔너리 {"제47조": Document, ...}

    Returns:
        참조 조항이 추가된 확장 Document 리스트
    """
    expanded = list(docs)
    existing_articles = {doc.metadata.get("article") for doc in docs}

    for doc in docs:
        # "제N조에서", "제N조에 따라", "제N조를 준용" 등의 참조 패턴 감지
        references = re.findall(r'(제\d+조(?:의\d+)?)[에를의]', doc.page_content)
        for ref_article in references:
            # 아직 포함되지 않은 참조 조항만 추가
            if ref_article not in existing_articles and ref_article in all_docs:
                expanded.append(all_docs[ref_article])
                existing_articles.add(ref_article)

    return expanded

 

      - 실무 권장: 고도화는 우선순위 1~2번(상호 참조 그래프, dict_chain 중복 제거)부터 적용하라. 이 두 가지만으로도 검색 품질과 응답 속도가 눈에 띄게 개선된다. 나머지 항목은 시스템이 안정화된 후 점진적으로 적용한다.

 

4. 시나리오 B: 고객센터 FAQ 챗봇

   - 고객센터 FAQ 챗봇은 법률 시스템과 완전히 다른 최적화가 필요하다. 문서가 이미 질문-답변 쌍으로 구조화되어 있고, 사용자의 질문은 짧고 비정형적이며, 응답은 간결하고 친절해야 한다. 또한 대량의 고객 트래픽을 처리해야 하므로 비용과 속도도 중요한 고려 사항이다.

   1) FAQ 도메인 RAG의 핵심 과제

과제 설명 예시
짧은 비정형 질의 고객의 질문이 매우 짧고 불완전 "안 돼요", "고장났어요", "환불"
의도 파악 같은 표현이 다른 의도를 가질 수 있음 "변경" → 주소 변경? 배송지 변경? 상품 변경?
카테고리 매칭 FAQ가 카테고리별로 분류되어 있어 정확한 매칭 필요 "결제" 카테고리 vs "배송" 카테고리
비용 효율 대량 트래픽 처리에 비용 최적화 필수 하루 수천~수만 질의 처리
친절한 톤 고객 만족도를 위한 친근한 응답 필요 기계적 답변이 아닌 상담사 톤


   2) 요구 명세 (Requirements Specification)
      - 시나리오 B는 대량 트래픽을 저비용으로 처리해야 하는 것이 핵심이다. 고객 만족도와 비용 효율 사이의 균형이 모든 설계 결정의 기준이 된다.

항목 요구사항 비고
FAQ 규모 300~500개 Q&A 쌍 카테고리 10~15개로 분류
데이터 형식 JSON (질문, 답변, 카테고리, 태그, 조회수) 주 1회 업데이트
동시 사용자 최대 500명 (B2C 고객 대상) 피크: 점심시간, 퇴근 후
일일 질의량 5,000~10,000건/일 프로모션 기간 3배 증가
응답 시간 평균 2초 이내, 최대 5초 고객 이탈 방지를 위한 빠른 응답 필수
검색 정확도 Retrieval Precision@3 ≥ 80% 반환된 3개 FAQ 중 정답 포함 비율
답변 만족도 CSAT ≥ 4.0/5.0 고객 만족도 설문 기반
카테고리 정확도 ≥ 90% 올바른 카테고리의 FAQ를 반환하는 비율
에스컬레이션 비율 ≤ 15% "담당 부서로 연결" 응답 비율
가용성 99.9% (월간 가동시간) 고객 서비스 중단 불가
월간 비용 한도 $50 이내 (OpenAI API 기준) 대량 트래픽에 비용 효율 필수
FAQ 갱신 반영 24시간 이내 신규 FAQ 등록 후 검색 가능까지


      - 설계 의도: FAQ 챗봇은 비용 효율과 응답 속도가 최우선이다. FAQ 데이터가 이미 정답을 포함하고 있으므로 LLM의 역할은 "적절한 FAQ를 찾아 자연스럽게 전달"하는 것에 한정된다. 따라서 `gpt-4o-mini`와 `text-embedding-3-small` 조합으로 비용을 최소화하고, 하이브리드 검색으로 짧은 질의의 매칭률을 보완한다. 일일 10,000건 처리 시 월간 API 비용이 $50을 초과하지 않도록 LLM 호출 횟수를 1~2회로 제한한다.

 

   4.1. B.1 문서 로딩 최적화

      - 문제: FAQ 데이터는 질문-답변 쌍으로 구성되며, 카테고리와 태그가 있다.

 

      - FAQ 데이터는 일반적으로 JSON, CSV, 또는 데이터베이스 형태로 구조화되어 있다. 각 항목에는 질문, 답변, 카테고리, 태그, 조회수 등의 정보가 포함된다. 이 구조적 정보를 모두 메타데이터로 보존하면 검색 시 카테고리 필터링, 우선순위 정렬 등에 활용할 수 있다.

 

      4.1.1. FAQ 데이터 구조 예시

[
    {
        "question": "배송 기간은 얼마나 걸리나요?",
        "answer": "일반 배송은 2-3일, 빠른 배송은 당일~익일 배송됩니다.",
        "category": "배송",
        "tags": ["배송", "기간", "소요시간"],
        "priority": "high",
        "updated_at": "2024-01-15",
        "view_count": 1520
    }
]

 

          4.1.1.1. 커스텀 전략: 구조화된 FAQ 로더

import json
import logging
from langchain_core.documents import Document

logger = logging.getLogger(__name__)

class FAQDocumentLoader:
    """FAQ 데이터 전용 로더 - Q&A 쌍을 개별 Document로 변환

    JSON 형식의 FAQ 데이터를 로딩하여, 각 Q&A 쌍을 하나의 Document로 변환한다.
    질문과 답변을 하나의 page_content로 결합하고, 카테고리, 태그, 조회수 등
    구조적 정보를 메타데이터로 보존한다.

    메타데이터에 'question_only'와 'answer_only'를 별도로 저장하여,
    검색 시에는 질문+답변 전체를 매칭하고, 응답 시에는 답변만 반환하는
    유연한 활용이 가능하다.
    """

    # FAQ JSON 항목의 필수 필드
    REQUIRED_FIELDS = {"question", "answer"}

    def __init__(self, file_path: str):
        # FAQ JSON 파일 경로
        self.file_path = file_path

    def load(self) -> list[Document]:
        """FAQ JSON 파일을 Document 리스트로 변환

        Returns:
            list[Document]: FAQ Document 리스트. 로딩 실패 시 빈 리스트 반환.

        Raises:
            FileNotFoundError: 파일이 존재하지 않을 때
        """
        import os
        if not os.path.exists(self.file_path):
            raise FileNotFoundError(f"FAQ 데이터 파일이 존재하지 않습니다: {self.file_path}")

        try:
            # JSON 파일 로딩 (UTF-8 인코딩 필수 - 한국어 지원)
            with open(self.file_path, "r", encoding="utf-8") as f:
                faq_data = json.load(f)
        except json.JSONDecodeError as e:
            logger.error(f"JSON 파싱 실패 ({self.file_path}): {e}")
            return []

        if not isinstance(faq_data, list):
            logger.error(f"FAQ 데이터가 리스트 형식이 아닙니다: {type(faq_data)}")
            return []

        documents = []
        skipped = 0

        for idx, item in enumerate(faq_data):
            # 필수 필드 검증
            missing = self.REQUIRED_FIELDS - set(item.keys())
            if missing:
                logger.warning(f"FAQ #{idx}: 필수 필드 누락 {missing}, 건너뜀")
                skipped += 1
                continue

            # 질문과 답변을 하나의 문서로 결합
            # "질문: ..." "답변: ..." 형식으로 구성하여
            # 벡터 검색 시 질문과 답변 모두에서 매칭 가능
            content = f"질문: {item['question']}\n답변: {item['answer']}"

            documents.append(Document(
                page_content=content,
                metadata={
                    "source": self.file_path,                       # 데이터 출처
                    "faq_id": idx,                                   # FAQ 항목 고유 ID
                    "category": item.get("category", "general"),    # FAQ 카테고리
                    "tags": item.get("tags", []),                   # 검색용 태그
                    "question_only": item["question"],              # 질문만 (검색 매칭용)
                    "answer_only": item["answer"],                  # 답변만 (응답 반환용)
                    "priority": item.get("priority", "normal"),     # 우선순위 (high/normal/low)
                    "last_updated": item.get("updated_at", ""),     # 마지막 업데이트 날짜
                    "view_count": item.get("view_count", 0)         # 조회수 (인기 FAQ 판별)
                }
            ))

        logger.info(f"FAQ 로딩 완료: {len(documents)}개 로드, {skipped}개 건너뜀")
        return documents


            - 파라미터 정의 기준: `question_only`와 `answer_only`를 메타데이터에 별도 저장하는 이유는 용도별로 다르게 활용하기 위함이다. `page_content`에는 질문+답변 전체를 넣어 벡터 검색의 매칭 범위를 넓히고, `answer_only`는 응답 구성 시 정확한 FAQ 답변만 추출하는 데 사용한다. `view_count`는 동일 유사도의 FAQ가 여러 개일 때 인기 순으로 우선순위를 매기는 데 활용할 수 있다.

 

   4.2. B.2 청킹 최적화

      - 문제: FAQ는 이미 질문-답변 단위로 구성되어 있어 추가 분할이 불필요하다.

   

      - FAQ의 핵심 특성은 각 Q&A 쌍이 이미 완전한 의미 단위라는 점이다. 일반 문서처럼 청킹을 수행하면 오히려 질문과 답변이 분리되어 검색 품질이 저하된다. 따라서 FAQ에서는 분할 대신 메타데이터 강화에 집중한다.

 

      4.2.1. FAQ 청킹 전략 비교

전략 적합 여부 이유
분할 없음 (원본 유지) 적합 Q&A 쌍이 이미 완전한 의미 단위
RecursiveCharacterTextSplitter 부적합 Q&A가 분리되어 의미 손실
메타데이터 강화 매우 적합 유사 질문/키워드 추가로 검색 범위 확장

 

         4.2.1.1. 커스텀 전략: 분할하지 않고 메타데이터 강화

            - FAQ 자체를 분할하는 대신, 각 FAQ에 유사 질문과 키워드를 추가하여 검색 매칭률을 높인다. 이 과정은 인덱싱 시 한 번만 수행되므로, 검색 시점의 성능에 영향을 주지 않는다.

class FAQEnricher:
    """FAQ 문서에 검색 강화 메타데이터를 추가

    각 FAQ에 대해 LLM을 활용하여 유사 질문과 핵심 키워드를 생성하고,
    이를 page_content와 메타데이터에 추가한다.

    예: "배송 기간은 얼마나 걸리나요?"
    → 유사 질문: "택배 언제 오나요?", "주문하면 며칠 걸려요?", "배송 소요일"
    → 키워드: "배송", "기간", "소요시간", "택배", "배달"

    이렇게 강화된 FAQ는 다양한 표현의 사용자 질의에 대해
    더 높은 매칭률을 보인다.
    """

    def __init__(self, llm):
        # 유사 질문/키워드 생성에 사용할 LLM
        # gpt-4o-mini 권장 (비용 최적화 + 충분한 품질)
        self.llm = llm

    def enrich(self, documents: list[Document]) -> list[Document]:
        enriched = []
        for doc in documents:
            # LLM으로 동의어/유사 질문 생성
            # 사용자가 같은 의도를 다른 표현으로 물을 수 있으므로
            # 다양한 변형 질문을 미리 생성하여 검색 범위 확장
            similar_questions = self._generate_similar_questions(
                doc.metadata.get("question_only", "")
            )

            # 핵심 키워드 추출
            # BM25 키워드 검색에서의 매칭률을 높이기 위해
            # FAQ의 핵심 키워드를 명시적으로 추출
            keywords = self._extract_keywords(doc.page_content)

            # 강화된 page_content 구성
            # 원본 Q&A에 유사 질문과 키워드를 추가
            enriched_content = (
                f"{doc.page_content}\n\n"
                f"유사 질문: {'; '.join(similar_questions)}\n"
                f"키워드: {', '.join(keywords)}"
            )

            enriched.append(Document(
                page_content=enriched_content,
                metadata={
                    **doc.metadata,                             # 원본 메타데이터 보존
                    "similar_questions": similar_questions,      # 유사 질문 목록
                    "keywords": keywords,                       # 핵심 키워드 목록
                    "enriched": True                            # 강화 완료 플래그
                }
            ))

        return enriched

    def _generate_similar_questions(self, question: str) -> list[str]:
        """원본 질문과 동일한 의도의 다른 표현을 3개 생성"""
        response = self.llm.invoke(
            f"다음 질문과 동일한 의도를 가진 다른 표현 3개를 생성하세요.\n"
            f"질문: {question}\n"
            f"한 줄에 하나씩 작성하세요."
        )
        return [q.strip() for q in response.content.strip().split("\n") if q.strip()]

    def _extract_keywords(self, text: str) -> list[str]:
        """텍스트에서 핵심 키워드를 5개 이내로 추출"""
        response = self.llm.invoke(
            f"다음 텍스트의 핵심 키워드를 5개 이내로 추출하세요.\n"
            f"쉼표로 구분하세요.\n\n텍스트: {text}"
        )
        return [k.strip() for k in response.content.strip().split(",")]

 

            - 실무 권장: FAQ 강화(enrich)는 인덱싱 단계에서 한 번만 수행하므로, LLM 호출 비용은 일회성이다. FAQ가 100개라면 200회의 LLM 호출(유사 질문 100회 + 키워드 100회)로, gpt-4o-mini 기준 약 $0.1 미만의 비용이 발생한다. 이 투자로 검색 매칭률이 크게 향상되므로 비용 대비 효과가 매우 높다.

 

   4.3. B.3 임베딩 최적화

      - 권장 설정:
         - 모델: `text-embedding-3-small` (비용 최적화, FAQ는 짧은 텍스트)
         - 차원 축소 적용 가능

 

      - FAQ 텍스트는 일반적으로 짧다(50~200자). 짧은 텍스트에서는 고차원 임베딩의 이점이 줄어들므로, 비용 효율적인 `text-embedding-3-small`과 차원 축소를 적용해도 품질 저하가 미미하다.

 

      4.3.1 FAQ vs 법률 임베딩 설정 비교

항목 시나리오 A (법률) 시나리오 B (FAQ) 이유
모델 text-embedding-3-large text-embedding-3-small FAQ는 짧은 텍스트로 소형 모델 충분
차원 3072 (최대) 512 (축소) 짧은 텍스트에서 고차원 이점 감소
비용/1M 토큰 $0.13 $0.02 FAQ의 대량 트래픽에 비용 최적화
정밀도 최고 충분 FAQ는 미세한 의미 구분 불필요
from langchain_openai import OpenAIEmbeddings

# FAQ 도메인 임베딩 설정
# text-embedding-3-small은 비용이 text-embedding-3-large의 1/5 수준
# dimensions=512로 차원 축소하여 벡터 DB 저장/검색 비용도 절감
embedding = OpenAIEmbeddings(
    model="text-embedding-3-small",  # 비용 최적화 모델
    dimensions=512  # 차원 축소: FAQ는 짧은 텍스트이므로 512차원으로 충분
    # 기본 1536 → 512로 축소 시 저장 공간 약 67% 절감
    # OpenAI의 Matryoshka 기술로 저차원에서도 높은 품질 유지


         - 파라미터 정의 기준: `dimensions=512`는 FAQ의 특성(짧은 텍스트, 명확한 의도)을 고려한 값이다. OpenAI의 `text-embedding-3-small`은 Matryoshka Representation Learning을 적용하여 차원 축소 시에도 의미 보존율이 높다. 512차원은 FAQ의 질문-답변 매칭에 충분한 정밀도를 제공하면서 저장/검색 비용을 크게 절감한다. 다만 256 이하로 줄이면 "배송 변경"과 "주소 변경" 같은 유사 FAQ 간 구분이 어려워질 수 있으므로, 512가 하한선이다.

 

   4.4. B.4 검색 최적화

      - 문제: 고객의 질문이 비정형적이고, "안 돼요", "고장났어요" 같은 짧은 표현이 많다.

 

      - 고객센터 질의의 특성상, 벡터 검색만으로는 부족하다. "안 돼요"라는 2글자 질의를 벡터로 임베딩하면 의미 정보가 너무 적어 정확한 FAQ를 찾기 어렵다. 이를 해결하기 위해 세 가지 전략을 조합한다:

         1) 하이브리드 검색: 벡터 검색(의미 기반) + BM25 검색(키워드 기반)을 앙상블

         2) 카테고리 필터링: 사용자가 카테고리를 지정한 경우 해당 카테고리 내에서만 검색

         3) 짧은 질의 확장: 10자 미만의 짧은 질의를 LLM으로 구체적인 문장으로 확장

[FAQ 검색 최적화 흐름]

고객 질의: "안 돼요"
         ↓
[길이 체크: 10자 미만?]
         → 예: LLM으로 질의 확장
         → "결제/로그인/서비스가 안 됩니다. 어떻게 해야 하나요?"
         ↓
[하이브리드 검색 (EnsembleRetriever)]
         ├── BM25 검색 (키워드 매칭, 가중치 0.5)
         └── 벡터 검색 (의미 매칭, 가중치 0.5)
         ↓
[결과 합산 (Reciprocal Rank Fusion)]
         → 상위 3개 FAQ 반환

 

      4.4.1. 커스텀 전략: 하이브리드 검색 + 카테고리 필터링

from langchain.retrievers import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever

class FAQRetriever:
    """FAQ 전용 하이브리드 검색기

    벡터 검색과 BM25 키워드 검색을 앙상블로 결합하여
    짧고 비정형적인 고객 질의에 대해 높은 매칭률을 달성한다.

    주요 기능:
    1. 하이브리드 검색: 의미 기반 + 키워드 기반 결합
    2. 카테고리 필터링: 지정된 카테고리 내에서만 검색
    3. 짧은 질의 확장: 10자 미만 질의를 LLM으로 구체화
    """

    def __init__(self, vector_db, documents, llm):
        self.vector_db = vector_db  # 카테고리 필터 검색 시 필요

        # MMR(Maximal Marginal Relevance) 검색으로 다양성 확보
        # FAQ에서는 유사한 질문이 많으므로 중복 결과를 줄여야 함
        self.vector_retriever = vector_db.as_retriever(
            search_type="mmr",
            search_kwargs={
                "k": 3,             # 최종 반환 FAQ 수 (고객에게 3개 이상은 과도)
                "fetch_k": 10,      # 1차 후보 수 (다양성 확보를 위해 넓게)
                "lambda_mult": 0.7  # 0.7: 유사도를 다양성보다 약간 우선
            }
        )

        # BM25 키워드 검색기: FAQ의 키워드 직접 매칭에 강점
        # "환불"이라는 단어가 정확히 포함된 FAQ를 놓치지 않음
        self.bm25_retriever = BM25Retriever.from_documents(documents, k=3)

        # 앙상블 검색기: 벡터 + BM25 결합
        # weights=[0.5, 0.5]: FAQ는 의미 매칭과 키워드 매칭이 동등하게 중요
        # 내부적으로 Reciprocal Rank Fusion(RRF)으로 결과 합산
        self.ensemble = EnsembleRetriever(
            retrievers=[self.bm25_retriever, self.vector_retriever],
            weights=[0.5, 0.5]
        )

        self.llm = llm  # 질의 확장에 사용할 LLM

    def retrieve(self, query: str, category: str = None) -> list[Document]:
        """FAQ 검색 수행

        Args:
            query: 고객 질의 문자열
            category: FAQ 카테고리 필터 (선택). "배송", "결제" 등

        Returns:
            관련 FAQ Document 리스트 (최대 3개)
        """
        # 카테고리가 지정된 경우 벡터 검색에 필터 적용
        # 예: category="배송"이면 배송 관련 FAQ 내에서만 벡터 검색
        if category:
            self.vector_retriever = self.vector_db.as_retriever(
                search_kwargs={
                    "k": 3,
                    "filter": {"category": category}  # 메타데이터 기반 필터
                }
            )

        # 짧은 질의 확장: 10자 미만의 질의는 의미 정보가 부족하므로
        # LLM을 사용하여 구체적인 문장으로 확장
        if len(query) < 10:
            expanded = self._expand_short_query(query)
            return self.ensemble.invoke(expanded)

        return self.ensemble.invoke(query)

    def _expand_short_query(self, query: str) -> str:
        """짧은 고객 질문을 구체적인 문장으로 확장

        예: "안 돼요" → "서비스/결제/로그인 등이 정상적으로 작동하지 않습니다"
        """
        response = self.llm.invoke(
            f"다음 짧은 고객 질문을 더 구체적이고 검색에 적합한 문장으로 확장하세요.\n"
            f"원본: {query}\n확장된 질문:"
        )
        return response.content.strip()

 

         - 파라미터 정의 기준: `weights=[0.5, 0.5]`는 FAQ에서 의미 검색과 키워드 검색의 중요도가 동등함을 반영한다. "환불 방법"처럼 키워드가 명확한 질의에는 BM25가, "주문한 것 받고 싶지 않아요"처럼 의미적 표현인 질의에는 벡터 검색이 각각 강점을 보이므로, 동등한 가중치가 최적이다. `lambda_mult=0.7`은 FAQ 검색에서 관련성이 다양성보다 중요함을 반영한다.

 

         - 실무 권장: 짧은 질의 확장(`_expand_short_query`)은 추가 LLM 호출이 발생하므로, 응답 속도가 중요한 경우 확장 없이 BM25 가중치를 높이는(예: `weights=[0.7, 0.3]`) 대안을 고려하라. 또는 질의 확장 결과를 캐싱하여 동일한 짧은 질의의 반복 호출 시 LLM 호출을 생략할 수도 있다.

 

   4.5. B.5 생성 최적화

      - 커스텀 프롬프트: 간결하고 친절한 답변

 

      - 고객센터 챗봇의 프롬프트는 법률 프롬프트와 정반대의 특성을 가진다. 간결함, 친절함, 이해하기 쉬운 표현이 핵심이다.

항목 법률 프롬프트 (시나리오 A) FAQ 프롬프트 (시나리오 B)
답변 길이 장문 (법적 근거 포함) 3문장 이내
용어 수준 법률 전문 용어 쉬운 일상 용어
전문적, 객관적 친절, 공감적
불확실 처리 "정보를 찾을 수 없습니다" "담당 부서로 연결해 드리겠습니다"
추가 안내 참조 조항 목록 고객센터 번호/링크
faq_prompt = ChatPromptTemplate.from_template("""
당신은 친절한 고객센터 상담사입니다. 다음 규칙을 준수하세요:

1. 답변은 3문장 이내로 간결하게 작성하세요
2. 전문 용어 대신 쉬운 표현을 사용하세요
3. 추가 도움이 필요한 경우 안내 번호를 제공하세요
4. 컨텍스트에 답이 없으면 "담당 부서로 연결해 드리겠습니다"라고 답하세요
5. 이모지는 사용하지 마세요

참고할 FAQ:
{context}

고객 질문: {question}

답변:
""")

 

      - 파라미터 정의 기준: "3문장 이내" 규칙은 고객센터 UX 연구에서 짧은 답변이 고객 만족도를 높인다는 결과에 기반한다. 규칙 4("담당 부서로 연결")는 FAQ에 없는 질문에 대해 "모르겠습니다"보다 능동적인 대안을 제시하여 고객 경험을 개선한다.

 

   4.6. B.6 전체 파이프라인 조립

[FAQ RAG 파이프라인 전체 흐름]

고객 질의: "배송 언제 와요?"
         ↓
[RunnablePassthrough: 원본 질의 유지]
         ↓
[FAQRetriever.ensemble: 하이브리드 검색]
         ├── BM25: "배송", "언제" 키워드 매칭
         └── 벡터: "배송 기간 문의" 의미 매칭
         ↓
[format_faq_docs: FAQ 형식 포맷팅]
         → "[FAQ 1] 카테고리: 배송\nQ: 배송 기간은...\nA: 일반 배송은..."
         ↓
[faq_prompt: 친절한 답변 프롬프트]
         ↓
[ChatOpenAI(gpt-4o-mini): 비용 효율 LLM]
         ↓
[StrOutputParser: 최종 응답]
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_community.vectorstores import Chroma

# [전제 조건] B.1~B.4 단계의 결과물
# documents = FAQDocumentLoader("faq.json").load()
# enriched_docs = FAQEnricher(llm=ChatOpenAI(model="gpt-4o-mini")).enrich(documents)
# embedding = OpenAIEmbeddings(model="text-embedding-3-small", dimensions=512)
# db = Chroma.from_documents(enriched_docs, embedding=embedding)
# faq_retriever = FAQRetriever(vector_db=db, documents=enriched_docs, llm=ChatOpenAI(model="gpt-4o-mini"))

# FAQ 문서 포맷팅 함수
# 검색된 FAQ를 구조화된 형식으로 정리하여 LLM에 전달
# 카테고리, 질문, 답변을 명확히 분리하여 LLM이 정확히 참조하도록 유도
def format_faq_docs(docs):
    formatted = []
    for i, doc in enumerate(docs, 1):
        q = doc.metadata.get("question_only", "")   # 원본 질문
        a = doc.metadata.get("answer_only", "")      # 원본 답변
        cat = doc.metadata.get("category", "")       # 카테고리
        formatted.append(f"[FAQ {i}] 카테고리: {cat}\nQ: {q}\nA: {a}")
    return "\n\n".join(formatted)

# 전체 체인 조립 (LCEL)
faq_rag_chain = (
    {
        "question": RunnablePassthrough(),          # 원본 고객 질의 그대로 전달
        "context": faq_retriever.ensemble | format_faq_docs,  # 하이브리드 검색 → 포맷팅
    }
    | faq_prompt                                    # 친절한 답변 프롬프트
    | ChatOpenAI(model="gpt-4o-mini", temperature=0.1)  # 비용 효율 모델
    | StrOutputParser()                             # 문자열 출력
)

 

      - 파라미터 정의 기준: `model="gpt-4o-mini"`는 FAQ 응답의 비용 효율을 위한 선택이다. FAQ는 이미 정답이 정해져 있으므로 LLM의 역할은 적절한 FAQ를 선택하고 자연스럽게 전달하는 것이며, 복잡한 추론이 필요하지 않다. `temperature=0.1`은 거의 결정적이면서도 약간의 자연스러움을 부여하여 기계적이지 않은 답변을 생성한다.

 

   4.7. B.7 실행 예시 및 동작 흐름

      - FAQ RAG 파이프라인의 실행 과정을 구체적인 입출력 예시와 함께 추적한다.

# ============================================================
# FAQ RAG 파이프라인 실행 예시
# ============================================================

# 테스트 질의 모음 (다양한 패턴 포함)
test_queries = [
    "배송 언제 와요?",         # 일반 질의
    "안 돼요",                  # 짧은 비정형 질의 (10자 미만 → 확장 필요)
    "환불하고 싶어요",          # 명확한 의도
]

for query in test_queries:
    print(f"\n{'='*50}")
    print(f"[질의] {query} (길이: {len(query)}자)")

    # [1단계] 짧은 질의 확장 (10자 미만인 경우만)
    if len(query) < 10:
        expanded = faq_retriever._expand_short_query(query)
        print(f"[1단계] 질의 확장: '{query}' → '{expanded}'")
        search_query = expanded
    else:
        print(f"[1단계] 질의 확장: 불필요 (10자 이상)")
        search_query = query

    # [2단계] 하이브리드 검색 (BM25 + 벡터)
    docs = faq_retriever.ensemble.invoke(search_query)
    print(f"[2단계] 검색된 FAQ: {len(docs)}개")
    for i, doc in enumerate(docs):
        cat = doc.metadata.get("category", "N/A")
        q = doc.metadata.get("question_only", "")[:40]
        print(f"  - FAQ {i+1}: [{cat}] {q}...")

    # [3단계] 최종 답변 생성
    response = faq_rag_chain.invoke(query)
    print(f"[3단계] 응답: {response}")

 

      - 예상 출력 예시:

==================================================
[질의] 배송 언제 와요? (길이: 9자)
[1단계] 질의 확장: '배송 언제 와요?' → '주문한 상품의 배송 기간은 얼마나 소요되나요?'
[2단계] 검색된 FAQ: 3개
  - FAQ 1: [배송] 배송 기간은 얼마나 걸리나요?...
  - FAQ 2: [배송] 빠른 배송은 어떻게 신청하나요?...
  - FAQ 3: [배송] 배송 추적은 어디서 확인하나요?...
[3단계] 응답: 일반 배송은 2-3일, 빠른 배송은 당일~익일 배송됩니다.
배송 추적은 마이페이지 > 주문내역에서 확인하실 수 있어요.
추가 도움이 필요하시면 고객센터 1588-0000으로 연락해 주세요.

==================================================
[질의] 안 돼요 (길이: 4자)
[1단계] 질의 확장: '안 돼요' → '서비스 이용이 정상적으로 되지 않습니다. 결제, 로그인, 앱 오류 등의 문제를 해결하고 싶습니다.'
[2단계] 검색된 FAQ: 3개
  - FAQ 1: [서비스] 앱이 작동하지 않을 때 어떻게 하나요?...
  - FAQ 2: [결제] 결제가 안 될 때 어떻게 해야 하나요?...
  - FAQ 3: [로그인] 로그인이 안 되는 경우 해결방법...
[3단계] 응답: 어떤 부분이 안 되시는지에 따라 해결 방법이 달라요.
앱 오류라면 앱을 삭제 후 재설치해 보시고, 결제 문제라면 카드 정보를 확인해 주세요.
정확한 도움을 위해 담당 부서로 연결해 드리겠습니다. 고객센터: 1588-0000


      4.7.1. 동작 흐름 요약

입력 → [길이 체크] → (10자 미만이면) LLM 질의 확장
     → EnsembleRetriever(BM25×0.5 + 벡터×0.5) → RRF 합산
     → format_faq_docs(카테고리+Q+A 포맷) → faq_prompt → gpt-4o-mini → 응답

API 호출 내역 (짧은 질의):
  [1] gpt-4o-mini: 질의 확장 (1회) — 10자 이상이면 생략
  [2] text-embedding-3-small: 질의 임베딩 (1회)
  [3] gpt-4o-mini: 최종 답변 생성 (1회)
  총: LLM 1~2회 + 임베딩 1회 ≈ $0.0003/질의

월간 비용 추정 (10,000건/일 기준):
  - 짧은 질의 비율 30% 가정: 10,000 × 0.3 × 2 + 10,000 × 0.7 × 1 = 13,000회 LLM 호출/일
  - gpt-4o-mini 비용: 13,000 × $0.00015 ≈ $1.95/일 ≈ $58.5/월
  → 요구 명세의 $50 한도에 근접, 질의 확장 비율 최적화 필요


   4.8. B.8 결과 검증 및 품질 평가

      - FAQ RAG 시스템의 검증은 "올바른 FAQ를 반환했는가"에 집중한다. 시나리오 A(법률)와 C(기술)에는 각각 검증 함수가 있지만, 시나리오 B에는 없었다. 다음은 FAQ 전용 검증 함수이다.

from typing import Optional

def evaluate_faq_response(
    query: str,
    response: str,
    retrieved_docs: list[Document],
    expected_faq_id: Optional[int] = None,
    expected_category: Optional[str] = None
) -> dict:
    """FAQ RAG 답변의 품질을 정량적으로 평가

    평가 항목:
    1. 응답 길이: 3문장 이내 규칙 준수 여부
    2. 카테고리 정확도: 검색된 FAQ의 카테고리가 기대와 일치하는지
    3. FAQ 매칭 정확도: 정답 FAQ가 검색 결과에 포함되었는지 (faq_id 기반)
    4. 톤 검사: 친절한 어조가 사용되었는지
    5. 에스컬레이션 감지: "담당 부서" 연결 응답인지

    Args:
        query: 고객 질의
        response: RAG 파이프라인의 응답
        retrieved_docs: 검색된 FAQ Document 리스트
        expected_faq_id: 정답 FAQ의 ID (선택, 정확도 측정용)
        expected_category: 기대 카테고리 (선택)

    Returns:
        dict: 평가 결과 딕셔너리
    """
    result = {
        "query": query,
        "response_sentences": 0,          # 응답 문장 수
        "is_concise": False,              # 3문장 이내 여부
        "category_match": False,          # 카테고리 일치 여부
        "faq_hit": False,                 # 정답 FAQ 검색 여부
        "is_escalation": False,           # 에스컬레이션 응답 여부
        "has_friendly_tone": False,       # 친절한 어조 여부
        "retrieved_categories": [],       # 검색된 FAQ 카테고리 목록
        "score": 0.0                      # 종합 점수 (0~1)
    }

    # 1. 응답 길이 (문장 수) 검사
    sentences = [s.strip() for s in re.split(r'[.!?。]\s', response) if s.strip()]
    result["response_sentences"] = len(sentences)
    result["is_concise"] = len(sentences) <= 4  # 3문장 + 안내 1문장 허용

    # 2. 카테고리 정확도
    result["retrieved_categories"] = [
        doc.metadata.get("category", "unknown") for doc in retrieved_docs
    ]
    if expected_category:
        result["category_match"] = expected_category in result["retrieved_categories"]

    # 3. FAQ 매칭 정확도 (faq_id 기반)
    if expected_faq_id is not None:
        retrieved_ids = [doc.metadata.get("faq_id") for doc in retrieved_docs]
        result["faq_hit"] = expected_faq_id in retrieved_ids

    # 4. 에스컬레이션 감지
    escalation_keywords = ["담당 부서", "연결해 드리", "고객센터", "전화"]
    result["is_escalation"] = any(kw in response for kw in escalation_keywords)

    # 5. 친절한 어조 검사 (존댓말 + 안내 표현)
    polite_markers = ["요.", "습니다.", "세요.", "드리", "주세요", "해 주"]
    polite_count = sum(1 for m in polite_markers if m in response)
    result["has_friendly_tone"] = polite_count >= 2

    # 종합 점수 계산
    score = 0.0
    score += 0.20 if result["is_concise"] else 0
    score += 0.25 if result["faq_hit"] or expected_faq_id is None else 0
    score += 0.20 if result["category_match"] or expected_category is None else 0
    score += 0.20 if result["has_friendly_tone"] else 0
    score += 0.15 if not result["is_escalation"] else 0.05  # 에스컬레이션도 부분 점수
    result["score"] = round(score, 2)

    return result

# 배치 평가 실행 예시
def run_faq_evaluation(chain, test_cases: list[dict]) -> dict:
    """여러 테스트 케이스에 대해 배치 평가 실행

    Args:
        chain: FAQ RAG 체인
        test_cases: [{"query": "...", "expected_faq_id": 0, "expected_category": "배송"}, ...]

    Returns:
        dict: 평균 점수 및 항목별 통계
    """
    results = []
    for case in test_cases:
        response = chain.invoke(case["query"])
        docs = faq_retriever.ensemble.invoke(case["query"])
        eval_result = evaluate_faq_response(
            query=case["query"],
            response=response,
            retrieved_docs=docs,
            expected_faq_id=case.get("expected_faq_id"),
            expected_category=case.get("expected_category")
        )
        results.append(eval_result)

    # 통계 집계
    avg_score = sum(r["score"] for r in results) / len(results)
    concise_rate = sum(1 for r in results if r["is_concise"]) / len(results)
    escalation_rate = sum(1 for r in results if r["is_escalation"]) / len(results)

    return {
        "total_cases": len(results),
        "avg_score": round(avg_score, 3),
        "concise_rate": f"{concise_rate:.0%}",
        "escalation_rate": f"{escalation_rate:.0%}",
        "details": results
    }


   4.9. B.9 고도화 제안

      - FAQ 챗봇의 품질과 비용 효율을 동시에 높이기 위한 고도화 방안이다.

 

      4.9.1. 1순위는 FAQ 자동 분류(Intent Detection)다.

         - 구현 난이도는 중간이며, 카테고리 정확도가 약 20% 향상된다. 고객 질의가 들어오면 FAQ 검색 전에 먼저 의도를 분류하여 배송, 결제, 환불 등의 카테고리를 특정한다. few-shot classifier를 활용하면 별도의 LLM 호출 없이도 빠르게 구현할 수 있으며, 분류 결과를 벡터 검색의 필터로 적용해 전체 FAQ 중 해당 카테고리에 속하는 항목만 검색 대상으로 좁혀 정확도를 높인다.

 

      4.9.2. 2순위는 응답 캐싱이다.

         - 구현 난이도가 낮아 즉시 적용 가능하며, 비용을 최대 50%, 응답 속도를 최대 80% 개선할 수 있다. FAQ의 특성상 동일하거나 유사한 질문이 반복되는 비율이 높다. 새 질의의 임베딩과 캐시된 임베딩 간 코사인 유사도가 0.95 이상이면 LLM을 재호출하지 않고 이전에 생성한 응답을 Redis 또는 인메모리 캐시에서 즉시 반환한다.

 

      4.9.3. 3순위는 멀티턴 대화 지원이다.

         - 구현 난이도는 중간이며, 고객 만족도가 약 15% 향상된다. 현재 시스템은 각 질의를 독립적으로 처리하지만, 실제 고객은 "아까 물어본 배송 건이요"처럼 이전 대화를 참조하여 질문하는 경우가 많다. 대화 세션 개념을 도입하고 이전 질의와 답변을 컨텍스트에 포함하여 이런 참조를 해소하면 자연스러운 대화 흐름이 가능해진다.

 

      4.9.4. 4순위는 FAQ 자동 업데이트다.

         - 구현 난이도가 높아 충분한 설계가 필요하지만, 시스템의 장기 운영 효율이 크게 향상된다. 담당 부서로 연결되는 에스컬레이션 로그를 정기적으로 분석하면 기존 FAQ로 해결되지 않는 반복 질의 패턴을 발견할 수 있다. 이를 새 FAQ 후보로 자동 제안하여 운영팀이 검토 후 추가하는 반자동 운영 사이클을 구축한다.

 

      4.9.5. 5순위는 A/B 테스트 프레임워크다.

         - 구현 난이도는 중간이며, 파이프라인 변경의 효과를 데이터로 검증할 수 있다. 프롬프트를 수정하거나 검색 파라미터를 변경할 때 감이 아닌 데이터로 개선 여부를 판단해야 한다. 트래픽을 두 그룹으로 나누어 기존 버전과 새 버전을 동시에 운영하고, 응답 만족도나 에스컬레이션 비율 같은 지표를 자동으로 비교하여 배포 여부를 결정한다.

 

      4.9.6. 6순위는 감정 분석 기반 응대다. 

         - 구현 난이도가 낮으며, 고객 경험을 직접적으로 개선할 수 있다. 질의 텍스트에서 부정적 감정(분노, 실망 등)을 감지하면 평소보다 더 공감적인 어조로 응답을 생성하고, 감정 강도가 높을 경우 즉시 인간 상담사에게 에스컬레이션을 제안한다. 단순한 자동화 응답이 아닌 고객의 감정 상태를 반영하는 응대가 이루어진다.

# [고도화 예시] 응답 캐싱으로 비용 50% 절감
from functools import lru_cache
import hashlib

class CachedFAQChain:
    """FAQ 응답 캐싱 래퍼

    동일한 질의에 대해 LLM을 다시 호출하지 않고 캐시된 응답을 반환한다.
    질의의 임베딩 유사도가 threshold 이상이면 캐시 히트로 처리한다.

    비용 절감 효과:
    - FAQ 질의의 약 30~40%는 동일/유사 질의의 반복
    - 캐싱으로 LLM 호출 30~40% 감소 → 월간 비용 $58.5 → ~$35
    """

    def __init__(self, chain, embedding, threshold: float = 0.95):
        self.chain = chain
        self.embedding = embedding
        self.threshold = threshold
        self.cache = {}  # {query_hash: {"response": str, "embedding": list}}

    def invoke(self, query: str) -> str:
        # 1. 질의 임베딩 생성
        query_emb = self.embedding.embed_query(query)

        # 2. 캐시에서 유사 질의 검색
        for cached_hash, cached_data in self.cache.items():
            similarity = self._cosine_similarity(query_emb, cached_data["embedding"])
            if similarity >= self.threshold:
                return cached_data["response"]  # 캐시 히트

        # 3. 캐시 미스: 체인 실행 후 결과 캐싱
        response = self.chain.invoke(query)
        query_hash = hashlib.md5(query.encode()).hexdigest()
        self.cache[query_hash] = {"response": response, "embedding": query_emb}
        return response

    def _cosine_similarity(self, a: list, b: list) -> float:
        """코사인 유사도 계산"""
        import numpy as np
        a, b = np.array(a), np.array(b)
        return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))


      - 실무 권장: FAQ 시스템에서 가장 즉각적인 비용 효과를 주는 것은 응답 캐싱(우선순위 2)이다. FAQ의 특성상 동일한 질문이 반복되는 비율이 높으므로, 간단한 캐싱만으로도 LLM 호출을 30~40% 줄일 수 있다. 다만 FAQ가 업데이트되면 관련 캐시를 무효화하는 로직이 필요하다.

 

5. 시나리오 C: 기술 문서 검색 시스템

   - 기술 문서 검색 시스템은 법률이나 FAQ와 또 다른 고유한 과제를 가진다. 문서에 코드 블록, API 명세, 설정 파일, 버전별 변경 사항이 혼재되어 있으며, 사용자 질의는 기술 키워드와 자연어가 혼합된 형태이다. 핵심 과제는 코드와 텍스트 설명을 모두 검색하되, 코드의 구조적 완전성을 보존하는 것이다.

 

   1) 기술 문서 도메인 RAG의 핵심 과제

과제 설명 예시
코드-텍스트 혼재 하나의 문서에 설명과 코드가 번갈아 등장 API 사용법 설명 + 코드 예시
코드 구조 보존 코드 블록이 분할되면 의미를 잃음 함수 정의가 두 청크에 걸쳐 분할
버전 관리 동일 API의 v1, v2 문서가 공존 "v2에서 변경된 파라미터" 검색
교차 검색 자연어 질의로 관련 코드를 찾아야 함 "리스트 정렬" → sorted(), list.sort()
언어별 차이 Python, JavaScript 등 언어별 문법 차이 같은 기능의 다른 언어 구현


   2) 요구 명세 (Requirements Specification)

      - 기술 문서 검색 시스템은 코드와 텍스트의 교차 검색이라는 고유한 과제를 해결해야 한다. 개발자의 생산성 향상이 최종 목표이다.

항목 요구사항 비고
문서 규모 마크다운 기술 문서 500~2,000개 API 레퍼런스 + 가이드 + 튜토리얼
코드 블록 수 문서당 평균 5~15개 코드 블록 Python, JavaScript, TypeScript 위주
버전 관리 동시에 2~3개 버전 유지 v1, v2, latest
동시 사용자 최대 200명 (개발팀 내부 도구) 피크: 업무 시간 09~18시
일일 질의량 1,000~3,000건/일 릴리즈 기간에 2배 증가
응답 시간 평균 3초 이내, 최대 10초 IDE 통합 시 빠른 응답 중요
코드 검색 정확도 Code Retrieval Recall@2 ≥ 75% 관련 코드 블록 2개 중 정답 포함
텍스트 검색 정확도 Text Retrieval Recall@3 ≥ 80% 관련 설명 문서 3개 중 정답 포함
코드 실행 가능률 ≥ 85% 생성된 코드가 실제 실행 가능한 비율
버전 정확도 ≥ 95% 요청 버전과 반환 문서 버전 일치율
가용성 99.5% (월간 가동시간) 업무 시간 내 서비스 안정성
월간 비용 한도 $200 이내 (OpenAI API 기준) 임베딩 + LLM 호출 합계

 

      - 설계 의도: 기술 문서 검색에서는 코드 품질과 정확도가 핵심이다. 개발자가 검색 결과를 바로 복사하여 사용할 수 있는 실행 가능한 코드를 제공해야 하므로, 생성 단계에 `gpt-4o`를 사용한다. 코드와 텍스트를 병렬로 검색하는 이유는 설명(개념 이해) + 코드(구현 참조)를 모두 제공해야 완전한 답변이 되기 때문이다. 버전 필터는 API 문서에서 v1과 v2의 혼동이 치명적인 오류로 이어질 수 있어 필수이다.

 

   5.1. C.1 문서 로딩 최적화

      - 문제: 기술 문서에는 코드 블록, API 명세, 설정 파일, 버전 정보가 혼재한다.

 

      - 일반 텍스트 로더로 기술 문서를 로딩하면 코드 블록과 일반 텍스트가 구분 없이 섞인다. 이렇게 되면 코드 검색과 텍스트 검색의 최적화를 각각 다르게 적용할 수 없다. 코드는 임베딩 시 접두사를 다르게 적용하거나, 청킹 시 분할을 방지하는 등 별도 처리가 필요하다.

 

      5.1.1. 일반 로더 vs 기술 문서 로더 비교

항목 일반 로더 기술 문서 로더 (TechnicalDocLoader)
코드 처리 텍스트와 동일하게 취급 코드 블록을 별도 Document로 분리
언어 정보 보존 안 됨 코드 언어(python, js 등) 메타데이터 저장
위치 정보 없음 position으로 원본 내 위치 추적
검색 분리 불가 content_type으로 코드/텍스트 개별 검색 가능


         - 커스텀 전략: 코드-텍스트 분리 로더

import re
import os
import logging
from langchain_core.documents import Document

logger = logging.getLogger(__name__)

class TechnicalDocLoader:
    """기술 문서 전용 로더 - 코드와 텍스트를 구분하여 처리

    마크다운 형식의 기술 문서에서 코드 블록(```...```)을 인식하여
    코드와 텍스트를 별도의 Document 객체로 분리한다.
    각 Document의 metadata에 content_type("code" 또는 "text")을 저장하여
    이후 청킹, 임베딩, 검색 단계에서 차별화된 처리가 가능하다.

    코드 블록의 프로그래밍 언어도 자동 감지하여 metadata에 저장한다.
    예: ```python → language="python"
    """

    # 지원되는 코드 언어 (메타데이터 정규화에 사용)
    SUPPORTED_LANGUAGES = {
        "python", "py", "javascript", "js", "typescript", "ts",
        "java", "go", "rust", "bash", "sh", "sql", "yaml", "json", "html", "css"
    }

    def __init__(self, file_path: str, version: str = None):
        """
        Args:
            file_path: 기술 문서 파일 경로 (마크다운 형식 권장)
            version: 문서 버전 (선택). "v1", "v2" 등. 검색 시 버전 필터에 활용.
        """
        self.file_path = file_path
        self.version = version  # 버전 정보를 메타데이터에 자동 추가

    def load(self) -> list[Document]:
        """기술 문서를 코드/텍스트 분리 Document 리스트로 변환

        Returns:
            list[Document]: 코드와 텍스트가 분리된 Document 리스트

        Raises:
            FileNotFoundError: 파일이 존재하지 않을 때
        """
        if not os.path.exists(self.file_path):
            raise FileNotFoundError(f"기술 문서 파일이 존재하지 않습니다: {self.file_path}")

        try:
            with open(self.file_path, "r", encoding="utf-8") as f:
                content = f.read()
        except UnicodeDecodeError:
            logger.warning(f"UTF-8 디코딩 실패, latin-1로 재시도: {self.file_path}")
            with open(self.file_path, "r", encoding="latin-1") as f:
                content = f.read()

        if not content.strip():
            logger.warning(f"문서가 비어 있습니다: {self.file_path}")
            return []

        documents = []

        # 기본 메타데이터 (모든 Document에 공통 적용)
        base_metadata = {"source": self.file_path}
        if self.version:
            base_metadata["version"] = self.version

        # 마크다운 코드 블록 패턴 추출
        # ```언어\n코드내용\n``` 형식을 인식
        # (\w+)? 부분은 선택적 언어 지정자 (python, javascript 등)
        # (.*?) 부분은 코드 내용 (non-greedy로 최소 매칭)
        # re.DOTALL로 줄바꿈도 . 에 포함
        code_pattern = r'```(\w+)?\n(.*?)```'
        code_blocks = re.finditer(code_pattern, content, re.DOTALL)

        last_end = 0  # 이전 코드 블록의 끝 위치 추적
        for match in code_blocks:
            # 코드 블록 앞의 텍스트를 별도 Document로 생성
            text_before = content[last_end:match.start()].strip()
            if text_before:
                documents.append(Document(
                    page_content=text_before,
                    metadata={
                        **base_metadata,
                        "content_type": "text",       # 일반 텍스트 표시
                        "position": last_end           # 원본 문서 내 위치
                    }
                ))

            # 코드 블록을 별도 Document로 생성
            raw_lang = (match.group(1) or "unknown").lower()
            # 언어명 정규화: "py" → "python", "js" → "javascript" 등
            language = self._normalize_language(raw_lang)
            code = match.group(2).strip()

            if code:  # 빈 코드 블록은 건너뜀
                documents.append(Document(
                    # 코드 블록을 마크다운 코드 펜스와 함께 저장
                    # LLM이 코드임을 인식할 수 있도록 ``` 표시 유지
                    page_content=f"```{language}\n{code}\n```",
                    metadata={
                        **base_metadata,
                        "content_type": "code",         # 코드 블록 표시
                        "language": language,             # 프로그래밍 언어
                        "position": match.start()        # 원본 문서 내 위치
                    }
                ))

            last_end = match.end()  # 다음 텍스트 영역의 시작점 업데이트

        # 마지막 코드 블록 이후의 텍스트 처리
        remaining = content[last_end:].strip()
        if remaining:
            documents.append(Document(
                page_content=remaining,
                metadata={
                    **base_metadata,
                    "content_type": "text",
                    "position": last_end
                }
            ))

        code_count = sum(1 for d in documents if d.metadata.get("content_type") == "code")
        text_count = len(documents) - code_count
        logger.info(f"기술 문서 로딩 완료: 코드 {code_count}개, 텍스트 {text_count}개 ({self.file_path})")
        return documents

    def _normalize_language(self, lang: str) -> str:
        """언어명 약어를 정식 명칭으로 정규화"""
        aliases = {"py": "python", "js": "javascript", "ts": "typescript", "sh": "bash"}
        return aliases.get(lang, lang)

 

         - 파라미터 정의 기준: `code_pattern = r'```(\w+)?\n(.*?)```'`의 정규표현식에서 `(\w+)?`는 코드 블록의 언어 지정자를 캡처한다. `\w+`는 알파벳, 숫자, 밑줄만 매칭하므로 "python", "javascript", "typescript" 등 표준 언어명을 캡처한다. `(.*?)`의 non-greedy 매칭은 중첩된 코드 블록이 있을 때 최소 범위만 캡처하여 오류를 방지한다.

 

      5.1.2. 코드-텍스트 연결 정보 보존

         - 코드 블록은 보통 바로 앞의 텍스트(설명)와 밀접하게 관련된다. 이 연결 관계를 보존하면 검색 품질을 높일 수 있다.

def link_code_with_context(documents: list[Document]) -> list[Document]:
    """코드 블록에 바로 앞의 텍스트 설명을 연결하는 함수

    코드 Document의 metadata에 'preceding_text'를 추가하여
    코드와 관련 설명의 연결 관계를 보존한다.
    이를 통해 코드 검색 시 설명도 함께 참조할 수 있다.

    예: "다음은 리스트를 정렬하는 코드입니다:" + [코드 블록]
    → 코드 Document의 preceding_text에 설명 텍스트 저장
    """
    linked = []
    prev_text = ""  # 이전 텍스트 Document의 내용 추적

    for doc in documents:
        if doc.metadata.get("content_type") == "text":
            # 텍스트 Document는 그대로 저장하고, 내용을 기억
            prev_text = doc.page_content
            linked.append(doc)
        elif doc.metadata.get("content_type") == "code":
            # 코드 Document에 바로 앞 텍스트를 연결
            # 마지막 200자만 저장 (바로 앞 설명 부분만 필요)
            doc.metadata["preceding_text"] = prev_text[-200:] if prev_text else ""
            linked.append(doc)
            prev_text = ""  # 리셋

    return linked

 

         - 실무 권장: 기술 문서 로더의 품질을 높이려면, 코드 블록뿐만 아니라 인라인 코드(`backtick` 처리된 코드), 설정 파일, YAML/JSON 블록 등도 별도로 처리하는 것이 좋다. 특히 API 명세 문서에서는 엔드포인트별로 분리하고, 각 엔드포인트의 메서드(GET, POST 등), 파라미터, 응답 형식을 메타데이터로 저장하면 정밀한 검색이 가능하다.

 

   5.2. C.2 청킹 최적화

      - 문제: 코드 블록은 분할하면 의미를 잃고, API 명세는 파라미터 단위로 관리해야 한다.

 

      - 코드의 핵심 특성은 구조적 완전성이 필수라는 점이다. 함수 정의가 중간에 잘리면 실행할 수 없고, 클래스 정의가 분할되면 상속 관계를 파악할 수 없다. 반면 텍스트 설명은 일반적인 청킹 전략으로 분할해도 큰 문제가 없다.

 

      5.2.1. 코드 청킹 전략 비교

전략 코드 처리 텍스트 처리 적합 상황
RecursiveCharacterTextSplitter 코드 중간 분할 위험 정상 분할 텍스트만 있는 문서
MarkdownHeaderTextSplitter 코드 블록 무시 헤더 기준 분할 마크다운 설명 문서
CodeAwareChunker (커스텀) 코드 보존 + 함수/클래스 분할 일반 분할 코드+텍스트 혼합 문서


         5.2.1.1. 커스텀 전략: 코드 인식 청킹

from langchain_text_splitters import RecursiveCharacterTextSplitter

class CodeAwareChunker:
    """코드 블록을 보존하면서 텍스트를 분할하는 청커

    content_type 메타데이터를 기반으로 코드와 텍스트를 다르게 처리한다:
    - 코드(content_type="code"): code_max_size 이내이면 분할하지 않음.
      초과 시 함수/클래스 단위로 분할하여 구조적 완전성 보존.
    - 텍스트(content_type="text"): RecursiveCharacterTextSplitter로 일반 분할.

    Args:
        max_chunk_size: 텍스트 청크의 최대 문자 수 (기본 1500)
        code_max_size: 코드 블록 분할 임계값 (기본 3000).
                      이 크기 이하의 코드는 분할하지 않음.
                      일반적인 함수/클래스는 3000자 이내이므로 대부분 보존됨.
    """

    def __init__(self, max_chunk_size: int = 1500, code_max_size: int = 3000):
        self.max_chunk_size = max_chunk_size
        self.code_max_size = code_max_size
        # 텍스트 전용 분할기 (코드에는 사용하지 않음)
        self.text_splitter = RecursiveCharacterTextSplitter(
            chunk_size=max_chunk_size,
            chunk_overlap=200  # 텍스트 문맥 연속성을 위한 오버랩
        )

    def chunk(self, documents: list[Document]) -> list[Document]:
        chunks = []
        for doc in documents:
            content_type = doc.metadata.get("content_type", "text")

            if content_type == "code":
                # 코드 처리: 가능한 한 분할하지 않음
                if len(doc.page_content) <= self.code_max_size:
                    # code_max_size 이내: 원본 그대로 유지 (분할 없음)
                    chunks.append(doc)
                else:
                    # code_max_size 초과: 함수/클래스 단위로 분할
                    # 이 경우에도 임의 위치가 아닌 의미 있는 단위로 분할
                    code_chunks = self._split_code(doc)
                    chunks.extend(code_chunks)
            else:
                # 텍스트 처리: 일반적인 RecursiveCharacterTextSplitter 사용
                text_chunks = self.text_splitter.split_documents([doc])
                chunks.extend(text_chunks)

        return chunks

    def _split_code(self, doc: Document) -> list[Document]:
        """함수/클래스 단위로 코드 분할

        프로그래밍 언어별로 다른 패턴을 적용하여
        함수, 클래스, 상수 정의 등의 경계에서 분할한다.

        Python: def, class 키워드 기준
        JavaScript/TypeScript: function, class, const 키워드 기준
        기타 언어: 빈 줄 기준 (범용적 대체)
        """
        code = doc.page_content
        language = doc.metadata.get("language", "python")

        if language == "python":
            # Python: 함수(def) 또는 클래스(class) 정의의 시작점에서 분할
            # (?=\n(?:def |class )) 전방탐색으로 패턴 자체는 보존
            pattern = r'(?=\n(?:def |class ))'
        elif language in ("javascript", "typescript"):
            # JS/TS: function, class, const 변수 선언의 시작점에서 분할
            pattern = r'(?=\n(?:function |class |const \w+ = ))'
        else:
            # 기타 언어: 빈 줄(2개 이상 연속 줄바꿈)을 기준으로 분할
            pattern = r'\n\n+'

        parts = re.split(pattern, code)
        return [
            Document(
                page_content=part.strip(),
                metadata={
                    **doc.metadata,           # 원본 메타데이터 상속
                    "code_part": i             # 분할된 코드의 순서 번호
                }
            )
            for i, part in enumerate(parts) if part.strip()
        ]


            - 파라미터 정의 기준: `code_max_size=3000`은 일반적인 함수/클래스 정의가 3000자 이내인 점을 고려한 값이다. Python의 경우 한 함수가 평균 20~50줄(약 500~1500자)이므로, 3000자는 대부분의 함수를 분할 없이 보존한다. 이 값을 초과하는 코드는 보통 매우 긴 클래스나 모듈 수준의 코드이므로 함수/클래스 단위 분할이 적절하다.

 

      5.2.2. 코드 청킹 품질 검증

def verify_code_chunks(chunks: list[Document]) -> dict:
    """코드 청킹 결과를 검증하는 유틸리티 함수

    코드 청크의 구조적 완전성을 확인한다:
    - 괄호 쌍이 맞는지 (분할로 인해 깨지지 않았는지)
    - 코드 펜스(```)가 올바르게 닫혔는지
    """
    report = {
        "total_code_chunks": 0,     # 총 코드 청크 수
        "total_text_chunks": 0,     # 총 텍스트 청크 수
        "broken_brackets": [],      # 괄호가 깨진 코드 청크 목록
        "unclosed_fences": [],      # 코드 펜스가 닫히지 않은 청크 목록
    }

    for i, chunk in enumerate(chunks):
        if chunk.metadata.get("content_type") == "code":
            report["total_code_chunks"] += 1
            content = chunk.page_content

            # 괄호 쌍 검사: 열린 괄호와 닫힌 괄호의 수가 일치하는지
            for open_b, close_b in [("(", ")"), ("{", "}"), ("[", "]")]:
                if content.count(open_b) != content.count(close_b):
                    report["broken_brackets"].append((i, open_b + close_b))

            # 코드 펜스 검사: ``` 가 짝수 개인지 (열기+닫기)
            fence_count = content.count("```")
            if fence_count % 2 != 0:
                report["unclosed_fences"].append(i)
        else:
            report["total_text_chunks"] += 1

    return report

 

         - 실무 권장: 코드 청킹 후 반드시 괄호 쌍과 코드 펜스 검사를 수행하라. 괄호가 깨진 코드 청크는 LLM이 불완전한 코드를 생성하는 원인이 된다. 검증에서 문제가 발견되면 `code_max_size`를 늘리거나 분할 패턴을 조정하라.

 

   5.3. C.3 임베딩 최적화

      - 권장 설정:
         - 모델: `text-embedding-3-large` (코드와 텍스트 간 교차 검색 필요)
         - 코드와 텍스트에 대한 접두사(prefix) 활용

      - 기술 문서에서의 임베딩 최적화 핵심은 코드와 텍스트 간 교차 검색(cross-modal retrieval)이다. 사용자가 "리스트를 정렬하는 방법"이라고 자연어로 질문했을 때, `sorted()` 함수가 포함된 코드 블록을 찾아야 한다. 이를 위해 임베딩 시 접두사(prefix)를 활용하여 코드와 텍스트의 임베딩 공간을 조율한다.

 

      5.3.1. 접두사(Prefix) 전략의 원리

[접두사 없이 임베딩하는 경우]
텍스트: "리스트 정렬 방법" → 벡터 A (텍스트 영역)
코드:  "sorted(list)"      → 벡터 B (코드 영역)
→ 두 벡터가 임베딩 공간에서 멀리 위치하여 교차 검색이 어려움

[접두사를 활용한 임베딩]
텍스트: "[DOCS] 리스트 정렬 방법"  → 벡터 A' (접두사로 도메인 표시)
코드:  "[CODE] sorted(list)"       → 벡터 B' (접두사로 도메인 표시)
질의:  "리스트 정렬" (접두사 없음)  → 벡터 Q  (양쪽 모두 검색 가능)
→ 질의 벡터는 접두사가 없으므로 코드/텍스트 양쪽에 가까움
from langchain_openai import OpenAIEmbeddings

class TechDocEmbeddings:
    """기술 문서용 임베딩 - 코드/텍스트 구분 접두사 추가

    코드와 텍스트 Document에 서로 다른 접두사를 추가하여 임베딩한다.
    질의(query)는 접두사 없이 임베딩하여 코드/텍스트 양쪽에서 검색 가능하다.

    접두사를 사용하는 이유:
    - [CODE] 접두사: 코드 임베딩이 코드 공간에서 클러스터링되도록 유도
    - [DOCS] 접두사: 텍스트 임베딩이 텍스트 공간에서 클러스터링되도록 유도
    - 질의에 접두사 없음: 두 공간 모두에서 검색 가능

    이 방식은 E5, BGE 등의 임베딩 모델에서 사용되는
    instruction prefix 기법에서 영감을 받았다.
    """

    def __init__(self):
        # 고차원 모델 사용: 코드와 텍스트의 의미 차이를 충분히 표현하기 위해
        self.embedding = OpenAIEmbeddings(model="text-embedding-3-large")

    def embed_documents(self, documents: list[Document]) -> list[list[float]]:
        """문서 임베딩: 코드/텍스트에 다른 접두사 추가"""
        texts = []
        for doc in documents:
            content_type = doc.metadata.get("content_type", "text")
            if content_type == "code":
                # 코드에 [CODE] 접두사 추가
                texts.append(f"[CODE] {doc.page_content}")
            else:
                # 텍스트에 [DOCS] 접두사 추가
                texts.append(f"[DOCS] {doc.page_content}")
        return self.embedding.embed_documents(texts)

    def embed_query(self, query: str) -> list[float]:
        """질의 임베딩: 접두사 없이 임베딩하여 코드/텍스트 모두 검색 가능"""
        return self.embedding.embed_query(query)


      - 파라미터 정의 기준: 접두사 `[CODE]`와 `[DOCS]`는 임베딩 모델이 인식할 수 있는 토큰이면서, 실제 코드/텍스트 내용과 충돌하지 않는 고유한 마커이다. 대괄호 형식은 임베딩 모델이 구조적 마커로 처리할 가능성이 높아 선택했다. 접두사의 효과는 임베딩 모델에 따라 다르므로, 실제 검색 품질을 측정하여 접두사 사용 여부를 결정하라.

 

      - 실무 권장: 접두사 전략의 효과는 임베딩 모델에 따라 다르다. OpenAI의 `text-embedding-3` 시리즈에서는 효과가 미미할 수 있지만, E5(`intfloat/e5-large`)나 BGE(`BAAI/bge-large`) 같은 오픈소스 임베딩 모델에서는 instruction prefix가 공식적으로 지원되어 효과가 뚜렷하다. 반드시 접두사 유/무의 검색 품질을 비교 테스트하라.

 

   5.4. C.4 검색 최적화

      - 문제: "Python에서 리스트 정렬하는 방법"이라는 질의에 코드 예시와 설명 문서 모두 필요하다.

 

      - 기술 문서 검색의 고유한 요구사항은 코드와 텍스트를 병렬로 검색하여 결합하는 것이다. 사용자가 기술 질문을 하면, 설명 문서(개념 이해)와 코드 예시(실제 구현) 모두를 제공해야 완전한 답변이 된다. 또한 특정 버전의 문서만 검색해야 하는 경우도 많다.

[기술 문서 검색 전략 흐름]

사용자 질의: "Python에서 리스트 정렬하는 방법"
         ↓
[텍스트 검색 (content_type="text")]
         → "Python의 sorted() 함수는..." (설명 문서 3개)
         ↓
[코드 질의 변환]
         → "sorted list sort python" (기술 키워드)
         ↓
[코드 검색 (content_type="code")]
         → "```python\nsorted_list = sorted(...)```" (코드 2개)
         ↓
[결합: 텍스트(우선) + 코드]
         → 설명 3개 + 코드 2개 = 총 5개 문서


      - 커스텀 전략: 코드-텍스트 병렬 검색 + 버전 필터

class TechDocRetriever:
    """기술 문서 전용 검색기 - 코드/텍스트 병렬 검색

    코드와 텍스트를 각각 독립적으로 검색하여 결합한다.
    텍스트 검색은 원본 자연어 질의를 사용하고,
    코드 검색은 LLM으로 변환한 기술 키워드를 사용한다.

    추가로 버전 필터를 지원하여 특정 버전의 문서만 검색할 수 있다.
    """

    def __init__(self, vector_db, llm):
        self.db = vector_db   # 벡터 DB (코드+텍스트 모두 저장)
        self.llm = llm        # 코드 질의 변환에 사용할 LLM

    def retrieve(self, query: str, version: str = None) -> list[Document]:
        """코드-텍스트 병렬 검색 수행

        Args:
            query: 사용자 질의 (자연어)
            version: 문서 버전 필터 (선택). "v2", "3.9" 등

        Returns:
            텍스트 문서(최대 3개) + 코드 문서(최대 2개) 결합 리스트
        """
        # 버전 필터 구성
        filter_dict = {}
        if version:
            filter_dict["version"] = version

        # 텍스트 검색: 자연어 질의로 설명 문서 검색
        # content_type="text" 필터로 텍스트 문서만 대상
        text_results = self.db.similarity_search(
            query,
            k=3,  # 텍스트 설명은 3개가 적절 (개념 이해에 충분)
            filter={**filter_dict, "content_type": "text"} if filter_dict else {"content_type": "text"}
        )

        # 코드 검색: 기술 키워드로 변환 후 코드 블록 검색
        # 자연어 질의를 함수명, 메서드명 등 기술 키워드로 변환
        code_query = self._to_code_query(query)
        code_results = self.db.similarity_search(
            code_query,
            k=2,  # 코드 예시는 2개면 충분 (다양한 접근법 제시)
            filter={**filter_dict, "content_type": "code"} if filter_dict else {"content_type": "code"}
        )

        # 텍스트 우선으로 결합: 설명을 먼저 읽고 코드를 참고하는 학습 패턴 반영
        return text_results + code_results

    def _to_code_query(self, query: str) -> str:
        """자연어 질의를 코드 검색에 적합한 기술 키워드로 변환

        예: "Python에서 리스트 정렬하는 방법"
        → "sorted() list.sort() python sort algorithm"
        """
        response = self.llm.invoke(
            f"다음 질문을 프로그래밍 코드 검색에 적합한 키워드로 변환하세요.\n"
            f"함수명, 메서드명, 라이브러리명 위주로 작성하세요.\n"
            f"질문: {query}\n키워드:"
        )
        return response.content.strip()

 

      - 파라미터 정의 기준: 텍스트 `k=3`, 코드 `k=2`로 비대칭 설정한 이유는 텍스트 설명이 개념 이해의 기초이고, 코드는 구현 참고용이기 때문이다. 텍스트가 먼저 배치되는 이유도 동일하다. LLM이 컨텍스트를 처리할 때 앞쪽 내용에 더 높은 가중치를 부여하는 경향(primacy bias)이 있으므로, 중요한 설명 문서를 앞에 배치한다.

 

      - 실무 권장: 코드 질의 변환(`_to_code_query`)의 품질이 코드 검색 정확도를 좌우한다. LLM에게 "함수명, 메서드명, 라이브러리명 위주"로 변환하도록 지시하는 것이 핵심이다. 변환 품질이 낮으면, 프롬프트에 도메인별 예시를 추가(few-shot)하거나, 변환 없이 원본 질의로 코드도 검색하는 대안을 고려하라.

 

   5.5. C.5 생성 최적화

      - 커스텀 프롬프트: 코드 예시 필수 포함

 

      - 기술 문서 RAG의 프롬프트는 실행 가능한 코드, 버전 호환성, 주의사항(pitfall)을 반드시 포함하도록 설계한다.

항목 법률 프롬프트 FAQ 프롬프트 기술 프롬프트
필수 포함 법 조항 번호 간결한 답변 실행 가능한 코드
부가 정보 원문 인용 고객센터 번호 버전 호환성, 주의사항
불확실 처리 "정보를 찾을 수 없습니다" "담당 부서 연결" "공식 문서 참조 권장"
전문적 친절 실용적, 간결

 

tech_prompt = ChatPromptTemplate.from_template("""
당신은 시니어 개발자입니다. 다음 규칙을 준수하세요:

1. 답변에 반드시 실행 가능한 코드 예시를 포함하세요
2. 코드에는 주석을 추가하세요
3. 사용하는 라이브러리의 버전 호환성을 명시하세요
4. 주의사항이나 함정(pitfall)이 있으면 경고하세요
5. 컨텍스트에 없는 내용은 추측하지 말고 공식 문서 참조를 권하세요

참고 문서:
{context}

질문: {question}

답변:
""")

 

      - 파라미터 정의 기준: 규칙 1(실행 가능한 코드)은 기술 문서 RAG의 핵심 가치이다. "실행 가능한"이라는 조건을 명시하지 않으면 LLM이 의사 코드(pseudo code)나 불완전한 코드를 생성할 수 있다. 규칙 3(버전 호환성)은 기술 문서에서 빈번한 오류 원인인 버전 불일치를 방지한다. 규칙 4(주의사항)는 개발자가 코드를 그대로 사용할 때 발생할 수 있는 함정을 미리 경고하여 품질을 높인다.

 

      5.5.1. 기술 프롬프트 강화: 언어별 특화

# 프로그래밍 언어에 따라 프롬프트를 동적으로 조정하는 패턴
def get_tech_prompt(language: str = None) -> ChatPromptTemplate:
    """프로그래밍 언어에 따라 최적화된 프롬프트를 반환

    언어별로 다른 코딩 컨벤션과 주의사항을 프롬프트에 반영한다.
    """
    base_rules = """
1. 답변에 반드시 실행 가능한 코드 예시를 포함하세요
2. 코드에는 한국어 주석을 추가하세요
3. 주의사항이나 함정(pitfall)이 있으면 경고하세요
4. 컨텍스트에 없는 내용은 공식 문서 참조를 권하세요
"""

    # 언어별 추가 규칙
    language_rules = {
        "python": "\n5. PEP 8 스타일 가이드를 준수하세요\n6. type hint를 포함하세요",
        "javascript": "\n5. ES6+ 문법을 사용하세요\n6. async/await 패턴을 선호하세요",
        "typescript": "\n5. 타입 정의를 명확히 하세요\n6. interface와 type의 적절한 사용을 보여주세요",
    }

    extra_rules = language_rules.get(language, "")

    return ChatPromptTemplate.from_template(f"""
당신은 시니어 개발자입니다. 다음 규칙을 준수하세요:
{base_rules}{extra_rules}

참고 문서:
{{context}}

질문: {{question}}

답변:
""")


   5.6. C.6 전체 파이프라인 조립

[기술 문서 RAG 파이프라인 전체 흐름]

사용자 질의: "Python에서 리스트 정렬하는 방법"
         ↓
[RunnablePassthrough: 원본 질의 유지]
         ↓
[TechDocRetriever.retrieve]
         ├── 텍스트 검색 (k=3): "sorted() 함수는..." 등
         └── 코드 검색 (k=2): "```python\nsorted(...)```" 등
         ↓
[format_tech_docs: 코드/텍스트 구분 포맷팅]
         → "[문서]\n설명...\n---\n[코드 - python]\n```python...```"
         ↓
[tech_prompt: 코드 포함 프롬프트]
         ↓
[ChatOpenAI(gpt-4o): 고품질 코드 생성]
         ↓
[StrOutputParser: 최종 응답]
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_community.vectorstores import Chroma

# [전제 조건] C.1~C.3 단계의 결과물
# documents = TechnicalDocLoader("api_reference.md", version="v2").load()
# linked_docs = link_code_with_context(documents)
# chunks = CodeAwareChunker(max_chunk_size=1500, code_max_size=3000).chunk(linked_docs)
# embedding = OpenAIEmbeddings(model="text-embedding-3-large")
# db = Chroma.from_documents(chunks, embedding=embedding)
# llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

# 기술 문서 검색기 인스턴스 생성
tech_retriever = TechDocRetriever(vector_db=db, llm=llm)

# 기술 문서 포맷팅 함수
# 코드와 텍스트를 구분하여 LLM이 명확히 인식하도록 포맷
def format_tech_docs(docs):
    formatted = []
    for doc in docs:
        content_type = doc.metadata.get("content_type", "text")
        language = doc.metadata.get("language", "")
        # 코드는 [코드 - 언어] 접두사, 텍스트는 [문서] 접두사
        prefix = f"[코드 - {language}]" if content_type == "code" else "[문서]"
        formatted.append(f"{prefix}\n{doc.page_content}")
    return "\n\n---\n\n".join(formatted)

# 전체 체인 조립 (LCEL)
tech_rag_chain = (
    {
        "question": RunnablePassthrough(),  # 원본 질의 그대로 전달
        # TechDocRetriever로 코드+텍스트 병렬 검색 → 포맷팅
        "context": lambda x: format_tech_docs(tech_retriever.retrieve(x)),
    }
    | tech_prompt                               # 코드 포함 프롬프트
    | ChatOpenAI(model="gpt-4o", temperature=0) # gpt-4o: 정확한 코드 생성 필요
    | StrOutputParser()                         # 문자열 출력
)


      - 파라미터 정의 기준: `model="gpt-4o"`를 사용하는 이유는 코드 생성에 높은 품질이 필요하기 때문이다. `gpt-4o-mini`도 간단한 코드는 생성할 수 있지만, 복잡한 패턴(비동기 처리, 에러 핸들링 등)이나 여러 함수의 조합이 필요한 코드에서는 `gpt-4o`가 더 정확하다. `temperature=0`은 코드의 정확성을 위해 결정적 생성을 사용한다.

 

      - 실무 권장: `lambda x: format_tech_docs(tech_retriever.retrieve(x))` 부분에서 `TechDocRetriever`가 내부적으로 LLM을 호출한다(코드 질의 변환). 이 추가 LLM 호출이 응답 시간을 늘리므로, 응답 속도가 중요한 경우 코드 질의 변환을 생략하고 원본 질의로 코드도 직접 검색하는 간소화 모드를 고려하라.

 

   5.7. C.7 실행 예시 및 동작 흐름

      - 기술 문서 RAG 파이프라인의 실행 과정을 구체적인 입출력 예시와 함께 추적한다.

# ============================================================
# 기술 문서 RAG 파이프라인 실행 예시
# ============================================================

# 테스트 질의
test_query = "Python에서 리스트를 정렬하는 방법을 알려주세요"

# [1단계] 코드 질의 변환 (_to_code_query)
# 자연어 → 기술 키워드로 변환
code_keywords = tech_retriever._to_code_query(test_query)
print(f"[1단계] 코드 키워드 변환: {code_keywords}")

# [2단계] 병렬 검색 (텍스트 + 코드)
docs = tech_retriever.retrieve(test_query)
print(f"\n[2단계] 검색 결과: 총 {len(docs)}개")
for i, doc in enumerate(docs):
    ctype = doc.metadata.get("content_type", "unknown")
    lang = doc.metadata.get("language", "N/A")
    preview = doc.page_content[:60].replace("\n", " ")
    print(f"  - 문서 {i+1} [{ctype}] (lang={lang}): {preview}...")

# [3단계] 최종 답변 생성
response = tech_rag_chain.invoke(test_query)
print(f"\n[3단계] 최종 답변:\n{response}")

 

      - 예상 출력 예시:

[1단계] 코드 키워드 변환: sorted() list.sort() python sort key reverse lambda

[2단계] 검색 결과: 총 5개
  - 문서 1 [text] (lang=N/A): Python의 정렬 기능은 내장 함수 sorted()와 리스트...
  - 문서 2 [text] (lang=N/A): 정렬 시 key 파라미터를 사용하면 커스텀 정렬 기준을...
  - 문서 3 [text] (lang=N/A): sorted()와 list.sort()의 차이점은 원본 변경 여부입니다...
  - 문서 4 [code] (lang=python): ```python numbers = [3, 1, 4, 1, 5, 9] sorted_nu...
  - 문서 5 [code] (lang=python): ```python # 딕셔너리 리스트를 특정 키로 정렬 users =...

[3단계] 최종 답변:
Python에서 리스트를 정렬하는 두 가지 주요 방법이 있습니다.

## 1. `sorted()` 내장 함수 (새 리스트 반환)
```python
numbers = [3, 1, 4, 1, 5, 9]
# 오름차순 정렬 (원본 변경 없음)
sorted_numbers = sorted(numbers)  # [1, 1, 3, 4, 5, 9]

# 내림차순 정렬
sorted_desc = sorted(numbers, reverse=True)  # [9, 5, 4, 3, 1, 1]

 

      -  `list.sort()` 메서드 (원본 리스트 변경)

numbers = [3, 1, 4, 1, 5, 9]
numbers.sort()  # 원본이 변경됨: [1, 1, 3, 4, 5, 9]


      - 커스텀 정렬 (key 파라미터)

# 딕셔너리 리스트를 특정 키로 정렬
users = [{"name": "Kim", "age": 30}, {"name": "Lee", "age": 25}]
sorted_users = sorted(users, key=lambda x: x["age"])

 

      - 주의사항: `sort()`는 리스트에만 사용 가능하며, `sorted()`는 모든 이터러블에 사용 가능합니다.

 

      5.7.1. 동작 흐름 요약

입력 → _to_code_query(자연어→키워드 변환)
     ├── 텍스트 검색: 원본 질의 → similarity_search(content_type="text", k=3)
     └── 코드 검색: 키워드 질의 → similarity_search(content_type="code", k=2)
     → format_tech_docs([문서]+[코드] 포맷) → tech_prompt → gpt-4o → 응답

API 호출 내역:
  [1] gpt-4o-mini/gpt-4o: 코드 키워드 변환 (1회)
  [2] text-embedding-3-large: 원본 질의 임베딩 (1회, 텍스트 검색용)
  [3] text-embedding-3-large: 키워드 질의 임베딩 (1회, 코드 검색용)
  [4] gpt-4o: 최종 답변 생성 (1회)
  총: LLM 2회 + 임베딩 2회 ≈ $0.005/질의


   5.8. C.8 결과 검증 및 품질 평가

      - 기술 문서 RAG의 핵심 평가 항목은 "실행 가능한 코드가 포함되었는가"와 "올바른 버전의 코드인가"이다.

import re
from typing import Optional

def evaluate_tech_response(
    query: str,
    response: str,
    retrieved_docs: list[Document],
    expected_language: str = "python",
    expected_version: Optional[str] = None
) -> dict:
    """기술 문서 RAG 답변의 품질을 정량적으로 평가

    평가 항목:
    1. 코드 포함 여부: 답변에 코드 블록이 포함되었는지
    2. 코드 구문 검사: 포함된 Python 코드가 구문적으로 유효한지
    3. 버전 호환성 명시: 라이브러리 버전이나 Python 버전이 언급되었는지
    4. 주의사항 포함: pitfall/warning이 포함되었는지
    5. 코드-텍스트 균형: 설명과 코드가 모두 포함되었는지

    Args:
        query: 원본 질의
        response: RAG 파이프라인의 응답
        retrieved_docs: 검색된 Document 리스트
        expected_language: 기대 프로그래밍 언어
        expected_version: 기대 버전 (선택)

    Returns:
        dict: 평가 결과 딕셔너리
    """
    result = {
        "query": query,
        "has_code_block": False,          # 코드 블록 포함 여부
        "code_block_count": 0,            # 코드 블록 수
        "code_syntax_valid": False,       # 구문 유효성 (Python만)
        "has_version_info": False,        # 버전 정보 포함 여부
        "has_warning": False,             # 주의사항 포함 여부
        "has_explanation": False,         # 텍스트 설명 포함 여부
        "language_match": False,          # 기대 언어 일치 여부
        "doc_type_coverage": {},          # 검색된 문서 유형 분포
        "score": 0.0                      # 종합 점수 (0~1)
    }

    # 1. 코드 블록 검사
    code_blocks = re.findall(r'```(\w+)?\n(.*?)```', response, re.DOTALL)
    result["code_block_count"] = len(code_blocks)
    result["has_code_block"] = len(code_blocks) > 0

    # 2. 코드 구문 유효성 검사 (Python만)
    if code_blocks:
        for lang, code in code_blocks:
            if lang and lang.lower() in ("python", "py"):
                result["language_match"] = True
                try:
                    compile(code.strip(), "<string>", "exec")
                    result["code_syntax_valid"] = True
                except SyntaxError:
                    result["code_syntax_valid"] = False
                break  # 첫 번째 Python 코드만 검사

    # 3. 버전 정보 포함 여부
    version_patterns = [
        r'Python\s*\d+\.\d+',           # Python 3.9
        r'v\d+\.\d+',                    # v2.0
        r'버전\s*\d+',                    # 버전 3
        r'\d+\.\d+\.\d+',               # 1.2.3 (세그먼트 버전)
    ]
    result["has_version_info"] = any(
        re.search(p, response) for p in version_patterns
    )

    # 4. 주의사항/경고 포함 여부
    warning_keywords = ["주의", "경고", "warning", "주의사항", "pitfall", "함정", "deprecated"]
    result["has_warning"] = any(kw.lower() in response.lower() for kw in warning_keywords)

    # 5. 텍스트 설명 포함 여부 (코드 외 텍스트가 50자 이상)
    text_only = re.sub(r'```.*?```', '', response, flags=re.DOTALL).strip()
    result["has_explanation"] = len(text_only) >= 50

    # 6. 검색 문서 유형 분포
    for doc in retrieved_docs:
        ctype = doc.metadata.get("content_type", "unknown")
        result["doc_type_coverage"][ctype] = result["doc_type_coverage"].get(ctype, 0) + 1

    # 종합 점수 계산
    score = 0.0
    score += 0.25 if result["has_code_block"] else 0
    score += 0.20 if result["code_syntax_valid"] else 0
    score += 0.15 if result["has_version_info"] else 0
    score += 0.15 if result["has_warning"] else 0
    score += 0.15 if result["has_explanation"] else 0
    score += 0.10 if result["language_match"] else 0
    result["score"] = round(score, 2)

    return result

# 사용 예시
eval_result = evaluate_tech_response(
    query="Python에서 리스트 정렬하는 방법",
    response=response,  # tech_rag_chain의 출력
    retrieved_docs=docs,
    expected_language="python"
)
print(f"평가 점수: {eval_result['score']}")
print(f"코드 블록: {eval_result['code_block_count']}개")
print(f"구문 유효: {eval_result['code_syntax_valid']}")
print(f"버전 정보: {eval_result['has_version_info']}")
print(f"주의사항: {eval_result['has_warning']}")


   5.9. C.9 고도화 제안

      - 기술 문서 RAG 시스템의 코드 품질과 검색 정확도를 높이기 위한 고도화 방안이다.

 

      5.9.1. 1순위는 코드 실행 검증(Sandbox)이다.

         - 구현 난이도는 중간이며, 코드 실행 가능률이 약 15% 향상된다. LLM이 생성한 코드를 격리된 Docker sandbox 환경에서 실제로 실행해보고, 오류가 발생하면 오류 메시지를 함께 포함하여 LLM에 재생성을 요청하는 자동 수정 루프를 구성한다. 이 방식으로 개발자에게 바로 사용할 수 없는 불완전한 코드가 전달되는 상황을 크게 줄일 수 있다. 보안을 위해 반드시 네트워크 접근과 파일 시스템 쓰기가 차단된 격리 환경에서만 실행해야 한다.

 

      5.9.2. 2순위는 API 변경 감지 자동 알림이다.

         - 구현 난이도는 중간이며, 문서의 버전 정확도를 지속적으로 유지할 수 있다. 라이브러리 공식 문서나 GitHub 릴리즈 노트를 주기적으로 크롤링하여 기존에 색인된 API가 deprecated되거나 시그니처가 변경되었는지 감지하고, 해당 문서의 메타데이터에 자동으로 태깅한다. 사용자가 이미 폐기된 API를 참조하는 답변을 받는 상황을 방지한다.

 

      5.9.3. 3순위는 코드 임베딩 특화 모델 도입이다.

         -구현 난이도가 높지만, 코드 검색 Recall이 약 20% 향상된다. 일반 텍스트 임베딩 모델 대신 CodeBERT나 StarCoder-Embedding과 같이 대규모 코드 데이터로 학습된 전용 모델을 코드 블록 벡터화에 적용한다. 자연어 질의로 코드를 검색하는 교차 검색(cross-modal retrieval)의 품질이 눈에 띄게 개선되며, 함수명이나 라이브러리명의 의미적 유사성도 더 정확하게 포착한다.

 

      5.9.4. 4순위는 IDE 플러그인 통합이다.

         - 구현 난이도가 높지만, 개발자의 실제 워크플로우에 RAG를 직접 통합하는 차별화된 기능이다. VS Code Extension을 구현하여 에디터 내에서 RAG 검색을 실행할 수 있게 하고, 검색 시 현재 열려 있는 파일의 코드와 커서 위치를 컨텍스트로 자동 포함한다. 개발자가 브라우저로 전환하지 않고 코딩 중에 바로 관련 문서와 코드 예시를 찾을 수 있어 생산성이 크게 향상된다.

 

      5.9.5. 5순위는 인기 문서 가중치 부여다.

         - 구현 난이도가 낮아 빠르게 적용할 수 있으며, 검색 결과의 실용성이 높아진다. 검색 로그를 분석하여 사용자들이 자주 조회하는 문서를 파악하고, 유사도 점수가 비슷할 때 조회 수가 높은 문서를 상위에 배치하는 재정렬(reranking) 로직을 추가한다. 많이 참조되는 문서는 실무에서 검증된 내용일 가능성이 높으므로 검색 결과의 실용성이 높아진다.

 

      5.9.6. 6순위는 멀티 언어 코드 변환이다.

         - 구현 난이도는 중간이며, 시스템의 활용 범위가 크게 확대된다. Python으로 작성된 코드 예시를 찾은 사용자가 "JavaScript 버전도 보여줘"처럼 다른 언어로의 변환을 요청하면 LLM이 즉시 동등한 기능의 코드로 변환해준다. 다양한 언어를 사용하는 팀에서 단일 기술 문서 시스템으로 모든 구성원의 필요를 충족할 수 있다.

import subprocess
import tempfile
import os

def verify_generated_code(code: str, language: str = "python", timeout: int = 10) -> dict:
    """생성된 코드를 샌드박스 환경에서 실행하여 유효성 검증

    Args:
        code: 실행할 코드 문자열
        language: 프로그래밍 언어 (현재 python만 지원)
        timeout: 실행 타임아웃 (초)

    Returns:
        dict: {"success": bool, "output": str, "error": str}
    """
    if language != "python":
        return {"success": None, "output": "", "error": f"지원하지 않는 언어: {language}"}

    # 임시 파일에 코드 작성
    with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False, encoding='utf-8') as f:
        f.write(code)
        temp_path = f.name

    try:
        # 서브프로세스로 코드 실행 (타임아웃 적용)
        result = subprocess.run(
            ["python", temp_path],
            capture_output=True,
            text=True,
            timeout=timeout
        )
        return {
            "success": result.returncode == 0,
            "output": result.stdout[:500],  # 출력 500자 제한
            "error": result.stderr[:500] if result.returncode != 0 else ""
        }
    except subprocess.TimeoutExpired:
        return {"success": False, "output": "", "error": f"실행 타임아웃 ({timeout}초)"}
    finally:
        os.unlink(temp_path)  # 임시 파일 정리

# 기술 RAG 파이프라인에 코드 검증 통합
def tech_rag_with_verification(query: str) -> dict:
    """기술 RAG 응답 생성 + 코드 검증을 하나의 파이프라인으로"""
    response = tech_rag_chain.invoke(query)

    # 응답에서 코드 블록 추출
    code_blocks = re.findall(r'```python\n(.*?)```', response, re.DOTALL)

    verification_results = []
    for i, code in enumerate(code_blocks):
        result = verify_generated_code(code.strip())
        verification_results.append({
            "code_index": i,
            "success": result["success"],
            "error": result["error"]
        })

    return {
        "response": response,
        "code_verification": verification_results,
        "all_code_valid": all(v["success"] for v in verification_results if v["success"] is not None)
    }

 

      - 실무 권장: 코드 실행 검증(우선순위 1)은 기술 문서 RAG의 차별화 포인트가 된다. 단, 보안을 위해 반드시 격리된 샌드박스(Docker, Pyodide 등)에서 실행해야 하며, 네트워크 접근과 파일 시스템 쓰기를 차단해야 한다. 프로덕션에서는 `subprocess` 대신 Docker SDK 또는 AWS Lambda와 같은 서버리스 실행 환경을 권장한다.

 

6. 시나리오별 최적화 비교 요약

   6.1. 단계별 비교표

단계 시나리오 A (법률) 시나리오 B (FAQ) 시나리오 C (기술문서)
문서 로딩 조항 구조 파싱, 표→마크다운 Q&A 쌍 구조화, 메타데이터 강화 코드-텍스트 분리
청킹 조항 기반 계층적 분할 (2000자) 분할 없음 (Q&A 쌍 유지) 코드 보존 + 텍스트 분할
임베딩 모델 text-embedding-3-large (3072d) text-embedding-3-small (512d) text-embedding-3-large (3072d)
벡터 DB Pinecone (대량 조항) Chroma (소규모 FAQ) Pinecone (대량 문서+코드)
검색 방식 키워드변환 + MultiQuery 하이브리드(BM25+벡터) + 확장 코드/텍스트 병렬 검색
검색 k 값 k=6 (넓은 법적 근거) k=3 (정확한 FAQ 매칭) k=3(텍스트)+k=2(코드)
생성 모델 gpt-4o (정확한 법률 해석) gpt-4o-mini (비용 효율) gpt-4o (코드 품질)
온도 0 (결정적 답변) 0.1 (약간의 자연스러움) 0 (정확한 코드)
특수 처리 조항 번호 추적, 참조 명시 유사 질문 생성, 짧은 질의 확장 코드 접두사, 버전 필터


      6.1.1. 각 설정값의 이유 상세

         - 위 표의 설정값이 왜 그렇게 결정되었는지 이해하면, 새로운 도메인에 대해서도 최적의 설정을 스스로 결정할 수 있다.

 

         - 업데이트 사항: 각 시나리오에는 A.7~A.9, B.7~B.9, C.7~C.9 섹션이 추가되었다. 이 섹션들은 실행 예시(동작 흐름 추적), 결과 검증(자동 평가 함수), 고도화 제안(우선순위별 개선 방안)을 포함한다.

 

         6.1.1.1. 임베딩 모델 선택 기준

기준 고차원 모델 (3-large) 선택 저차원 모델 (3-small) 선택
텍스트 길이 긴 문서 (법률 조항, 기술 문서) 짧은 문서 (FAQ Q&A 쌍)
의미 구분 정밀도 미세한 차이 구분 필요 (소득공제 vs 세액공제) 대략적 매칭으로 충분
비용 민감도 정확도 > 비용 비용 ≈ 속도 > 극한 정밀도
문서 수 대규모 (수천~수만 조항) 소규모 (수백 FAQ)

 

         6.1.1.2. 검색 k 값 선택 기준

기준 k=6 (법률) k=3 (FAQ) k=3+k=2 (기술)
근거 관련 조항이 여러 개 FAQ는 1~2개가 정답 설명+코드 분리
노이즈 감수 (법적 근거 충분해야) 민감 (잘못된 FAQ 제공 위험) 중간
컨텍스트 비용 높음 허용 낮음 선호 중간

 

         6.1.1.3. temperature 선택 기준

temperature 의미 사용 시나리오
0 완전 결정적 (동일 입력 → 동일 출력) 법률 해석, 코드 생성 (정확성 필수)
0.1 거의 결정적 + 약간의 변형 FAQ 응답 (자연스러움 + 일관성)
0.3 적당한 변형 MultiQuery 변형 질의 생성 (다양성 필요)
0.7+ 높은 변형 창작, 브레인스토밍 (RAG에서는 거의 사용 안 함)


   6.2. 비용-품질 트레이드오프

시나리오 API 호출 횟수/질의 예상 비용/1000 질의 품질 우선순위
A (법률) 3~5회 $1.5~3.0 정확도 > 비용
B (FAQ) 1~2회 $0.1~0.3 비용 ≈ 속도 > 정확도
C (기술) 2~4회 $0.8~2.0 정확도 ≈ 코드품질 > 비용


      6.2.1. API 호출 횟수 상세 분석

         - 각 시나리오에서 하나의 질의가 처리될 때 발생하는 API 호출을 상세히 분석한다. 이는 비용 추정과 응답 시간 예측에 직접 활용된다.

 

         6.2.1.1. 시나리오 A (법률): 3~5회

1회차: dict_chain → gpt-4o-mini (키워드 사전 변환)
2~4회차: MultiQueryRetriever → gpt-4o-mini × 1~3회 (변형 질의 생성)
5회차: legal_rag_chain → gpt-4o (최종 답변 생성)
+ 임베딩 API 호출: 변환된 질의 + 변형 질의들의 임베딩

 

         6.2.1.2. 시나리오 B (FAQ): 1~2회

1회차 (선택): _expand_short_query → gpt-4o-mini (짧은 질의 확장, 10자 미만일 때만)
2회차: faq_rag_chain → gpt-4o-mini (최종 답변 생성)
+ 임베딩 API 호출: 질의 임베딩 1회


         6.2.1.3. 시나리오 C (기술): 2~4회

1회차: _to_code_query → llm (자연어→코드 키워드 변환)
2회차: tech_rag_chain → gpt-4o (최종 답변 생성)
+ 임베딩 API 호출: 원본 질의 + 코드 키워드 질의의 임베딩 2회

 

         6.2.1.4. 실무 권장: 

            - 비용 최적화가 필요한 경우, 가장 효과적인 방법은 LLM 호출 횟수를 줄이는 것이다. 법률 시나리오에서 dict_chain과 MultiQuery를 하나의 프롬프트로 통합하면 2회 호출로 줄일 수 있다. FAQ 시나리오에서 짧은 질의 확장 대신 BM25 가중치를 높이면 LLM 호출 없이 처리할 수 있다.

 

   6.3. 파이프라인 최적화 체크리스트

      - 각 시나리오의 RAG 파이프라인을 구축한 후 다음 체크리스트로 최적화 상태를 점검하라.

점검 항목 시나리오 A 시나리오 B 시나리오 C
문서 로딩 후 구조 보존 확인 조항 번호가 메타데이터에 있는가? Q&A 쌍이 분리되지 않았는가? 코드와 텍스트가 분리되었는가?
청킹 후 품질 검증 조항이 중간에 잘리지 않았는가? (청킹 없음) 코드 블록이 분할되지 않았는가?
임베딩 모델 적합성 유사 법률 용어가 구분되는가? 짧은 텍스트에서 매칭 정확한가? 코드-텍스트 교차 검색이 되는가?
검색 결과 품질 관련 조항을 모두 찾는가? 정확한 FAQ를 반환하는가? 코드+설명을 함께 찾는가?
생성 답변 품질 법적 근거가 명시되었는가? 3문장 이내 간결한가? 실행 가능한 코드가 포함되었는가?
비용 효율성 API 호출 횟수가 5회 이내인가? API 호출 횟수가 2회 이내인가? API 호출 횟수가 4회 이내인가?


   6.4. 시나리오 간 공통 최적화 패턴

      - 세 시나리오에서 공통적으로 적용할 수 있는 최적화 패턴이 있다. 이를 이해하면 새로운 도메인에도 빠르게 적용할 수 있다.

 

      6.4.1. 메타데이터 활용 패턴

         - 모든 시나리오에서 메타데이터를 적극적으로 활용한다. 메타데이터는 검색 필터링, 출처 추적, 결과 정렬에 사용된다.

# [공통 패턴] 메타데이터 기반 필터 검색
# 시나리오에 관계없이 메타데이터를 활용하면 검색 정밀도가 크게 향상됨

# 시나리오 A: 조항 유형 필터
results = db.similarity_search(query, k=4, filter={"type": "legal_article"})

# 시나리오 B: 카테고리 필터
results = db.similarity_search(query, k=3, filter={"category": "배송"})

# 시나리오 C: 코드/텍스트 + 버전 필터
results = db.similarity_search(query, k=3, filter={"content_type": "code", "version": "v2"})


      6.4.2. 질의 변환 패턴

         - 어휘 불일치 문제는 모든 도메인에서 발생한다. 질의 변환의 구체적 방법은 다르지만, "사용자 표현→도메인 표현" 변환이라는 핵심 패턴은 동일하다.

시나리오 질의 변환 방법 핵심 목적
A (법률) 키워드 사전 + MultiQuery 일상 용어→법률 용어
B (FAQ) 짧은 질의 확장 불완전 표현→구체적 질문
C (기술) 자연어→코드 키워드 설명적 표현→기술 키워드


      6.4.3. 프롬프트 설계 패턴

         - 모든 시나리오의 프롬프트에 공통적으로 포함해야 하는 요소가 있다.

# [공통 프롬프트 구조]
common_prompt_structure = """
당신은 {역할}입니다. 다음 규칙을 준수하세요:

1. {답변 형식 규칙}
2. {필수 포함 요소}
3. {금지 사항 - 특히 환각 방지 규칙}
4. {컨텍스트에 없는 내용 처리 규칙}
5. {추가 안내 규칙}

컨텍스트:
{context}

질문: {question}

답변:
"""
# 핵심: 모든 시나리오에서 "컨텍스트에 없는 내용 처리 규칙"은 반드시 포함해야 함
# 이 규칙이 없으면 LLM이 환각(hallucination)을 생성할 위험이 높음

 

         - 실무 권장: 새로운 도메인의 RAG를 구축할 때, 위 세 가지 공통 패턴(메타데이터 활용, 질의 변환, 프롬프트 설계)을 먼저 적용하고, 도메인 특화 최적화를 추가하는 순서로 진행하라. 이렇게 하면 기본 품질을 빠르게 확보한 후 점진적으로 개선할 수 있다.

 

   6.5. 누적 영향(Cascading Effect)과 단계별 최적화 우선순위

      - RAG 파이프라인의 각 단계 품질은 다음 단계에 누적적으로 전파된다. 각 단계가 90%의 품질이면 전체 품질은 약 59%로 떨어진다.

문서 로딩 90% × 청킹 90% × 임베딩 90% × 검색 90% × 생성 90% = 전체 약 59%
문서 로딩 95% × 청킹 95% × 임베딩 95% × 검색 95% × 생성 95% = 전체 약 77%


      - 따라서 최적화 우선순위는 앞 단계부터이다:

         - 가장 먼저 개선해야 할 단계는 문서 로딩이다.

            - 문서 로딩의 품질은 이후 모든 단계의 입력값을 결정하기 때문에, 여기서 구조 정보가 손실되면 아무리 나중 단계를 정교하게 설계해도 만회할 수 없다. 법률 문서의 조항 구조 보존, FAQ의 Q&A 쌍 유지, 기술 문서의 코드-텍스트 분리가 모두 이 단계에서 결정되며, 전체 파이프라인에 가장 큰 영향을 미친다.

 

         - 두 번째 우선순위는 청킹이다. 

            - 청킹 전략은 검색 정밀도를 직접 결정한다. 조항이 중간에 잘리거나 Q&A 쌍이 분리되거나 코드 블록이 분할되는 청킹 오류는 이후 어떤 검색 최적화를 해도 근본적으로 해결되지 않는다. 반대로 청킹을 올바르게 설정하면 검색 품질과 생성 품질이 동시에 개선되는 복합적인 효과가 있다.

 

         - 세 번째 우선순위는 검색이다. 

            - 검색 단계는 생성에 들어가는 컨텍스트의 품질을 직접 결정한다. 관련 문서를 찾지 못하면 LLM의 성능이 아무리 뛰어나도 올바른 답변을 생성할 수 없다. 키워드 사전, 다중 질의 생성(MultiQuery), 하이브리드 검색 등의 전략이 이 단계에서 적용되며, 이 단계를 개선하면 생성 단계의 품질이 즉시 향상된다.

 

         - 네 번째 우선순위는 임베딩이다. 

            - 임베딩 모델을 교체하는 것은 비용과 시간이 드는 작업이지만, 앞서 언급한 검색 방식의 개선(하이브리드 검색, 질의 변환 등)으로 어느 정도 보완이 가능하다. 따라서 검색 전략 최적화를 먼저 시도한 후, 그래도 개선이 부족하다고 판단될 때 임베딩 모델 교체를 고려하는 것이 효율적이다.

 

         - 다섯 번째 우선순위는 생성이다. 

            - 프롬프트 수정이나 LLM 모델 교체는 비교적 빠르게 시도할 수 있어서 가장 접근하기 쉬운 개선 수단처럼 보인다. 그러나 생성 단계는 검색 결과를 입력으로 받기 때문에, 앞 단계에서 잘못된 문서가 검색되는 근본 원인을 해결하지 않고 프롬프트만 수정하는 것은 임시방편에 그친다. 답변 형식이나 표현 방식의 개선에는 효과적이지만, 정보의 정확성은 앞 단계에서 결정된다는 점을 반드시 이해해야 한다.

 

      - 실무 권장: 

         - RAG 파이프라인의 품질 문제가 발생하면, 생성 단계(프롬프트)부터 수정하는 것이 가장 빠르지만, 근본적인 개선은 문서 로딩과 청킹 단계를 먼저 점검하는 것이 효과적이다. "쓰레기가 들어가면 쓰레기가 나온다(Garbage In, Garbage Out)" 원칙이 RAG에서도 적용된다.

댓글