Python & AI Tutorials Logo
LangChain & LangGraph

10. 더 똑똑한 검색: 필터, 임계값, 그리고 MMR

9장에서 우리는 간단한 RAG 시스템을 만들었습니다. 문서를 청크로 나누어 ChromaDB에 임베딩하고, 사용자 질문이 들어오면 관련 청크를 검색해서 프롬프트에 함께 넣어 주는 파이프라인이었습니다. 덕분에 LLM은 회사 내부 문서, 제품 매뉴얼처럼 사전 학습되지 않은 정보에 대해서도 답변할 수 있었습니다. 문제 없이 잘 동작하는 것처럼 보였습니다.

하지만 다양한 질문을 던져 보면 금방 약점이 드러납니다. 소비자 보호 정책을 물었는데 직원 복지몰 내용이 딸려 오거나, 문서에 전혀 없는 내용을 물었는데도 그럴듯한 답변을 지어내거나, 검색 결과를 늘렸더니 오히려 답변 품질이 떨어지는 일이 벌어집니다.

이번 장에서는 이 세 가지 문제를 하나씩 해결합니다. 메타데이터 필터링으로 검색 대상 문서를 제한하고, 유사도 점수 임계값으로 질문과 무관한 검색 결과를 제외하고, K와 MMR 조절로 LLM에 공급하는 청크의 양과 다양성을 개선합니다. 새로운 도구가 필요하지는 않습니다. 여러분이 이미 알고 있는 similarity_search()Chroma 벡터 스토어의 설정과 사용법을 개선하는 것입니다.

10.1) 우리의 RAG에는 무엇이 문제일까?

9장에서는 환불 정책과 배송 정책, 두 개의 문서만 벡터 스토어에 넣었고, 각 문서가 다루는 주제도 명확히 달랐습니다. 질문도 문서 내에 답이 확실히 있거나 없는 경우만 테스트했습니다. 이번에는 좀 더 현실적인 상황을 만들어 보겠습니다. 벡터 스토어에 직원 복지몰 이용 안내 문서를 추가하겠습니다. 이 문서에도 환불에 관한 내용이 있지만, 일반 소비자가 아닌 임직원 대상입니다. 그리고 다양한 질문을 던져서 어떤 문제가 발생하는지 확인하겠습니다.

데이터 준비: 직원 복지몰 이용 안내 추가

9장에서 사용한 두 문서는 다음과 같습니다.

data/docs/refund_policy.md:

markdown
# 환불 정책
 
**시행일**: 2026년 1월 1일
 
## 표준 반품
 
모든 물리적 제품은 구매 후 30일 이내에 전액 환불을 위해 반품될 수 있습니다.
원본 영수증 또는 주문 확인 이메일이 필요합니다. 제품은 원래 포장 상태이고
사용되지 않은 상태여야 합니다.
 
30일 이후에는 스토어 크레딧으로만 반품이 가능합니다. 스토어 크레딧은 만료되지 않습니다.
 
## 디지털 제품
 
디지털 제품(소프트웨어 라이선스, 전자책, 온라인 강좌)은 다운로드 또는 액세스 링크가
활성화되면 환불이 불가능합니다. 액세스를 방해하는 기술적 문제가 발생하면
7일 이내에 지원팀에 연락하여 교체 또는 환불을 받으세요.
 
## 결함 있는 제품
 
결함 있는 제품은 언제든지 전액 환불 또는 교체를 위해 반품될 수 있습니다.
결함에 대한 설명을 포함해주세요. 결함 있는 반품의 배송비는
회사에서 부담합니다.
 
## 구독 서비스
 
월간 구독은 언제든지 취소할 수 있습니다. 환불은 청구 주기의 남은 일수를 기준으로
비례 배분됩니다. 연간 구독은 처음 14일 이내에 전액 환불될 수 있습니다.
14일 이후에는 환불이 불가능하지만 청구 기간이 끝날 때까지 액세스가 계속됩니다.

data/docs/shipping_info.md:

markdown
# 배송 정책
 
## 국내 배송
 
