이미지 로딩 중...

Context-aware Q&A 챗봇 구현 완벽 가이드 - 슬라이드 1/11
A

AI Generated

2025. 11. 16. · 6 Views

Context-aware Q&A 챗봇 구현 완벽 가이드

AI 챗봇에게 맥락을 이해하는 능력을 부여하는 방법을 배워봅니다. RAG(Retrieval-Augmented Generation) 패턴을 활용하여 사용자 질문에 정확하고 맥락있는 답변을 제공하는 챗봇을 구축하는 실전 가이드입니다.


목차

  1. RAG 시스템의 기본 구조 이해하기
  2. 문서 전처리와 청킹 전략
  3. 벡터 임베딩과 유사도 검색
  4. 프롬프트 엔지니어링으로 답변 품질 높이기
  5. 가능하면 관련 문서의 출처를 인용하세요
  6. 대화 기록을 활용한 멀티턴 대화
  7. 하이브리드 검색으로 정확도 높이기
  8. 답변 품질 평가와 개선
  9. 메타데이터 필터링으로 검색 정확도 높이기
  10. 문서 재순위화로 최종 정확도 향상
  11. 스트리밍 응답으로 사용자 경험 개선

1. RAG 시스템의 기본 구조 이해하기

시작하며

여러분이 AI 챗봇을 만들었는데, 사용자가 "우리 회사 휴가 정책이 뭐야?"라고 물었을 때 "죄송합니다, 그 정보는 모르겠습니다"라고 답한다면 어떨까요? 일반적인 ChatGPT 같은 모델은 학습 데이터에 없는 회사 내부 정보는 답변할 수 없습니다.

이런 문제는 실제 기업 챗봇 개발에서 가장 큰 장벽입니다. AI 모델은 아무리 똑똑해도 여러분의 회사 문서, 제품 매뉴얼, 고객 데이터 같은 특정 정보는 알 수 없기 때문입니다.

매번 모델을 재학습시키는 것은 비용도 많이 들고 현실적이지 않습니다. 바로 이럴 때 필요한 것이 RAG(Retrieval-Augmented Generation) 시스템입니다.

마치 시험 볼 때 교과서를 참고할 수 있는 것처럼, AI에게 필요한 문서를 찾아서 읽고 답변하게 만드는 기술입니다.

개요

간단히 말해서, RAG는 AI가 답변하기 전에 먼저 관련 문서를 찾아보고 그 내용을 참고하여 답변하는 시스템입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 기업의 내부 문서, 최신 뉴스, 제품 매뉴얼 같은 특정 데이터를 활용한 챗봇을 만들 때 매우 유용합니다.

예를 들어, 고객 지원 챗봇이 제품 사용 설명서를 참고하여 정확한 답변을 제공하거나, 사내 HR 챗봇이 회사 규정집을 검색하여 직원 질문에 답하는 경우에 필수적입니다. 기존에는 AI 모델을 특정 데이터로 재학습(fine-tuning)시켜야 했다면, 이제는 문서를 데이터베이스에 저장하고 필요할 때 검색하여 활용할 수 있습니다.

이는 비용도 적게 들고 문서 업데이트도 즉시 반영됩니다. RAG 시스템의 핵심 특징은 세 가지입니다.

첫째, 문서를 벡터로 변환하여 저장하는 '벡터 데이터베이스', 둘째, 사용자 질문과 유사한 문서를 찾는 '의미 기반 검색', 셋째, 검색된 문서와 질문을 함께 AI에게 전달하는 '프롬프트 엔지니어링'입니다. 이러한 특징들이 AI가 맥락을 이해하고 정확한 답변을 생성하는 데 결정적인 역할을 합니다.

코드 예제

# 기본 RAG 시스템 구조
from langchain.embeddings import OpenAIEmbeddings
from langchain.vectorstores import FAISS
from langchain.chat_models import ChatOpenAI
from langchain.chains import RetrievalQA

# 1. 문서를 벡터로 변환하여 저장
embeddings = OpenAIEmbeddings()
vector_store = FAISS.from_documents(documents, embeddings)

# 2. 검색기 설정 (상위 3개 문서 검색)
retriever = vector_store.as_retriever(search_kwargs={"k": 3})

# 3. QA 체인 생성 (검색 + 생성)
qa_chain = RetrievalQA.from_chain_type(
    llm=ChatOpenAI(temperature=0),
    retriever=retriever,
    return_source_documents=True  # 참고한 문서도 함께 반환
)

# 4. 질문하기
result = qa_chain({"query": "우리 회사 휴가 정책이 뭐야?"})
print(result["result"])  # AI 답변
print(result["source_documents"])  # 참고한 문서들

설명

이것이 하는 일: RAG 시스템은 사용자의 질문을 받으면 먼저 관련된 문서를 찾고, 그 문서 내용을 AI에게 전달하여 정확한 답변을 생성합니다. 첫 번째로, 문서를 벡터 데이터베이스에 저장하는 과정입니다.

FAISS.from_documents()는 여러분의 문서들을 숫자 배열(벡터)로 변환하여 저장합니다. 왜 이렇게 하는지 궁금하실 텐데요, 벡터로 변환하면 문서의 '의미'를 수학적으로 표현할 수 있어서 비슷한 의미를 가진 문서를 빠르게 찾을 수 있습니다.

그 다음으로, as_retriever()로 검색기를 만들면서 k=3 옵션을 설정합니다. 이는 질문과 가장 관련 있는 문서 3개를 찾아오라는 의미입니다.

내부적으로는 질문도 벡터로 변환한 후, 저장된 문서 벡터들과 비교하여 가장 유사한 것들을 찾아냅니다. 세 번째 단계에서 RetrievalQA.from_chain_type()이 실행되면서 검색과 생성을 하나로 연결합니다.

사용자가 질문하면 자동으로 관련 문서를 검색하고, 그 문서와 질문을 함께 AI에게 전달하여 답변을 생성합니다. return_source_documents=True 옵션은 AI가 어떤 문서를 참고했는지 확인할 수 있게 해줍니다.

여러분이 이 코드를 사용하면 특정 도메인 지식이 필요한 질문에도 정확하게 답변하는 챗봇을 만들 수 있습니다. 기업 내부 문서, 기술 매뉴얼, 법률 문서 등 어떤 종류의 데이터든 활용 가능하며, 문서가 업데이트되면 벡터 DB만 갱신하면 되므로 유지보수도 쉽습니다.

또한 답변의 출처를 확인할 수 있어 신뢰성도 높아집니다.

실전 팁

💡 k 값(검색할 문서 개수)은 3-5개가 적당합니다. 너무 많으면 AI가 불필요한 정보에 혼란을 겪고, 너무 적으면 중요한 정보를 놓칠 수 있습니다.

💡 temperature=0으로 설정하면 AI가 더 일관되고 정확한 답변을 제공합니다. 창의적인 답변이 필요하면 0.7 정도로 높이세요.

💡 항상 return_source_documents=True로 설정하여 AI가 참고한 문서를 확인하세요. 답변이 잘못되었을 때 원인을 파악하는 데 필수적입니다.

💡 벡터 DB는 한 번 만들면 저장해두고 재사용하세요. vector_store.save_local() 메서드로 로컬에 저장하면 다음에 다시 만들 필요가 없어 시간과 비용을 절약할 수 있습니다.

💡 문서가 많을 경우 FAISS보다 Pinecone이나 Weaviate 같은 클라우드 벡터 DB를 사용하면 성능과 확장성이 더 좋습니다.


2. 문서 전처리와 청킹 전략

시작하며

여러분이 100페이지짜리 매뉴얼을 통째로 AI에게 주면서 "이 중에서 필요한 내용 찾아봐"라고 하면 어떻게 될까요? AI는 토큰 제한 때문에 그렇게 긴 문서를 한 번에 처리할 수 없고, 설령 가능하더라도 비용이 엄청나게 많이 듭니다.

이런 문제는 실제 RAG 시스템 구축에서 가장 먼저 마주치는 난관입니다. 문서를 너무 크게 나누면 AI가 처리할 수 없고, 너무 작게 나누면 맥락이 끊겨서 정확한 답변을 할 수 없습니다.

마치 책을 읽는데 한 문장씩만 보여준다면 전체 스토리를 이해하기 어려운 것과 같습니다. 바로 이럴 때 필요한 것이 문서 청킹(Chunking) 전략입니다.

문서를 적절한 크기로 나누되, 의미가 유지되도록 자르는 기술이죠. 이렇게 하면 AI가 필요한 부분만 효율적으로 읽고 정확한 답변을 생성할 수 있습니다.

개요

간단히 말해서, 청킹은 긴 문서를 AI가 처리하기 좋은 크기의 조각들로 나누는 과정입니다. 각 조각은 의미상 완결성을 가져야 하며, 필요시 앞뒤 조각과의 중복(overlap)을 허용합니다.