표준 배송 (5-7 영업일): $50 이상 주문 시 무료, 그 외 $5.99.
특급 배송 (2-3 영업일): $12.99.
익일 배송 (다음 영업일): $24.99.
 
## 국제 배송
 
국제 주문은 추적 가능한 항공 우편으로 배송됩니다. 배송 시간은
목적지에 따라 다르며, 일반적으로 10-21 영업일입니다. 국제 배송비는
무게와 목적지를 기준으로 결제 시 계산됩니다.
 
관세 및 수입세는 구매자의 책임이며 배송비에 포함되지 않습니다.
 
## 주문 추적
 
모든 주문에는 배송 후 24시간 이내에 이메일로 전송되는 추적 번호가 포함됩니다.
이메일로 전송된 추적 링크 또는 배송업체 웹사이트에서 주문을 추적하세요.
 
## 분실 또는 손상된 패키지
 
패키지가 분실되거나 손상된 상태로 도착하면 48시간 이내에 지원팀에 연락하세요.
추가 비용 없이 교체품을 배송해드립니다. 손상된 제품의 경우
손상 및 포장 사진을 제공해주세요.

여기에 직원 복지몰 이용 안내 문서를 추가합니다.

data/docs/employee_store.md 생성:

markdown
# 직원 복지몰 이용 안내
 
## 이용 자격 및 혜택
 
임직원은 사내 복지몰에서 자사 제품을 정가 대비 30% 할인된 가격으로 구매할 수 있습니다.
월 구매 한도는 50만원이며, 결제는 급여 공제 또는 복지 포인트로 가능합니다.
 
## 주문 및 배송
 
복지몰 주문은 사내 포털에서 가능하며, 배송은 회사 주소지로만 가능합니다.
주문 후 3-5 영업일 이내에 배송되며, 배송비는 무료입니다.
 
## 환불 규정
 
복지몰에서 구매한 제품의 환불은 구매 후 7일 이내, 미개봉 상태에서만 가능합니다.
환불 시 현금 환불은 불가하며, 복지 포인트로 환급됩니다.
개봉 후에는 교환만 가능하고, 교환은 동일 제품에 한해 1회만 허용됩니다.
 
## 문의
 
복지몰 관련 문의는 인사팀(hr@acme.com)으로 연락해 주세요.

이제 data/docs/에는 세 개의 파일이 있습니다. 9장의 수집 스크립트를 다시 실행하여 벡터 스토어를 새로 구축합니다.

python
# ingest.py — 9장과 동일한 수집 파이프라인
from langchain_community.document_loaders import DirectoryLoader, TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
 
loader = DirectoryLoader(
    "data/docs/", glob="**/*.md",
    loader_cls=TextLoader, loader_kwargs={"encoding": "utf-8"},
)
documents = loader.load()
print(f"{len(documents)}개의 문서를 로드했습니다")
 
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=200,
    chunk_overlap=80,
    separators=["\n## ", "\n\n", "\n", " ", ""],
)
chunks = text_splitter.split_documents(documents)
print(f"{len(chunks)}개의 청크를 생성했습니다")
 
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
 
vector_store = Chroma.from_documents(
    documents=chunks,
    embedding=embedding_model,
    persist_directory="data/chroma_db",
    collection_name="company_docs",
)
print(f"{len(chunks)}개의 청크를 ChromaDB에 저장했습니다")

출력:

3개의 문서를 로드했습니다
12개의 청크를 생성했습니다
12개의 청크를 ChromaDB에 저장했습니다

문제 1: 검색 결과에 엉뚱한 문서가 섞인다

환불 조건에 대해 검색해 보겠습니다.

python
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
 
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
vector_store = Chroma(
    persist_directory="data/chroma_db",
    collection_name="company_docs",
    embedding_function=embedding_model,
)
 
results = vector_store.similarity_search("환불 조건이 어떻게 되나요?", k=3)
 
for i, doc in enumerate(results):
    source = doc.metadata["source"]
    print(f"결과 {i+1} [{source}] {doc.page_content[:60]}...")

출력:

결과 1 [data/docs/refund_policy.md] ## 표준 반품
모든 물리적 제품은 구매 후 30일 이내에 전액 환불...
 
결과 2 [data/docs/employee_store.md] ## 환불 규정
복지몰에서 구매한 제품의 환불은 구매 후 7일 이내...
 
결과 3 [data/docs/refund_policy.md] ## 구독 서비스
월간 구독은 언제든지 취소할 수 있습니다...

결과 2를 보세요. 고객이 환불 조건을 물었는데, 직원 복지몰의 환불 규정이 결과에 포함되어 있습니다. 소비자 환불 정책은 30일 이내 전액 환불이지만, 복지몰은 7일 이내 미개봉 상태에서만 환불 가능하고 복지 포인트로 환급됩니다. 이 두 정책이 함께 LLM에 전달되면, 고객에게 직원 전용 환불 조건이 안내될 수 있습니다.

문제 2: 문서에 없는 내용을 물어도 결과를 반환한다

이번에는 우리 문서에 전혀 없는 내용을 물어보겠습니다. 9장에서 배운 similarity_search_with_score()를 사용하여 거리 점수도 함께 알아보겠습니다. ChromaDB의 거리 점수는 낮을수록 유사함을 의미합니다.

python
results = vector_store.similarity_search_with_score(
    "이 회사의 채용 절차는 어떻게 되나요?", k=3
)
 
for doc, score in results:
    source = doc.metadata["source"]
    print(f"[거리={score:.4f}] [{source}] {doc.page_content[:50]}...")

출력:

[거리=1.4648] [data/docs/employee_store.md] ## 환불 규정
복지몰에서 구매한 제품의 환불은 구매 후 7일 이내...
 
[거리=1.4699] [data/docs/employee_store.md] ## 이용 자격 및 혜택
임직원은 사내 복지몰에서 자사 제품...
 
[거리=1.4743] [data/docs/refund_policy.md] ## 구독 서비스
월간 구독은 언제든지 취소할 수 있습니다...

채용 절차에 대한 정보는 어디에도 없습니다. 거리 값이 모두 1.4 이상으로 매우 낮은 유사도를 보여주고 있지만, similarity_search_with_score()는 여전히 세 개의 청크를 반환했습니다. 이 청크들이 RAG 체인에 전달되면 어떤 답변이 나올지 확인해 보겠습니다.

python
from rag_chain import build_rag_chain  # 9장에서 만든 RAG 체인
 
chain = build_rag_chain()
answer = chain.invoke("이 회사의 채용 절차는 어떻게 되나요?")
print(answer)

출력:

해당 질문에 답변할 충분한 정보가 없습니다.

LLM이 질문에 대한 정보가 부족하다고 답변했습니다. build_rag_chain()의 시스템 프롬프트에 "정보가 부족하면 답변할 정보가 없다고 말하라"는 지시를 넣어 두었기 때문입니다. 하지만 LLM이 항상 이렇게 판단할 수 있는 것은 아닙니다. 검색된 청크에 질문과 관련 있어 보이는 문구가 포함되어 있으면, LLM은 그 내용을 근거로 잘못된 답변을 생성할 수 있습니다.

문제 3: 검색 결과를 늘리면 항상 좋아질까?

"컨텍스트가 많을수록 좋지 않을까?" 하는 생각이 들 수 있습니다. k를 3에서 10으로 올리면 답변에 필요한 청크가 포함될 가능성은 높아집니다. 하지만 동시에 관련 없는 청크도 더 많이 들어옵니다. LLM은 이 모든 청크를 컨텍스트로 받아서 답변을 생성하므로, 불필요하거나 잘못된 정보가 답변에 섞일 수 있습니다. 더 많은 컨텍스트가 더 나은 답변을 의미하지는 않는 것입니다.

또한 검색된 청크는 모두 LLM에 토큰으로 전달됩니다. k가 커지면 API 호출 비용이 증가하고 응답 시간도 느려집니다.

세 가지 문제점을 알아보았습니다. 이제 이 문제들을 하나씩 해결해보겠습니다.