왜 이 개념이 필요한지 실무 관점에서 설명하면, RAG 시스템의 성능은 청킹 전략에 크게 좌우됩니다. 예를 들어, 기술 문서에서 하나의 기능 설명이 여러 단락에 걸쳐 있다면, 그 내용을 하나의 청크로 유지해야 AI가 완전한 답변을 제공할 수 있습니다.

법률 문서나 계약서 같은 경우에는 조항별로 청킹하는 것이 효과적입니다. 기존에는 단순히 글자 수나 단어 수로 기계적으로 자르는 방식을 사용했다면, 이제는 문서의 구조(제목, 단락, 문장)를 고려하여 의미 있는 단위로 나눌 수 있습니다.

이는 검색 정확도를 크게 향상시킵니다. 문서 청킹의 핵심 특징은 세 가지입니다.

첫째, 청크 크기(chunk_size)로 각 조각의 크기를 제어하고, 둘째, 중복(overlap)으로 청크 간 맥락 연결을 유지하며, 셋째, 구분자(separator)로 의미 있는 위치에서 자릅니다. 이러한 특징들이 검색 품질과 답변 정확도에 직접적인 영향을 미칩니다.

코드 예제

from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.document_loaders import PyPDFLoader

# 1. PDF 문서 로드
loader = PyPDFLoader("company_policy.pdf")
documents = loader.load()

# 2. 청킹 설정 (재귀적 방식으로 의미 단위 분할)
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,  # 각 청크는 1000자 정도
    chunk_overlap=200,  # 앞뒤 200자씩 중복 허용
    separators=["\n\n", "\n", ".", " ", ""],  # 우선순위: 단락 > 줄 > 문장 > 단어
    length_function=len  # 길이 측정 함수
)

# 3. 문서를 청크로 분할
chunks = text_splitter.split_documents(documents)

# 4. 각 청크에 메타데이터 추가 (검색 필터링용)
for i, chunk in enumerate(chunks):
    chunk.metadata["chunk_id"] = i
    chunk.metadata["source"] = "company_policy.pdf"
    chunk.metadata["page"] = chunk.metadata.get("page", 0)

print(f"총 {len(chunks)}개의 청크 생성됨")

설명

이것이 하는 일: 문서 청킹 시스템은 PDF나 텍스트 파일을 읽어서 의미 있는 단위로 나누고, 각 조각에 검색을 위한 메타데이터를 추가합니다. 첫 번째로, PyPDFLoader로 PDF 문서를 로드합니다.

이 로더는 PDF의 텍스트를 추출하면서 페이지 번호 같은 메타데이터도 함께 가져옵니다. 왜 이렇게 하는지 궁금하실 텐데요, 나중에 사용자에게 "이 답변은 5페이지에서 찾았습니다"라고 출처를 알려줄 수 있기 때문입니다.

그 다음으로, RecursiveCharacterTextSplitter가 실행되면서 문서를 똑똑하게 나눕니다. chunk_size=1000은 각 조각이 약 1000자가 되도록 하라는 의미인데, 이는 대부분의 LLM에서 처리하기 좋은 크기입니다.

chunk_overlap=200은 앞 조각의 끝 200자와 뒤 조각의 시작 200자가 겹치게 하는데, 이렇게 하면 중요한 정보가 청크 경계에서 잘릴 위험이 줄어듭니다. 가장 중요한 부분은 separators 설정입니다.

시스템은 먼저 \n\n(단락 구분)으로 나누려고 시도하고, 안 되면 \n(줄바꿈)으로, 그것도 안 되면 .(문장 끝)으로 나눕니다. 이렇게 하면 단어 중간에서 끊기는 것이 아니라 자연스러운 위치에서 분할됩니다.

마치 종이를 찢을 때 접선을 따라 찢는 것처럼 말이죠. 여러분이 이 코드를 사용하면 문서의 구조를 유지하면서 효율적으로 분할할 수 있습니다.

검색 정확도가 올라가고, AI가 더 완전한 답변을 제공하며, 비용도 절감됩니다. 또한 메타데이터를 추가함으로써 나중에 특정 페이지나 섹션만 검색하는 필터링 기능도 구현할 수 있습니다.

실전 팁

💡 chunk_size는 문서 특성에 따라 조정하세요. 기술 문서는 1000-1500자, 법률 문서는 500-800자, 대화형 로그는 300-500자가 적당합니다.

💡 chunk_overlap은 chunk_size의 10-20%로 설정하는 것이 일반적입니다. 너무 크면 중복이 많아져 비용이 증가하고, 너무 작으면 맥락이 끊깁니다.

💡 RecursiveCharacterTextSplitter 대신 SemanticChunker를 사용하면 의미적으로 더 정확하게 분할할 수 있지만, 처리 시간이 더 걸립니다. 성능과 정확도 사이의 트레이드오프를 고려하세요.

💡 청크 생성 후에는 반드시 몇 개를 샘플링해서 내용을 확인하세요. 중요한 정보가 여러 청크에 나뉘어져 있거나 의미 없는 위치에서 잘렸는지 검증이 필요합니다.

💡 다국어 문서의 경우 언어별로 다른 separator를 사용하세요. 한국어는 문장 종결 부호(".", "!", "?")와 문단 구분을 우선시하는 것이 좋습니다.


3. 벡터 임베딩과 유사도 검색

시작하며

여러분이 도서관에서 "인공지능 윤리"에 관한 책을 찾는다고 생각해보세요. 사서에게 물어보면 "AI ethics", "기계 학습 도덕성", "알고리즘 편향" 같은 관련 책들도 함께 추천해줍니다.

어떻게 단어는 다르지만 비슷한 주제라는 걸 알 수 있을까요? 이런 문제는 전통적인 키워드 검색으로는 해결할 수 없습니다.

"인공지능 윤리"라는 단어가 정확히 들어있는 문서만 찾기 때문에, "AI의 도덕적 책임"처럼 다른 표현으로 쓰인 내용은 놓치게 됩니다. 이는 검색 품질을 크게 떨어뜨립니다.

바로 이럴 때 필요한 것이 벡터 임베딩(Vector Embedding)입니다. 문장이나 단어의 '의미'를 숫자 배열로 변환하여, 비슷한 의미를 가진 텍스트들이 수학적으로도 가까운 위치에 배치되게 만드는 기술입니다.

개요

간단히 말해서, 벡터 임베딩은 텍스트를 수백 또는 수천 개의 숫자로 이루어진 배열로 변환하는 과정입니다. 이 숫자들은 텍스트의 의미를 수학적으로 표현합니다.

왜 이 개념이 필요한지 실무 관점에서 설명하면, 의미 기반 검색(Semantic Search)을 가능하게 하기 때문입니다. 예를 들어, 사용자가 "월급은 언제 받나요?"라고 물으면, 문서에 "급여 지급일"이라고 쓰여 있어도 찾을 수 있습니다.

고객 지원 챗봇이나 문서 검색 시스템에서 이는 필수적인 기능입니다. 기존에는 정확한 단어 매칭(BM25, TF-IDF)만 가능했다면, 이제는 동의어, 유사 표현, 심지어 다른 언어로 쓰인 내용까지 찾을 수 있습니다.

검색 재현율(recall)이 크게 향상됩니다. 벡터 임베딩의 핵심 특징은 세 가지입니다.

첫째, 고차원 공간(보통 768차원 또는 1536차원)에서 텍스트를 표현하고, 둘째, 코사인 유사도(cosine similarity)로 의미적 거리를 측정하며, 셋째, 사전 학습된 모델(OpenAI, Cohere, Sentence-BERT 등)을 활용합니다. 이러한 특징들이 RAG 시스템의 검색 정확도를 결정합니다.

코드 예제

from langchain.embeddings import OpenAIEmbeddings
from langchain.vectorstores import FAISS
import numpy as np

# 1. 임베딩 모델 초기화
embeddings = OpenAIEmbeddings(
    model="text-embedding-ada-002",  # OpenAI의 최신 임베딩 모델
    openai_api_key="your-api-key"
)

# 2. 텍스트를 벡터로 변환
texts = [
    "월급은 매월 25일에 지급됩니다",
    "휴가는 연간 15일 제공됩니다",
    "급여 지급일은 매달 25일입니다"
]
text_embeddings = embeddings.embed_documents(texts)

# 3. 쿼리도 벡터로 변환
query = "월급 언제 받나요?"
query_embedding = embeddings.embed_query(query)

# 4. 코사인 유사도 계산 (수동 방식 예시)
def cosine_similarity(vec1, vec2):
    return np.dot(vec1, vec2) / (np.linalg.norm(vec1) * np.linalg.norm(vec2))

# 5. 유사도 점수 출력
for i, text in enumerate(texts):
    similarity = cosine_similarity(query_embedding, text_embeddings[i])
    print(f"'{text}': {similarity:.4f}")