10.2) 메타데이터 필터링: 검색 공간 좁히기

문제 1에서 환불 조건을 검색했을 때, 고객 대상 환불 정책과 직원 복지몰 환불 규정이 함께 나왔습니다. similarity_search()를 호출할 때 어떤 문서에서 검색해야 하는지 알려주지 않았기 때문입니다.

메타데이터 필터링은 각 청크에 카테고리, 출처, 작성 연도 같은 속성 정보를 붙여 놓고, 유사도 검색 시 이 속성을 기준으로 검색 대상을 걸러내는 방법입니다. 조건에 맞는 청크들만 먼저 뽑은 후 유사도 계산을 진행하게 됩니다. SQL의 WHERE 절과 비슷한 역할을 합니다.

10.2.1) 문서 내용 vs. 문서 메타데이터

9장에서 배운 Document 객체에는 두 가지가 들어 있습니다.

  • page_content: 텍스트 자체입니다. 벡터로 임베딩되어 유사도 검색의 대상이 됩니다.
  • metadata: 텍스트의 출처, 카테고리 같은 속성을 담는 딕셔너리입니다. 이 값은 임베딩되지 않습니다.

유사도 검색은 page_content를 대상으로, 메타데이터 필터링은 metadata 정보를 대상으로 동작합니다.

10.2.2) 벡터 스토어 재구축: 메타데이터 추가

필터링에 사용할 category 속성을 추가하여 벡터 스토어를 다시 구축하겠습니다. 10.1에서 사용한 ingest.py에 메타데이터를 부여하는 코드만 추가합니다.

python
# ingest_with_metadata.py
from langchain_community.document_loaders import DirectoryLoader, TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
 
# 1단계: 문서를 로드합니다 (10.1과 동일)
loader = DirectoryLoader(
    "data/docs/", glob="**/*.md",
    loader_cls=TextLoader, loader_kwargs={"encoding": "utf-8"},
)
documents = loader.load()
print(f"{len(documents)}개의 문서를 로드했습니다")
 
# 2단계: [추가] 파일명에 따라 category 메타데이터를 부여합니다
CATEGORY_MAP = {
    "refund_policy.md": "customer",
    "shipping_info.md": "customer",
    "employee_store.md": "employee",
}
for doc in documents:
    filename = doc.metadata["source"].split("/")[-1]
    doc.metadata["category"] = CATEGORY_MAP.get(filename, "unknown")
 
# 3단계: 청크로 분할합니다 (10.1과 동일)
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=200,
    chunk_overlap=80,
    separators=["\n## ", "\n\n", "\n", " ", ""],
)
chunks = text_splitter.split_documents(documents)
print(f"{len(chunks)}개의 청크를 생성했습니다")
 
# 4단계: 벡터 스토어를 구축합니다 (10.1과 동일)
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
 
vector_store = Chroma.from_documents(
    documents=chunks,
    embedding=embedding_model,
    persist_directory="data/chroma_db",
    collection_name="company_docs",
)
print(f"{len(chunks)}개의 청크를 ChromaDB에 저장했습니다")

출력:

3개의 문서를 로드했습니다
12개의 청크를 생성했습니다
12개의 청크를 ChromaDB에 저장했습니다

기존 ingest.py 코드와 달라진 부분은 각 문서에 category 메타데이터를 부여한 것 뿐입니다.

10.2.3) 검색에 필터 적용하기

이제 similarity_search()filter 파라미터를 사용해 검색 대상 문서를 제한할 수 있습니다. 문제 1에서 사용한 것과 동일한 쿼리로 검색하되, 고객 대상 문서만 검색하도록 필터를 추가하겠습니다.

python
results = vector_store.similarity_search(
    "환불 조건이 어떻게 되나요?",
    k=3,
    filter={"category": "customer"},
)
 
for i, doc in enumerate(results):
    source = doc.metadata["source"]
    category = doc.metadata["category"]
    print(f"결과 {i+1} [{category}] [{source}] {doc.page_content[:60]}...")