설명

이것이 하는 일: 벡터 임베딩 시스템은 텍스트를 고차원 벡터로 변환하고, 쿼리와 문서 간의 의미적 유사도를 계산하여 가장 관련 있는 내용을 찾아냅니다. 첫 번째로, OpenAIEmbeddings로 임베딩 모델을 초기화합니다.

text-embedding-ada-002는 OpenAI의 최신 임베딩 모델로, 1536개의 차원을 가진 벡터를 생성합니다. 왜 이렇게 많은 차원이 필요한지 궁금하실 텐데요, 텍스트의 복잡한 의미를 표현하려면 많은 수의 숫자가 필요하기 때문입니다.

마치 3D 공간(x, y, z 3개 차원)보다 더 높은 차원에서 의미를 표현하는 것이죠. 그 다음으로, embed_documents()embed_query()가 실행되면서 텍스트들을 벡터로 변환합니다.

주목할 점은 "월급은 매월 25일에 지급됩니다"와 "급여 지급일은 매달 25일입니다"는 단어가 다르지만 의미가 비슷하므로, 변환된 벡터들이 수학적으로 가까운 위치에 놓입니다. 내부적으로는 딥러닝 모델이 문장의 문맥과 의미를 분석하여 이 숫자들을 생성합니다.

코사인 유사도 계산 부분에서는 두 벡터 사이의 각도를 측정합니다. 값이 1에 가까우면 의미가 매우 비슷하고, 0에 가까우면 관련이 없다는 뜻입니다.

"월급 언제 받나요?"라는 쿼리는 "월급은 매월 25일에 지급됩니다" 및 "급여 지급일은 매달 25일입니다"와 높은 유사도를 보이지만, "휴가는 연간 15일 제공됩니다"와는 낮은 유사도를 보입니다. 여러분이 이 코드를 사용하면 키워드가 정확히 일치하지 않아도 의미가 비슷한 문서를 찾을 수 있습니다.

사용자가 다양한 표현으로 질문해도 정확한 답변을 제공할 수 있고, 다국어 임베딩 모델을 사용하면 언어가 달라도 검색이 가능합니다. 또한 한 번 벡터로 변환하면 검색 속도가 매우 빠르며, 수백만 개의 문서에서도 밀리초 단위로 검색할 수 있습니다.

실전 팁

💡 OpenAI 임베딩 외에도 Cohere, Sentence-BERT, 한국어 특화 KoSimCSE 같은 대안이 있습니다. 한국어 문서가 많다면 한국어 특화 모델이 더 정확합니다.

💡 임베딩 비용을 줄이려면 문서는 한 번만 임베딩하여 벡터 DB에 저장하고, 쿼리만 매번 임베딩하세요. 문서 임베딩은 재사용 가능합니다.

💡 유사도 임계값(threshold)을 설정하여 관련성 낮은 결과를 필터링하세요. 일반적으로 0.7 이상인 결과만 사용하면 품질이 좋습니다.

💡 하이브리드 검색(키워드 검색 + 벡터 검색)을 사용하면 더 좋은 결과를 얻을 수 있습니다. BM25로 1차 필터링 후 벡터 검색으로 재순위화하는 방식이 효과적입니다.

💡 임베딩 차원 수가 높을수록 정확하지만 저장 공간과 계산 비용이 증가합니다. 실시간 성능이 중요하다면 384차원 모델도 고려하세요.


4. 프롬프트 엔지니어링으로 답변 품질 높이기

시작하며

여러분이 AI에게 검색된 문서를 주면서 "이거 읽고 답해줘"라고만 하면 어떻게 될까요? AI는 문서 내용을 그대로 복사해서 답하거나, 질문과 관계없는 내용을 말하거나, 심지어 검색된 문서에 없는 내용을 지어낼 수도 있습니다.

이런 문제는 실제 프로덕션 환경에서 심각한 이슈입니다. 사용자에게 잘못된 정보를 제공하면 신뢰를 잃게 되고, 특히 금융이나 의료 같은 분야에서는 법적 문제까지 발생할 수 있습니다.

AI가 "환각(hallucination)"을 일으켜 없는 정보를 만들어내는 것은 RAG 시스템의 가장 큰 적입니다. 바로 이럴 때 필요한 것이 프롬프트 엔지니어링입니다.

AI에게 정확한 역할, 제약 조건, 답변 형식을 명확히 지시하여 신뢰할 수 있는 답변을 생성하게 만드는 기술입니다.

개요

간단히 말해서, 프롬프트 엔지니어링은 AI에게 주는 지시문을 최적화하여 원하는 품질의 답변을 이끌어내는 과정입니다. 검색된 문서와 질문을 어떻게 조합하여 AI에게 전달하느냐가 결과를 크게 좌우합니다.

왜 이 개념이 필요한지 실무 관점에서 설명하면, 같은 문서와 질문이라도 프롬프트에 따라 답변 품질이 천차만별이기 때문입니다. 예를 들어, "문서에 없는 내용은 절대 만들지 말고 모른다고 답하라"는 지시가 있으면 환각을 크게 줄일 수 있습니다.

고객 지원 챗봇에서는 친절한 말투를, 기술 문서 챗봇에서는 정확하고 간결한 답변을 유도할 수 있습니다. 기존에는 단순히 문서와 질문을 이어 붙이는 방식을 사용했다면, 이제는 시스템 메시지, few-shot 예시, 체인 오브 씽킹(Chain-of-Thought) 같은 고급 기법을 활용합니다.

이는 답변의 정확도, 일관성, 유용성을 모두 향상시킵니다. 프롬프트 엔지니어링의 핵심 특징은 세 가지입니다.

첫째, 명확한 역할 정의(예: "당신은 친절한 HR 도우미입니다"), 둘째, 엄격한 제약 조건(예: "문서에 없으면 모른다고 답하세요"), 셋째, 구조화된 답변 형식(예: "답변 후 출처를 표시하세요")입니다. 이러한 특징들이 AI의 행동을 제어하고 예측 가능하게 만듭니다.

코드 예제

from langchain.prompts import ChatPromptTemplate
from langchain.chat_models import ChatOpenAI
from langchain.schema.runnable import RunnablePassthrough
from langchain.schema.output_parser import StrOutputParser