출력:

결과 1 [customer] [data/docs/refund_policy.md] ## 표준 반품
모든 물리적 제품은 구매 후 30일 이내에 전액 환불...
 
결과 2 [customer] [data/docs/refund_policy.md] ## 구독 서비스
월간 구독은 언제든지 취소할 수 있습니다...
 
결과 3 [customer] [data/docs/refund_policy.md] ## 디지털 제품
디지털 제품(소프트웨어, 전자책, 온라인 강좌)은...

문제 1에서 섞여 들어왔던 직원 복지몰 환불 규정이 포함되지 않았습니다. category"customer"인 청크만 검색하도록 지정했기 때문입니다.

여러 조건 결합하기

위 예시에서는 단일 조건(category"customer")으로 필터링했습니다. 여러 조건을 동시에 적용해야 하는 경우에는 $and, $or 같은 논리 연산자로 조건을 결합할 수 있습니다.

python
# $and: 모든 조건을 만족하는 청크만 검색
filter={
    "$and": [
        {"category": "customer"},
        {"source": "data/docs/refund_policy.md"},
    ]
}
 
# $or: 하나라도 만족하면 검색
filter={
    "$or": [
        {"source": "data/docs/refund_policy.md"},
        {"source": "data/docs/shipping_info.md"},
    ]
}

이 외에도 $ne(같지 않음), $gt(초과), $lt(미만) 등의 연산자를 지원합니다. 전체 연산자 목록은 ChromaDB 공식 문서를 참고하세요.

10.3) 유사도 임계값: 관련성 낮은 검색 결과 제외하기

문제 2에서 채용 절차를 물었을 때, 거리 값이 1.4 이상인 청크들이 반환되었습니다. ChromaDB에서 이 정도 거리 값이면 거의 관련이 없는 내용입니다. 그런데도 검색이 되었습니다. 이는 similarity_search()항상 k개의 결과를 반환하기 때문입니다.

이 문제는 거리 임계값을 정해서 해결할 수 있습니다. 검색 결과에서 임계값보다 먼 결과는 LLM에 전달하는 컨텍스트에 포함하지 않는 것입니다.

그러면 임계값을 얼마로 잡아야 할까요? 먼저 문서에 관련 내용이 있는 질문과 없는 질문의 거리 값이 어떻게 다른지 비교해 보겠습니다.

10.3.1) 관련 내용이 있는 질문과 없는 질문의 거리 비교

python
queries = [
    "구독 서비스는 취소할 수 있나요?",      # 관련 내용이 있는 질문
    "이 회사의 채용 절차는 어떻게 되나요?",  # 관련 내용이 없는 질문
]
 
for query in queries:
    print(f"\n쿼리: {query}")
    results = vector_store.similarity_search_with_score(query, k=1)
    for doc, score in results:
        print(f"  [거리={score:.4f}] {doc.page_content[:50]}...")

출력:

쿼리: 구독 서비스는 취소할 수 있나요?
  [거리=0.7511] ## 구독 서비스
월간 구독은 언제든지 취소할 수 있습니다. 환불은...
 
쿼리: 이 회사의 채용 절차는 어떻게 되나요?
  [거리=1.4648] ## 환불 규정
복지몰에서 구매한 제품의 환불은 구매 후 7일 이내...

문서에 관련 내용이 있는 질문은 거리 값이 0.75 부근이고, 관련 내용이 없는 질문은 1.4 이상으로 나왔습니다. 이처럼 여러 질문을 테스트해 본 후, 두 경우를 잘 구분할 수 있는 임계값을 정하면 됩니다. 임베딩 모델, 문서의 성격, 청크 크기 등에 따라 적절한 임계값이 달라질 수 있으므로, 자신의 데이터에서 직접 확인하여 정하는 것이 좋습니다.

10.3.2) 거리 임계값으로 검색 결과 필터링하기

임계값을 정했으면, 임계값을 넘는 결과를 걸러내는 함수를 만들어 보겠습니다. LLM에게 전달하는 컨텍스트에는 걸러지지 않고 남는 청크만 포함됩니다. 만일 임계값 이하로 남는 청크가 하나도 없으면 LLM을 호출하지 않고 "해당 질문에 답변할 충분한 정보가 없습니다."라고 바로 안내하면 됩니다.

python
from langchain_openai import ChatOpenAI
 
llm = ChatOpenAI(model="gpt-5-mini")
 
def retrieve_or_abstain(query: str, max_distance: float = 1.0, k: int = 3):
    """거리 임계값 이하의 청크만 반환한다. 없으면 None을 반환한다."""
    scored = vector_store.similarity_search_with_score(query, k=k)
    good = [doc for doc, dist in scored if dist <= max_distance]
    return good or None
 
def safe_answer(query: str) -> str:
    docs = retrieve_or_abstain(query)
    if docs is None:
        return "해당 질문에 답변할 충분한 정보가 없습니다."
 
    context = "\n\n".join(d.page_content for d in docs)
    prompt = (
        "제공된 컨텍스트만 사용하여 질문에 답변하세요.\n\n"
        f"컨텍스트:\n{context}\n\n질문: {query}"
    )
    return llm.invoke(prompt).content

앞에서 사용한 질문으로 테스트하겠습니다.

python
# 문서에 관련 내용이 있는 질문
print(safe_answer("구독 서비스는 취소할 수 있나요?"))
print("---")
# 문서에 관련 내용이 없는 질문
print(safe_answer("이 회사의 채용 절차는 어떻게 되나요?"))

출력:

네. 월간 구독은 언제든지 취소할 수 있으며, 환불은 남은 일수에 따라 비례 배분됩니다.
연간 구독은 처음 14일 이내에는 전액 환불될 수 있고...
---
해당 질문에 답변할 충분한 정보가 없습니다.

10.4) K와 MMR: 검색 결과의 양과 다양성 조절하기

이 섹션에서는 k 값에 따라 검색 결과가 어떻게 달라지는지 직접 비교해 보고, 검색 결과의 다양성을 높이는 MMR 검색을 배웁니다.

10.4.1) K를 올리면 어떻게 될까?

문제 3에서 k를 올리면 관련 없는 청크도 더 많이 들어온다고 했습니다. 실제로 확인해 보겠습니다. k=10으로 검색하여 각 청크의 거리 값을 살펴봅니다.

python
results = vector_store.similarity_search_with_score(
    "환불 조건이 어떻게 되나요?",
    k=10,
    filter={"category": "customer"},
)
 
for i, (doc, dist) in enumerate(results, 1):
    source = doc.metadata["source"].split("/")[-1]
    print(f"{i:>2}. [거리={dist:.4f}] [{source}] {doc.page_content[:45]}...")

출력:

 1. [거리=0.7798] [refund_policy.md] ## 표준 반품
**시행일**: 2026년 1월 1일
모든 물리적 제품은 구매 후 30일 이내에 전액 환불을...
 
 2. [거리=0.8203] [refund_policy.md] ## 결함 있는 제품
결함 있는 제품은 언제든지 전액 환불 또는 교체를 위해 반...
 
 3. [거리=0.93533] [refund_policy.md] ## 구독 서비스
월간 구독은 언제든지 취소할 수 있습니다. 환불은 청구 주기의...
 
 4. [거리=1.4249] [shipping_info.md] ## 분실 또는 손상된 패키지
패키지가 분실되거나 손상된 상태로 도착하면 48시...
 
 5. [거리=1.5516] [shipping_info.md] ## 국내 배송
표준 배송 (5-7 영업일): $50 이상 주문...
...

상위 3개까지는 거리가 1.0 이하로, 환불과 관련된 청크들입니다. 하지만 4위부터는 거리가 1.4 이상으로 급격히 올라가며, 배송 정보 등 환불 조건과 직접 관련 없는 청크가 나타납니다. k=10으로 검색하면 이 모든 청크가 LLM에 전달됩니다.