# 1. 고품질 프롬프트 템플릿 정의
template = """당신은 회사 HR 정책 전문 도우미입니다. 아래 문서를 참고하여 질문에 답변하세요.

**중요 지침:**

5. 가능하면 관련 문서의 출처를 인용하세요

설명

이것이 하는 일: 프롬프트 엔지니어링 시스템은 검색된 문서와 사용자 질문을 최적화된 형식으로 AI에게 전달하여, 정확하고 유용한 답변을 생성하도록 유도합니다. 첫 번째로, ChatPromptTemplate에서 상세한 지침을 정의합니다.

"당신은 회사 HR 정책 전문 도우미입니다"라는 역할 설정은 AI가 일관된 톤과 스타일로 답변하게 만듭니다. 왜 이렇게 하는지 궁금하실 텐데요, AI는 역할이 주어지면 그에 맞는 방식으로 사고하고 답변하기 때문입니다.

마치 배우가 대본을 받고 캐릭터에 몰입하는 것과 비슷합니다. 가장 중요한 부분은 "제공된 문서의 내용만을 기반으로 답변하세요"와 "문서에 정보가 없다면 모른다고 답하세요"라는 제약 조건입니다.

이 두 줄이 환각(hallucination)을 극적으로 줄여줍니다. AI는 기본적으로 그럴듯한 답변을 생성하려는 경향이 있는데, 이 지침이 없으면 문서에 없는 내용을 지어낼 수 있습니다.

그 다음으로, RunnablePassthrough()를 사용한 체인 구성이 실행됩니다. {"context": retriever, "question": RunnablePassthrough()}는 사용자 질문을 받아서 자동으로 관련 문서를 검색하고(retriever), 그 결과와 질문을 프롬프트 템플릿의 {context}{question} 자리에 삽입합니다.

이후 GPT-4가 이 완성된 프롬프트를 받아 답변을 생성합니다. 여러분이 이 코드를 사용하면 AI가 훨씬 더 신뢰할 수 있는 답변을 제공합니다.

환각을 최소화하고, 일관된 톤을 유지하며, 출처를 명확히 할 수 있습니다. 또한 프롬프트만 수정하면 답변 스타일을 쉽게 바꿀 수 있어, 같은 시스템을 다양한 도메인(HR, 기술 지원, 법률 등)에 적용할 수 있습니다.

temperature=0으로 설정하여 매번 일관된 답변을 얻는 것도 프로덕션 환경에서 중요합니다.

실전 팁

💡 few-shot 예시를 추가하면 품질이 크게 향상됩니다. "예시: 질문 - 휴가는 며칠? 답변 - 문서에 따르면 연 15일입니다" 같은 좋은 답변 샘플을 2-3개 제시하세요.

💡 답변 길이를 제한하고 싶다면 "3문장 이내로 답하세요" 같은 구체적인 지시를 추가하세요. 비용 절감과 사용자 경험 개선에 도움이 됩니다.

💡 Chain-of-Thought를 사용하려면 "먼저 문서에서 관련 부분을 인용하고, 그다음 답변을 작성하세요"라고 지시하세요. 추론 과정을 볼 수 있어 디버깅이 쉽습니다.

💡 다국어 지원이 필요하면 "질문 언어와 같은 언어로 답변하세요"를 추가하세요. 한국어로 물으면 한국어로, 영어로 물으면 영어로 자동 대응합니다.

💡 프롬프트 버전 관리를 하세요. 프롬프트 변경이 답변 품질에 미치는 영향을 추적하고, A/B 테스트로 최적의 프롬프트를 찾으세요.


5. 대화 기록을 활용한 멀티턴 대화

시작하며

여러분이 챗봇에게 "우리 회사 휴가 정책이 뭐야?"라고 물어보고 답을 들은 후, "그럼 병가는?"이라고 물으면 챗봇이 "무슨 병가를 말씀하시는 건가요?"라고 되묻는다면 당황스럽겠죠? 방금 전 대화 맥락을 전혀 이해하지 못하는 것입니다.

이런 문제는 일반적인 RAG 시스템의 큰 약점입니다. 각 질문을 독립적으로 처리하기 때문에 이전 대화 내용을 기억하지 못합니다.

실제 사용자들은 대화하듯이 연속된 질문을 하는데, 시스템이 매번 문맥을 잊어버리면 사용자 경험이 매우 나빠집니다. 바로 이럴 때 필요한 것이 대화 기록 관리(Conversation History)입니다.

이전 질문과 답변을 저장하고, 새로운 질문을 이해할 때 이 맥락을 함께 고려하는 기술입니다. 마치 사람처럼 대화의 흐름을 기억하고 이어나갈 수 있게 됩니다.

개요

간단히 말해서, 멀티턴 대화는 여러 번의 주고받기를 하나의 대화 흐름으로 연결하여, AI가 이전 문맥을 이해하고 답변하게 만드는 기능입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 실제 사용자들은 한 번의 질문으로 끝나는 경우가 거의 없기 때문입니다.

예를 들어, "재택근무 정책이 뭐야?" → "주 몇 일까지 가능해?" → "신청은 어떻게 해?" 같은 연속 질문이 일반적입니다. 고객 지원, 교육용 챗봇, 비서 에이전트 등에서는 대화 흐름 유지가 필수입니다.

기존에는 각 질문을 독립적으로 처리했다면, 이제는 대화 기록을 누적하여 "그럼", "그건", "추가로" 같은 지시어를 이해할 수 있습니다. 사용자는 더 자연스럽게 대화할 수 있고, 시스템은 더 정확한 의도 파악이 가능합니다.

멀티턴 대화의 핵심 특징은 세 가지입니다. 첫째, 대화 기록 저장(ConversationBufferMemory)으로 이전 대화를 유지하고, 둘째, 질문 재구성(question reformulation)으로 현재 질문을 독립적으로 이해 가능하게 변환하며, 셋째, 문맥 윈도우 관리로 토큰 제한 내에서 적절한 양의 기록만 유지합니다.

이러한 특징들이 자연스러운 대화 경험을 만듭니다.

코드 예제

from langchain.memory import ConversationBufferMemory
from langchain.chains import ConversationalRetrievalChain
from langchain.chat_models import ChatOpenAI

# 1. 대화 기록 메모리 초기화
memory = ConversationBufferMemory(
    memory_key="chat_history",  # 대화 기록 저장 키
    return_messages=True,  # 메시지 형식으로 반환
    output_key="answer"  # 답변을 메모리에 저장
)

# 2. 멀티턴 대화 체인 생성
conversation_chain = ConversationalRetrievalChain.from_llm(
    llm=ChatOpenAI(temperature=0, model="gpt-4"),
    retriever=retriever,
    memory=memory,
    return_source_documents=True,  # 참고 문서도 반환
    verbose=True  # 디버깅용 로그 출력
)

# 3. 첫 번째 질문
result1 = conversation_chain({"question": "우리 회사 휴가 정책이 뭐야?"})
print("답변:", result1["answer"])

# 4. 두 번째 질문 (이전 문맥 활용)
result2 = conversation_chain({"question": "그럼 병가는 며칠이야?"})
print("답변:", result2["answer"])  # "그럼"을 이해하고 휴가 정책 문맥에서 답변

# 5. 대화 기록 확인
print("\n대화 기록:", memory.load_memory_variables({})["chat_history"])

설명

이것이 하는 일: 멀티턴 대화 시스템은 각 질문-답변 쌍을 메모리에 저장하고, 새로운 질문이 들어오면 이전 대화 맥락과 함께 처리하여 자연스러운 대화를 만들어냅니다. 첫 번째로, ConversationBufferMemory로 대화 기록 저장소를 만듭니다.

return_messages=True는 대화를 "Human: ..., AI: ..." 형식으로 저장하라는 의미입니다. 왜 이렇게 하는지 궁금하실 텐데요, AI 모델은 이런 대화 형식을 학습할 때 사용했기 때문에 이 형식으로 주면 맥락을 더 잘 이해합니다.

그 다음으로, ConversationalRetrievalChain이 실행되면서 일반 RAG와 다른 마법이 일어납니다. 사용자가 "그럼 병가는 며칠이야?"라고 물으면, 시스템은 내부적으로 이전 대화를 참고하여 이 질문을 "회사의 병가 정책은 며칠까지 사용할 수 있습니까?"처럼 독립적인 질문으로 재구성합니다.

이 과정을 질문 재구성(question reformulation)이라고 하는데, 이렇게 해야 벡터 검색이 정확하게 작동합니다. 세 번째 단계에서는 재구성된 질문으로 문서를 검색하고, 검색된 문서와 대화 기록을 모두 AI에게 전달합니다.

AI는 "사용자가 처음에 휴가 정책을 물어봤고, 이제 병가에 대해 추가로 궁금해하는구나"라는 맥락을 이해하고, 자연스러운 답변을 생성합니다. 답변이 완료되면 이 새로운 질문-답변 쌍도 메모리에 추가됩니다.

여러분이 이 코드를 사용하면 사용자가 여러 번 질문하며 깊이 있게 정보를 탐색할 수 있습니다. "더 자세히 설명해줘", "예시를 들어줘", "이전 답변과 비교하면?" 같은 후속 질문이 자연스럽게 처리됩니다.

고객 만족도가 크게 향상되며, 한 세션에서 더 많은 정보를 제공할 수 있어 효율적입니다. 또한 verbose=True 옵션으로 내부 동작을 확인하면서 시스템을 개선할 수 있습니다.

실전 팁

💡 ConversationBufferMemory는 모든 대화를 저장하므로 긴 대화에서는 토큰 제한에 걸릴 수 있습니다. ConversationSummaryMemory나 ConversationBufferWindowMemory를 사용하여 최근 N개 메시지만 유지하세요.

💡 세션 관리가 필요하다면 각 사용자별로 별도의 메모리 인스턴스를 만들고 세션 ID로 구분하세요. Redis나 데이터베이스에 저장하면 서버 재시작 후에도 대화를 이어갈 수 있습니다.

💡 질문 재구성 과정을 커스터마이징하려면 condense_question_prompt를 수정하세요. "이전 대화를 고려하여 다음 질문을 독립적인 질문으로 변환하세요" 같은 지시를 조정할 수 있습니다.

💡 대화가 주제를 크게 벗어났다면 메모리를 초기화하세요. memory.clear()로 새로운 대화를 시작할 수 있습니다. 사용자에게 "새 주제로 대화하시겠습니까?" 같은 옵션을 제공하는 것도 좋습니다.

💡 return_source_documents=True로 항상 출처를 함께 반환하세요. 멀티턴 대화에서는 AI가 어느 시점의 문서를 참고했는지 추적하기 어려우므로 투명성 확보가 중요합니다.


6. 하이브리드 검색으로 정확도 높이기

시작하며

여러분이 "AWS EC2 t3.micro 인스턴스 가격"을 검색한다고 생각해보세요. 벡터 검색만 사용하면 "AWS EC2 요금", "클라우드 컴퓨팅 비용" 같은 일반적인 내용을 찾아올 수 있지만, 정확히 "t3.micro"라는 특정 인스턴스 타입을 놓칠 수 있습니다.

이런 문제는 순수 벡터 검색의 한계입니다. 의미는 비슷하지만 정확한 키워드가 중요한 경우(제품명, 버전 번호, 고유 명사 등)에는 전통적인 키워드 검색이 더 효과적일 수 있습니다.

한 가지 방법만 사용하면 다른 방법의 장점을 놓치게 됩니다. 바로 이럴 때 필요한 것이 하이브리드 검색(Hybrid Search)입니다.

벡터 검색(의미 기반)과 키워드 검색(정확한 매칭)을 동시에 수행하고 결과를 결합하여, 두 방법의 장점을 모두 활용하는 기술입니다.

개요

간단히 말해서, 하이브리드 검색은 의미 기반 검색(Dense Retrieval)과 키워드 기반 검색(Sparse Retrieval)을 동시에 실행하고, 두 결과를 가중치 조합하여 최종 순위를 매기는 방법입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 실제 사용자 질문은 매우 다양하기 때문입니다.

예를 들어, "파이썬 3.11 설치 방법"처럼 정확한 버전이 중요한 기술 문서나, "LG 그램 17인치" 같은 특정 제품 스펙 검색에서는 키워드 매칭이 필수입니다. 반면 "노트북 배터리를 오래 쓰려면?"은 의미 기반 검색이 더 적합합니다.

기존에는 하나의 검색 방법만 선택해야 했다면, 이제는 두 가지를 동시에 사용하여 서로의 약점을 보완할 수 있습니다. 연구 결과에 따르면 하이브리드 검색은 단일 방법 대비 10-20% 정확도 향상을 보입니다.

하이브리드 검색의 핵심 특징은 세 가지입니다. 첫째, BM25 같은 희소 검색 알고리즘으로 키워드 매칭을 수행하고, 둘째, 벡터 임베딩으로 의미 유사도를 계산하며, 셋째, RRF(Reciprocal Rank Fusion)나 가중치 합으로 두 결과를 결합합니다.

이러한 특징들이 검색 재현율(recall)과 정밀도(precision)를 동시에 높입니다.

코드 예제

from langchain.retrievers import BM25Retriever, EnsembleRetriever
from langchain.vectorstores import FAISS
from langchain.embeddings import OpenAIEmbeddings

# 1. 문서 준비
documents = [...]  # 여러분의 문서들

# 2. BM25 키워드 검색기 (희소 검색)
bm25_retriever = BM25Retriever.from_documents(documents)
bm25_retriever.k = 3  # 상위 3개 반환

# 3. FAISS 벡터 검색기 (밀집 검색)
embeddings = OpenAIEmbeddings()
vector_store = FAISS.from_documents(documents, embeddings)
vector_retriever = vector_store.as_retriever(search_kwargs={"k": 3})

# 4. 하이브리드 검색기 생성 (두 검색기 결합)
hybrid_retriever = EnsembleRetriever(
    retrievers=[bm25_retriever, vector_retriever],
    weights=[0.4, 0.6]  # BM25: 40%, Vector: 60% 가중치
)

# 5. 검색 실행
query = "AWS EC2 t3.micro 인스턴스 가격"
results = hybrid_retriever.get_relevant_documents(query)

# 6. 결과 확인
for i, doc in enumerate(results):
    print(f"{i+1}. {doc.page_content[:100]}...")

설명

이것이 하는 일: 하이브리드 검색 시스템은 BM25 키워드 검색과 벡터 의미 검색을 병렬로 실행하고, 각 결과에 가중치를 적용하여 최종 순위를 결정합니다. 첫 번째로, BM25Retriever로 전통적인 키워드 검색기를 만듭니다.

BM25는 TF-IDF의 개선 버전으로, 단어 빈도와 문서 길이를 고려하여 관련성을 계산합니다. 왜 이것이 중요한지 궁금하실 텐데요, "t3.micro"처럼 특정 키워드가 정확히 포함된 문서를 찾는 데는 이 방법이 벡터 검색보다 훨씬 효과적입니다.

그 다음으로, FAISS로 벡터 검색기를 만듭니다. 이는 앞에서 배운 의미 기반 검색입니다.

"인스턴스 가격"을 "비용", "요금", "pricing" 같은 유사 표현으로도 찾을 수 있습니다. 두 검색기가 각각 독립적으로 작동하면서 서로 다른 관점에서 관련 문서를 찾아냅니다.

가장 중요한 부분은 EnsembleRetriever입니다. weights=[0.4, 0.6]은 BM25 결과에 40%, 벡터 검색 결과에 60%의 가중치를 준다는 의미입니다.

내부적으로는 RRF(Reciprocal Rank Fusion) 알고리즘을 사용하여 두 결과를 합칩니다. 예를 들어, BM25에서 1위였던 문서가 벡터 검색에서는 5위였다면, 두 순위를 모두 고려하여 최종 순위를 매깁니다.

이렇게 하면 한 방법에서 놓친 좋은 문서를 다른 방법이 잡아낼 수 있습니다. 여러분이 이 코드를 사용하면 검색 정확도가 크게 향상됩니다.

기술 문서, 제품 카탈로그, 법률 문서 같은 정확한 용어가 중요한 도메인에서 특히 효과적입니다. 또한 가중치를 조정하면서 여러분의 데이터에 최적화할 수 있습니다.

기술 문서는 키워드 가중치를 높이고(0.6, 0.4), 일반 문서는 벡터 가중치를 높이는(0.3, 0.7) 식으로 튜닝 가능합니다.

실전 팁

💡 가중치는 실험적으로 결정하세요. 테스트 쿼리 50-100개를 준비하고, 다양한 가중치 조합(0.5/0.5, 0.4/0.6, 0.3/0.7)을 시도하여 최적값을 찾으세요.

💡 도메인에 따라 가중치를 동적으로 조정할 수 있습니다. 질문에 버전 번호나 고유 명사가 많으면 키워드 검색 가중치를 높이고, 추상적인 질문이면 벡터 검색 가중치를 높이세요.

💡 BM25는 전처리(토큰화, 불용어 제거, 스테밍)에 민감합니다. 한국어는 형태소 분석기(KoNLPy)를 사용하면 성능이 좋아집니다.

💡 더 많은 검색기를 결합할 수도 있습니다. 예: BM25 + 벡터 검색 + 메타데이터 필터링의 3-way 앙상블도 가능합니다.

💡 성능이 중요하다면 두 검색을 병렬로 실행하세요. asyncio를 사용하면 검색 시간을 절반으로 줄일 수 있습니다.


7. 답변 품질 평가와 개선

시작하며

여러분이 RAG 챗봇을 만들고 배포했는데, 사용자들이 "답변이 정확하지 않아요", "질문과 상관없는 답을 해요"라고 불평한다면 어떻게 하시겠어요? 문제가 있다는 건 알겠는데, 정확히 무엇이 잘못되었고 어떻게 고쳐야 할지 모르는 상황입니다.

이런 문제는 실제 프로덕션 환경에서 가장 어려운 도전 과제입니다. RAG 시스템은 검색, 프롬프트, LLM 등 여러 구성 요소로 이루어져 있어서, 어느 부분이 문제인지 파악하기 어렵습니다.

주관적인 느낌만으로는 시스템을 개선할 수 없습니다. 바로 이럴 때 필요한 것이 자동화된 평가 시스템입니다.

답변의 정확성, 관련성, 완전성을 객관적으로 측정하고, 문제가 되는 부분을 정량적으로 파악하여 개선하는 기술입니다. 마치 학생이 시험을 보고 점수를 확인하여 약점을 보완하는 것과 같습니다.

개요

간단히 말해서, RAG 평가는 시스템이 생성한 답변을 여러 기준(정확성, 관련성, 완전성 등)으로 자동 채점하여 성능을 측정하고 개선 포인트를 찾는 과정입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 시스템 변경(새로운 임베딩 모델, 다른 청킹 전략, 프롬프트 수정 등)의 효과를 객관적으로 비교해야 하기 때문입니다.

예를 들어, 청크 크기를 1000자에서 1500자로 늘렸을 때 답변 품질이 실제로 좋아졌는지 숫자로 확인할 수 있어야 합니다. A/B 테스트나 지속적 개선이 필요한 엔터프라이즈 환경에서는 필수입니다.

기존에는 사람이 직접 답변을 읽고 평가해야 했다면, 이제는 LLM을 심판(judge)으로 사용하여 자동으로 평가할 수 있습니다. 수백 개의 테스트 케이스를 몇 분 만에 평가 가능합니다.

RAG 평가의 핵심 지표는 네 가지입니다. 첫째, Faithfulness(답변이 검색 문서에 근거하는가), 둘째, Answer Relevance(답변이 질문과 관련 있는가), 셋째, Context Precision(검색된 문서가 정확한가), 넷째, Context Recall(필요한 문서를 모두 검색했는가)입니다.

이러한 지표들이 시스템의 강점과 약점을 명확히 보여줍니다.

코드 예제

from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy, context_precision, context_recall
from datasets import Dataset

# 1. 평가 데이터 준비 (실제 시스템 응답)
eval_data = {
    "question": ["육아휴직은 얼마나 사용할 수 있나요?", "재택근무 정책은?"],
    "answer": ["1년까지 가능합니다", "주 2일 가능합니다"],  # 시스템 답변
    "contexts": [
        ["육아휴직은 최대 1년까지 사용 가능하며..."],  # 검색된 문서
        ["재택근무는 주 2회까지 허용됩니다..."]
    ],
    "ground_truth": ["최대 1년", "주 2회"]  # 정답 (사람이 작성)
}

# 2. Dataset 형식으로 변환
dataset = Dataset.from_dict(eval_data)

# 3. 평가 실행 (4가지 지표로 측정)
result = evaluate(
    dataset,
    metrics=[
        faithfulness,  # 답변이 문서에 근거하는가?
        answer_relevancy,  # 답변이 질문과 관련 있는가?
        context_precision,  # 검색 문서가 정확한가?
        context_recall  # 필요한 정보를 모두 검색했는가?
    ]
)

# 4. 결과 출력
print(f"Faithfulness: {result['faithfulness']:.2f}")  # 0~1, 높을수록 좋음
print(f"Answer Relevancy: {result['answer_relevancy']:.2f}")
print(f"Context Precision: {result['context_precision']:.2f}")
print(f"Context Recall: {result['context_recall']:.2f}")

# 5. 개별 질문별 상세 분석
print(result.to_pandas())  # DataFrame으로 확인

설명

이것이 하는 일: RAG 평가 시스템은 실제 시스템의 질문-답변-문서 데이터를 받아서, 여러 기준으로 자동 채점하고 점수를 제공합니다. 첫 번째로, 평가 데이터를 준비합니다.

question은 사용자 질문, answer는 시스템이 생성한 답변, contexts는 검색된 문서들, ground_truth는 사람이 작성한 정답입니다. 왜 이렇게 구성하는지 궁금하실 텐데요, 각 지표는 이 중 일부를 사용하여 평가합니다.

예를 들어 Faithfulness는 answer와 contexts를 비교하고, Answer Relevancy는 answer와 question을 비교합니다. 그 다음으로, evaluate() 함수가 실행되면서 각 지표를 계산합니다.

Faithfulness(근거성)는 "답변 내용이 실제로 검색된 문서에서 나온 것인가?"를 확인합니다. 내부적으로는 또 다른 LLM이 답변의 각 주장(claim)을 추출하고, 그것이 문서에서 뒷받침되는지 검증합니다.

점수가 낮으면 AI가 환각을 일으켜 문서에 없는 내용을 지어낸 것입니다. Context Precision(검색 정밀도)과 Context Recall(검색 재현율)은 검색 품질을 측정합니다.

Precision은 "검색된 문서 중 실제로 유용한 문서의 비율"이고, Recall은 "필요한 정보를 모두 검색했는가"입니다. Precision이 낮으면 불필요한 문서를 많이 가져온 것이고, Recall이 낮으면 중요한 정보를 놓친 것입니다.

이를 통해 청킹 전략이나 검색 파라미터(k값, 임계값)를 조정할 수 있습니다. 여러분이 이 코드를 사용하면 시스템 변경의 영향을 객관적으로 측정할 수 있습니다.

"새 프롬프트가 Faithfulness를 0.75에서 0.85로 올렸다"처럼 구체적인 수치로 개선을 증명할 수 있습니다. 또한 어느 질문에서 점수가 낮은지 확인하여 문제 패턴을 찾을 수 있습니다.

예를 들어 복잡한 다단계 추론 질문에서 점수가 낮다면, 프롬프트에 Chain-of-Thought를 추가하는 식으로 타겟 개선이 가능합니다.

실전 팁

💡 최소 50-100개의 다양한 테스트 케이스를 준비하세요. 간단한 질문, 복잡한 질문, 애매한 질문, 문서에 답이 없는 질문 등 다양한 유형을 포함해야 합니다.

💡 ground_truth 작성이 어렵다면 먼저 faithfulness와 answer_relevancy만 측정하세요. 이 두 지표는 정답이 없어도 계산 가능합니다.

💡 평가 결과를 시계열로 저장하여 시스템 변경에 따른 성능 추이를 추적하세요. 새 버전 배포 전에 회귀 테스트로 활용할 수 있습니다.

💡 RAGAS 외에도 TruLens, LangSmith 같은 평가 도구가 있습니다. 각각 장단점이 있으니 여러분의 요구에 맞는 것을 선택하세요.

💡 사용자 피드백(좋아요/싫어요)을 수집하여 평가 데이터로 활용하세요. 실제 사용자가 낮은 점수를 준 질문을 테스트 케이스에 추가하면 현실 반영도가 높아집니다.


8. 메타데이터 필터링으로 검색 정확도 높이기

시작하며

여러분이 회사 문서 챗봇에게 "2024년 신입사원 채용 공고"를 검색하는데, 2021년, 2022년 공고까지 섞여서 나온다면 어떨까요? 원하는 정보를 찾기 위해 불필요한 결과들을 일일이 걸러내야 하는 불편함을 겪게 됩니다.

이런 문제는 순수 의미 기반 검색의 한계입니다. "채용 공고"라는 의미는 비슷하지만, 연도라는 명확한 조건을 무시하기 때문입니다.

실제로는 시간, 카테고리, 권한, 버전 같은 구조화된 정보로 필터링해야 하는 경우가 많습니다. 바로 이럴 때 필요한 것이 메타데이터 필터링입니다.

문서에 연도, 부서, 태그, 작성자 같은 속성을 추가하고, 검색할 때 이 조건들로 먼저 걸러낸 후 의미 검색을 수행하는 기술입니다. 마치 온라인 쇼핑몰에서 "가격 범위", "브랜드", "색상"으로 필터링하는 것과 같습니다.

개요

간단히 말해서, 메타데이터 필터링은 문서의 구조화된 속성(날짜, 카테고리, 권한 등)을 활용하여 검색 범위를 먼저 좁힌 후, 그 안에서 의미 기반 검색을 수행하는 방법입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 대부분의 엔터프라이즈 문서는 구조화된 정보를 가지고 있기 때문입니다.

예를 들어, 법률 회사는 "법률 분야(형사/민사)", "날짜", "담당 변호사"로 문서를 분류하고, 이커머스는 "카테고리", "가격대", "재고 여부"로 제품 정보를 관리합니다. 사용자가 "50만원 이하 노트북"을 검색할 때 가격 필터 없이 의미만으로 검색하면 비효율적입니다.

기존에는 모든 문서를 대상으로 검색한 후 결과를 필터링했다면, 이제는 필터를 먼저 적용하여 검색 대상을 줄일 수 있습니다. 이는 검색 속도와 정확도를 동시에 향상시킵니다.

메타데이터 필터링의 핵심 특징은 세 가지입니다. 첫째, 문서 로드 시 메타데이터를 추출하거나 수동으로 추가하고, 둘째, 벡터 DB에 메타데이터도 함께 저장하며, 셋째, 검색 시 filter 조건으로 메타데이터를 활용합니다.

이러한 특징들이 검색을 더 정확하고 빠르게 만듭니다.

코드 예제

from langchain.document_loaders import PyPDFLoader
from langchain.vectorstores import FAISS
from langchain.embeddings import OpenAIEmbeddings
from datetime import datetime

# 1. 문서 로드 및 메타데이터 추가
docs = []
for file_info in [
    {"path": "recruit_2024.pdf", "year": 2024, "dept": "HR", "type": "공고"},
    {"path": "recruit_2023.pdf", "year": 2023, "dept": "HR", "type": "공고"},
    {"path": "policy_2024.pdf", "year": 2024, "dept": "HR", "type": "정책"}
]:
    loader = PyPDFLoader(file_info["path"])
    file_docs = loader.load()

    # 각 청크에 메타데이터 추가
    for doc in file_docs:
        doc.metadata.update({
            "year": file_info["year"],
            "department": file_info["dept"],
            "doc_type": file_info["type"],
            "source": file_info["path"]
        })
    docs.extend(file_docs)

# 2. 벡터 DB 생성 (메타데이터 포함)
vector_store = FAISS.from_documents(docs, OpenAIEmbeddings())

# 3. 메타데이터 필터링 검색
results = vector_store.similarity_search(
    "신입사원 채용 공고",
    k=5,
    filter={"year": 2024, "doc_type": "공고"}  # 2024년 공고만 검색
)

# 4. 결과 확인
for doc in results:
    print(f"[{doc.metadata['year']}] {doc.page_content[:100]}...")

설명

이것이 하는 일: 메타데이터 필터링 시스템은 문서 로드 시 속성 정보를 추가하고, 검색 시 이 속성으로 필터링한 후 의미 검색을 수행합니다. 첫 번째로, 문서마다 메타데이터를 추가합니다.

doc.metadata.update()로 연도, 부서, 문서 타입 같은 정보를 각 문서에 태그처럼 붙입니다. 왜 이렇게 하는지 궁금하실 텐데요, 나중에 검색할 때 "2024년 문서만", "HR 부서 문서만" 같은 조건을 적용할 수 있기 때문입니다.

마치 도서관 책에 "출판 연도", "카테고리" 라벨을 붙이는 것과 같습니다. 그 다음으로, FAISS.from_documents()가 실행되면서 문서 내용(벡터)뿐만 아니라 메타데이터도 함께 인덱스에 저장합니다.

FAISS는 벡터 검색과 동시에 메타데이터 필터링을 지원하므로, 두 가지 조건을 동시에 만족하는 문서를 효율적으로 찾을 수 있습니다. 가장 중요한 부분은 similarity_search()filter 파라미터입니다.

{"year": 2024, "doc_type": "공고"}는 "2024년이면서 공고인 문서"만 검색하라는 의미입니다. 내부적으로는 먼저 이 조건을 만족하는 문서들을 추려내고, 그 안에서 "신입사원 채용 공고"와 의미적으로 유사한 것을 찾습니다.

2023년 공고는 아예 검색 대상에서 제외되므로, 결과가 훨씬 정확하고 관련성 높아집니다. 여러분이 이 코드를 사용하면 사용자가 구체적인 조건을 제시할 때 정확한 결과를 빠르게 제공할 수 있습니다.

시간 범위, 부서, 권한 레벨, 문서 타입 같은 다양한 조건을 조합할 수 있으며, 불필요한 문서를 검색하지 않아 비용과 시간이 절약됩니다. 또한 민감한 정보는 filter={"access_level": "public"}처럼 권한으로 제어하여 보안도 강화할 수 있습니다.

실전 팁

💡 메타데이터는 쿼리 가능한 형태로 저장하세요. 날짜는 ISO 형식(YYYY-MM-DD), 숫자는 int/float, 카테고리는 소문자 문자열로 통일하면 필터링이 쉽습니다.

💡 사용자 입력에서 메타데이터를 자동 추출하세요. "2024년 채용 공고"라는 질문에서 LLM으로 year=2024를 추출하여 자동으로 필터를 적용할 수 있습니다.

💡 복합 필터링도 가능합니다. {"year": {"$gte": 2023}, "dept": {"$in": ["HR", "Finance"]}}처럼 범위나 OR 조건을 사용하려면 벡터 DB의 쿼리 문법을 확인하세요.

💡 메타데이터를 너무 많이 추가하면 저장 공간이 증가합니다. 실제로 검색에 사용할 속성만 선별하여 추가하세요.

💡 Pinecone, Weaviate 같은 고급 벡터 DB는 더 강력한 필터링 기능(정규식, 지리적 위치, 그래프 쿼리 등)을 제공합니다. 복잡한 조건이 필요하면 이런 도구를 고려하세요.


9. 문서 재순위화로 최종 정확도 향상

시작하며

여러분이 검색 결과 10개를 받았는데, 정말 유용한 문서가 7번째에 있다면 어떨까요? AI는 보통 상위 3개 정도만 프롬프트에 포함시키므로, 7번째 문서는 무시되고 답변 품질이 떨어집니다.

이런 문제는 초기 검색(1차 검색)이 완벽하지 않기 때문에 발생합니다. 벡터 검색이나 하이브리드 검색도 때로는 덜 관련된 문서를 상위에 올리거나, 중요한 문서를 낮은 순위에 배치할 수 있습니다.

단순히 k값을 늘려서 더 많은 문서를 가져오면 비용만 증가합니다. 바로 이럴 때 필요한 것이 재순위화(Reranking)입니다.

초기 검색으로 후보 문서들을 넓게 가져온 후, 더 정교한 모델로 질문과의 관련성을 재평가하여 순위를 다시 매기는 기술입니다. 마치 1차 서류 전형 후 면접으로 최종 합격자를 선발하는 것과 같습니다.

개요

간단히 말해서, 재순위화는 초기 검색 결과를 받아서 질문-문서 쌍을 더 정밀하게 분석하고, 관련성 점수를 재계산하여 순위를 조정하는 과정입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 초기 검색 모델(임베딩)은 속도를 위해 상대적으로 간단한 구조를 사용하기 때문입니다.

예를 들어, 벡터 임베딩은 질문과 문서를 각각 독립적으로 인코딩하지만, 재순위화 모델은 두 개를 함께 보면서 "이 질문과 이 문서가 얼마나 잘 맞는가"를 직접 평가합니다. 복잡한 쿼리나 미묘한 뉘앙스가 중요한 경우에 특히 효과적입니다.

기존에는 초기 검색 순위를 그대로 사용했다면, 이제는 2단계 검색 전략(retrieve → rerank)을 사용하여 정확도를 10-30% 향상시킬 수 있습니다. 연구 결과에 따르면 재순위화는 특히 상위 3-5개 결과의 품질을 크게 개선합니다.

재순위화의 핵심 특징은 세 가지입니다. 첫째, 초기 검색으로 많은 후보(예: 50개)를 가져오고, 둘째, Cross-Encoder 같은 정교한 모델로 질문-문서 쌍을 재평가하며, 셋째, 상위 N개(예: 3개)만 최종 선택하여 LLM에 전달합니다.

이러한 특징들이 검색 정밀도를 극대화합니다.

코드 예제

from langchain.retrievers import ContextualCompressionRetriever
from langchain.retrievers.document_compressors import CohereRerank
from langchain.vectorstores import FAISS
from langchain.embeddings import OpenAIEmbeddings

# 1. 기본 검색기 생성 (1차 검색: 많은 후보 수집)
base_retriever = FAISS.from_documents(documents, OpenAIEmbeddings())
base_retriever = base_retriever.as_retriever(
    search_kwargs={"k": 20}  # 20개 후보 가져오기
)

# 2. 재순위화 모델 설정 (Cohere Rerank API 사용)
compressor = CohereRerank(
    cohere_api_key="your-api-key",
    top_n=3,  # 최종적으로 상위 3개만 선택
    model="rerank-multilingual-v2.0"  # 다국어 지원 모델
)

# 3. 재순위화 검색기 생성 (1차 검색 + 재순위화)
rerank_retriever = ContextualCompressionRetriever(
    base_compressor=compressor,
    base_retriever=base_retriever
)

# 4. 검색 실행
query = "육아휴직 신청 절차가 어떻게 되나요?"
results = rerank_retriever.get_relevant_documents(query)

# 5. 결과 확인 (재순위화된 상위 3개)
for i, doc in enumerate(results):
    print(f"{i+1}위 (관련도: {doc.metadata.get('relevance_score', 'N/A')})")
    print(doc.page_content[:150])
    print()

설명

이것이 하는 일: 재순위화 시스템은 기본 검색기로 많은 후보를 수집한 후, Cohere Rerank 같은 전문 모델로 질문과의 관련성을 재계산하여 최상위 문서만 선택합니다. 첫 번째로, base_retriever로 20개의 후보 문서를 가져옵니다.

왜 많은 후보를 가져오는지 궁금하실 텐데요, 초기 검색이 놓친 좋은 문서가 10-20위권에 있을 수 있기 때문입니다. 재순위화는 이 넓은 풀에서 진짜 보석을 찾아내는 역할을 합니다.

그 다음으로, CohereRerank가 실행되면서 진짜 마법이 일어납니다. 일반 임베딩 모델은 질문을 벡터로, 문서를 벡터로 각각 변환한 후 거리를 계산하지만, Rerank 모델은 "육아휴직 신청 절차가 어떻게 되나요?"와 각 문서를 함께 입력받아 "이 둘이 얼마나 잘 맞는가"를 직접 판단합니다.

Cross-Encoder 아키텍처를 사용하여 두 텍스트 간 상호작용을 깊이 분석하므로, 미묘한 의미 차이도 포착할 수 있습니다. top_n=3 설정으로 재순위화된 결과 중 상위 3개만 선택합니다.

초기 검색에서 15위였던 문서가 재평가 후 1위로 올라올 수도 있습니다. 예를 들어, "육아휴직 신청 절차"라는 질문에 "육아휴직 제도 개요"보다 "육아휴직 신청 방법"이 훨씬 관련성이 높다고 정확히 판단합니다.

이렇게 선택된 3개 문서만 LLM 프롬프트에 포함되므로, 답변 품질이 크게 향상됩니다. 여러분이 이 코드를 사용하면 검색 정확도가 극적으로 개선됩니다.

특히 복잡하거나 긴 질문, 여러 의도가 섞인 질문에서 효과가 큽니다. 또한 비용 효율적입니다.

초기 검색은 빠르고 저렴한 임베딩으로, 재순위화는 소수 후보에만 적용하므로 전체 비용이 크게 늘지 않으면서도 품질은 크게 향상됩니다. Cohere의 다국어 모델을 사용하면 한국어 문서에서도 높은 성능을 보입니다.

실전 팁

💡 초기 검색 k값과 재순위화 top_n의 비율은 3:1에서 10:1이 적당합니다. 예: k=30, top_n=3 또는 k=50, top_n=5. 너무 많은 후보를 재순위화하면 비용이 증가합니다.

💡 Cohere Rerank 외에도 오픈소스 Cross-Encoder(sentence-transformers/ms-marco-MiniLM) 모델을 사용할 수 있습니다. API 비용이 부담되면 로컬에서 실행 가능합니다.

💡 재순위화는 검색 시간을 약간 증가시킵니다(보통 100-300ms). 실시간 성능이 중요하면 비동기 처리나 캐싱을 고려하세요.

💡 재순위화 모델도 질문 유형에 따라 성능이 다릅니다. 여러 모델을 테스트하여 여러분의 도메인에 맞는 것을 선택하세요. 법률, 의료, 기술 문서 등 특화 모델도 있습니다.

💡 재순위화 점수(relevance_score)를 임계값으로 사용하세요. 0.5 미만이면 관련성이 낮으므로 "관련 정보를 찾을 수 없습니다"라고 답하는 것이 더 나을 수 있습니다.


10. 스트리밍 응답으로 사용자 경험 개선

시작하며

여러분이 챗봇에게 복잡한 질문을 했는데, 15초 동안 아무 반응이 없다가 갑자기 긴 답변이 쏟아진다면 답답하지 않을까요? 사용자는 "시스템이 멈춘 건가?", "내 질문을 제대로 이해한 건가?" 같은 불안감을 느낍니다.

이런 문제는 실제 프로덕션 챗봇에서 사용자 이탈의 주요 원인입니다. 특히 GPT-4 같은 강력한 모델은 생각하고 답변을 생성하는 데 시간이 걸리는데, 완성될 때까지 기다리게 하면 사용자 경험이 나빠집니다.

연구에 따르면 3초 이상 응답이 없으면 사용자의 20%가 이탈합니다. 바로 이럴 때 필요한 것이 스트리밍 응답(Streaming Response)입니다.

AI가 답변을 생성하는 동시에 완성된 부분부터 바로바로 사용자에게 보여주는 기술입니다. 마치 ChatGPT처럼 타이핑하듯이 답변이 나타나는 것이죠.

개요

간단히 말해서, 스트리밍 응답은 AI가 답변을 한 번에 완성하여 반환하는 대신, 단어나 문장 단위로 생성하는 즉시 전달하여 사용자가 실시간으로 볼 수 있게 하는 방식입니다. 왜 이 개념이 필요한지 실무 관점에서 설명하면, 사용자 경험이 크게 개선되기 때문입니다.

예를 들어, 10초 걸리는 답변을 기다리게 하는 대신, 1초부터 답변이 나타나기 시작하면 사용자는 훨씬 더 반응이 빠르다고 느낍니다. 챗봇 UI, 대화형 검색, AI 비서 같은 인터랙티브 애플리케이션에서는 필수 기능입니다.

기존에는 invoke() 메서드로 완성된 답변만 받을 수 있었다면, 이제는 stream() 메서드로 생성 중인 토큰을 실시간으로 받을 수 있습니다. 대기 시간에 대한 인식(perceived latency)이 크게 줄어듭니다.

스트리밍 응답의 핵심 특징은 세 가지입니다. 첫째, 토큰 단위 생성(token-by-token generation)으로 완성된 부분을 즉시 전송하고, 둘째, 비동기 처리(async/await)로 효율적인 I/O를 구현하며, 셋째, Server-Sent Events(SSE)나 WebSocket으로 실시간 통신합니다.

이러한 특징들이 반응성 높은 사용자 경험을 만듭니다.

코드 예제

from langchain.chat_models import ChatOpenAI
from langchain.chains import RetrievalQA
from langchain.callbacks.streaming_stdout import StreamingStdOutCallbackHandler

# 1. 스트리밍을 지원하는 LLM 설정
streaming_llm = ChatOpenAI(
    temperature=0,
    model="gpt-4",
    streaming=True,  # 스트리밍 활성화
    callbacks=[StreamingStdOutCallbackHandler()]  # 콘솔에 실시간 출력
)

# 2. RAG 체인 생성
qa_chain = RetrievalQA.from_chain_type(
    llm=streaming_llm,
    retriever=retriever,
    return_source_documents=True
)

# 3. 일반 방식 (완성될 때까지 대기)
# result = qa_chain({"query": "육아휴직 신청 방법은?"})

# 4. 스트리밍 방식 (생성되는 대로 출력)
print("답변 생성 중...\n")
for chunk in qa_chain.stream({"query": "육아휴직 신청 방법은?"}):
    if "result" in chunk:
        print(chunk["result"], end="", flush=True)  # 토큰 단위로 즉시 출력

print("\n\n완료!")

# 5. 비동기 스트리밍 (고급)
# async def stream_response(query):
#     async for chunk in qa_chain.astream({"query": query}):
#         yield chunk["result"]

설명

이것이 하는 일: 스트리밍 응답 시스템은 LLM이 토큰(단어)을 생성할 때마다 즉시 클라이언트에게 전송하여, 사용자가 타이핑되는 것처럼 답변을 실시간으로 볼 수 있게 합니다. 첫 번째로, ChatOpenAIstreaming=True를 설정합니다.

이 옵션이 활성화되면 모델은 전체 답변을 한 번에 반환하는 대신, 토큰을 생성하는 즉시 전송합니다. 왜 이렇게 하는지 궁궁하실 텐데요, GPT-4는 답변을 순차적으로 생성하므로(autoregressive generation), 첫 단어는 1초, 열 번째 단어는 3초에 완성될 수 있습니다.

스트리밍을 사용하면 첫 단어를 1초에 바로 보여줄 수 있습니다. 그 다음으로, StreamingStdOutCallbackHandler()를 콜백으로 등록합니다.

콜백은 "토큰이 생성될 때마다 이 함수를 실행하라"는 의미입니다. 이 예제에서는 콘솔에 출력하지만, 실제 애플리케이션에서는 WebSocket이나 SSE로 브라우저에 전송하는 커스텀 콜백을 만들 수 있습니다.

가장 중요한 부분은 qa_chain.stream() 메서드입니다. 일반 invoke()는 완성된 답변을 한 번에 반환하지만, stream()은 제너레이터(generator)를 반환하여 각 청크(chunk)를 순차적으로 yielding합니다.

for chunk in qa_chain.stream(...)로 반복하면서 각 토큰을 print(..., end="", flush=True)로 즉시 출력합니다. flush=True가 중요한데, 이게 없으면 버퍼링 때문에 여러 토큰이 모였다가 한 번에 출력되어 스트리밍 효과가 사라집니다.

여러분이 이 코드를 사용하면 사용자는 즉각적인 피드백을 받습니다. 긴 답변도 시작 부분을 먼저 읽을 수 있어 대기 시간이 지루하지 않고, "시스템이 작동 중"이라는 확신을 주어 이탈률이 감소합니다.

또한 비동기 astream()을 사용하면 여러 사용자 요청을 동시에 처리할 수 있어 서버 효율도 향상됩니다. FastAPI나 Flask와 결합하면 실시간 채팅 UI를 쉽게 구현할 수 있습니다.

실전 팁

💡 웹 애플리케이션에서는 Server-Sent Events(SSE)를 사용하세요. FastAPI의 StreamingResponse와 결합하면 브라우저에서 실시간 스트리밍을 구현할 수 있습니다.

💡 스트리밍 중에는 에러 처리가 중요합니다. try-except로 예외를 잡아서 "죄송합니다, 오류가 발생했습니다"를 스트리밍하세요. 중간에 끊기면 사용자가 혼란스러워합니다.

💡 모바일이나 느린 네트워크에서는 청크 크기를 조절하세요. 토큰 하나씩 보내면 네트워크 오버헤드가 크므로, 5-10개 토큰을 모아서 전송하는 것도 고려하세요.

💡 출처 문서(source_documents)는 스트리밍이 끝난 후 한 번에 보내세요. 답변 중간에 출처가 섞이면 가독성이 떨어집니다.

💡 사용자가 답변을 중단할 수 있게 하세요. "생성 중단" 버튼을 제공하고, 클라이언트에서 연결을 끊으면 서버도 생성을 멈추도록 구현하면 비용을 절약할 수 있습니다.


#AI#RAG#LangChain#VectorDB#ChatBot

댓글 (0)

댓글을 작성하려면 로그인이 필요합니다.