k를 올릴 때의 비용을 정리하면 다음과 같습니다.

  • 노이즈 유입: 순위가 낮은 청크는 질문과 관련이 없는 내용일 수 있습니다. 이런 청크가 프롬프트에 포함되면 LLM이 불필요하거나 잘못된 정보를 답변에 포함시킬 수 있습니다.
  • 비용 증가: 청크가 많을수록 LLM에 전달되는 토큰이 늘어나고, API 호출 비용이 증가합니다.
  • 응답 속도 저하: 처리할 토큰이 많아지므로 응답 시간이 느려집니다.

10.4.2) MMR: 관련성과 다양성을 동시에 확보하기

실제 환경에서는 문서 규모가 커지면서 비슷한 내용의 청크가 여러 개 생기는 경우가 많습니다. 9장에서 chunk_overlap으로 인접한 청크가 내용을 일부 공유하도록 설정한 것도 중복의 원인이 됩니다. 이런 상황에서 k=3으로 검색하면 세 청크가 거의 같은 내용일 수 있습니다.

MMR(Maximum Marginal Relevance)은 검색 결과가 한쪽 내용에 치우치지 않도록 하는 검색 방식입니다. 일반 유사도 검색은 쿼리와 가장 가까운 k개를 반환하므로, 비슷한 내용의 청크가 상위에 몰릴 수 있습니다. MMR은 쿼리와 관련성이 높으면서도, 이미 선택된 결과와는 내용이 다른 청크를 우선적으로 선택합니다.

동작 방식은 다음과 같습니다.

  1. 일반 유사도 검색과 동일하게, 쿼리와 가장 가까운 fetch_k개의 후보를 먼저 가져옵니다.
  2. 후보 중에서 쿼리와 가장 가까운 청크를 첫 번째로 선택합니다.
  3. 나머지 후보 중에서, 쿼리와의 관련성은 높지만 이미 선택된 청크와는 내용이 다른 청크를 다음으로 선택합니다.
  4. k개가 채워질 때까지 3번을 반복합니다.

결과적으로 관련성은 유지하면서 내용이 겹치지 않는 청크들이 선택됩니다.

python
results_mmr = vector_store.max_marginal_relevance_search(
    "환불 조건이 어떻게 되나요?",
    k=3,
    fetch_k=10,
)
 
for i, doc in enumerate(results_mmr):
    print(f"{i+1}. {doc.page_content[:60]}...")

출력:

1. ## 표준 반품
모든 물리적 제품은 구매 후 30일 이내에 전액 환불을 요...
 
2. ## 결함 있는 제품
결함 있는 제품은 언제든지 전액 환불 또는 교체를 위해...
 
3. ## 디지털 제품
디지털 제품(소프트웨어, 전자책, 온라인 강좌)은 다운로...

현재 데이터는 규모가 작아서 일반 검색과 큰 차이가 나지 않습니다. 하지만 문서가 수백-수천 개로 늘어나면 비슷한 내용의 청크가 상위에 몰리는 일이 자주 발생하며, 이때 MMR이 매우 유용합니다. fetch_k는 MMR이 후보를 고를 풀의 크기로, 10-20 정도에서 시작하면 됩니다.

10.4.3) 언제 튜닝을 멈출까

k, fetch_k, 메타데이터 필터, 거리 임계값 등 조정할 수 있는 파라미터가 여러 개 있습니다. 다음과 같은 규칙을 따르면 효율적으로 튜닝할 수 있습니다.

  1. 문서에 관련 내용이 있는 질문과 없는 질문을 각각 몇 개씩 준비합니다.
  2. 준비한 질문들을 실행하고, 검색된 청크를 직접 확인합니다.
  3. 문제가 발견되면 한 번에 하나의 파라미터만 바꾸고, 같은 질문으로 다시 테스트합니다.

문서에 관련 내용이 있는 질문에는 올바른 답변이 나오고, 없는 질문에는 답변을 보류한다면 기본적인 품질은 확보된 것입니다. 처음부터 완벽하게 설정하기는 쉽지 않습니다. 실제 사용 중에 발견되는 문제에 대응하면서 점진적으로 개선하면 됩니다.