10. Умный поиск: фильтры, пороги и MMR
В главе 9 мы построили простую RAG-систему. Это был конвейер, который разбивал документы на фрагменты, встраивал их в ChromaDB, а когда поступал вопрос пользователя, извлекал релевантные фрагменты и включал их в промпт. Это позволяло LLM отвечать на вопросы об информации, на которой она никогда не обучалась, например о внутренних документах компании и руководствах по продуктам. Казалось, что всё работает прекрасно.
Но попробуйте задать более разнообразные вопросы, и слабые места быстро проявятся. Вы спрашиваете о потребительской политике возврата, а в ответ примешивается содержимое корпоративного магазина для сотрудников; или вы спрашиваете о том, чего нет ни в одном документе, и LLM выдумывает правдоподобный ответ; или вы увеличиваете количество результатов поиска, а качество ответа на самом деле падает.
В этой главе мы решим эти три проблемы одну за другой. Мы воспользуемся фильтрацией по метаданным (metadata filtering), чтобы ограничить набор документов, по которым ведётся поиск, порогами оценки схожести (similarity score thresholds), чтобы исключить результаты, не связанные с вопросом, и настройкой K и MMR, чтобы улучшить количество и разнообразие фрагментов, передаваемых LLM. Никаких новых инструментов не потребуется. Мы дорабатываем конфигурацию и применение similarity_search() и векторного хранилища Chroma, которые вы уже знаете.
10.1) Что не так с нашим RAG?
В главе 9 мы поместили в векторное хранилище только два документа — политику возврата и политику доставки — и каждый из них освещал отдельную тему. Мы также проверяли только такие вопросы, ответы на которые либо явно присутствовали, либо явно отсутствовали в документах. На этот раз мы создадим более реалистичный сценарий. Мы добавим в векторное хранилище руководство по корпоративному магазину для сотрудников. Этот документ также содержит информацию о возвратах, но он предназначен для сотрудников, а не для обычных потребителей. Затем мы зададим различные вопросы и посмотрим, какие проблемы возникнут.
Подготовка данных: добавление руководства по корпоративному магазину
Вот два документа из главы 9 для справки.
data/docs/refund_policy.md:
# Refund Policy
**Effective Date**: January 1, 2026
## Standard Returns
All physical products may be returned within 30 days of purchase for a full refund.
The original receipt or order confirmation email is required. Items must be in their
original packaging and unused condition.
After 30 days, returns are accepted for store credit only. Store credit does not expire.
## Digital Products
Digital products (software licenses, e-books, online courses) are non-refundable
once the download or access link has been activated. If you experience technical
issues preventing access, contact support within 7 days for a replacement or refund.
## Defective Items
Defective items may be returned at any time for a full refund or replacement.
Please include a description of the defect. Shipping costs for defective returns
are covered by the company.
## Subscription Services
Monthly subscriptions may be cancelled at any time. Refunds are prorated based on
the remaining days in the billing cycle. Annual subscriptions may be refunded in full
within the first 14 days. After 14 days, no refund is available but access continues until the end of the billing period.data/docs/shipping_info.md:
# Shipping Information
## Domestic Shipping
Standard shipping (5-7 business days): Free on orders over $50, otherwise $5.99.
Express shipping (2-3 business days): $12.99.
Overnight shipping (next business day): $24.99.
## International Shipping
International orders are shipped via tracked airmail. Delivery times vary by
destination, typically 10-21 business days. International shipping costs are
calculated at checkout based on weight and destination.
Customs duties and import taxes are the responsibility of the buyer and are not included in the shipping cost.
## Order Tracking
All orders include a tracking number sent via email within 24 hours of shipment.
Track your order through the tracking link in your email or through the carrier's website.
## Lost or Damaged Packages
If your package is lost or arrives damaged, contact support within 48 hours.
We will ship a replacement at no additional cost. For damaged items, please
provide photos of the damage and packaging.Здесь добавьте руководство по корпоративному магазину для сотрудников.
Создайте data/docs/employee_store.md:
# Employee Store Guide
## Eligibility and Benefits
Employees can purchase company products at a 30% discount through the internal employee store.
The monthly purchase limit is $500, and payment can be made via payroll deduction or benefit points.
## Ordering and Shipping
Employee store orders are placed through the internal portal, and delivery is only available to the company address.
Orders are delivered within 3-5 business days, and shipping is free.
## Refund Policy
Refunds are available within 7 days of purchase for unopened items only.
Cash refunds are not available; refunds are credited as benefit points.
After opening, only exchanges are allowed, limited to one exchange per identical product.
## Contact
For employee store inquiries, please contact HR at hr@acme.com.Теперь каталог data/docs/ содержит три файла: refund_policy.md, shipping_info.md, employee_store.md. Запустите заново скрипт загрузки из главы 9, чтобы перестроить векторное хранилище.
# 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"Loaded {len(documents)} documents")
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=200,
chunk_overlap=80,
separators=["\n## ", "\n\n", "\n", " ", ""],
)
chunks = text_splitter.split_documents(documents)
print(f"Created {len(chunks)} 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"Stored {len(chunks)} chunks in ChromaDB")Вывод:
Loaded 3 documents
Created 12 chunks
Stored 12 chunks in ChromaDBПроблема 1: нерелевантные документы примешиваются к результатам поиска
Выполним поиск по условиям возврата.
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("What are the refund conditions?", k=3)
for i, doc in enumerate(results):
source = doc.metadata["source"]
print(f"Result {i+1} [{source}] {doc.page_content[:80]}...")Вывод:
Result 1 [data/docs/refund_policy.md] ## Standard Returns
All physical products may be returned within 30 days of pur...
Result 2 [data/docs/employee_store.md] ## Refund Policy
Refunds are available within 7 days of purchase for unopened i...
Result 3 [data/docs/refund_policy.md] ## Subscription Services
Monthly subscriptions may be cancelled at any time. Re...Посмотрите на Result 2. Клиент спросил об условиях возврата, и в результаты включена политика возврата корпоративного магазина для сотрудников. Потребительская политика возврата допускает полный возврат средств в течение 30 дней, но корпоративный магазин допускает возврат только в течение 7 дней для нераспечатанных товаров, причём средства зачисляются в виде бонусных баллов. Если обе политики передать LLM вместе, клиенту могут сообщить условия возврата, предназначенные только для сотрудников.
Проблема 2: результаты возвращаются даже при отсутствии релевантного содержимого
Теперь спросим о том, чего нигде нет в наших документах. Мы воспользуемся similarity_search_with_score(), который изучили в главе 9, чтобы также увидеть значения расстояния. В ChromaDB меньшие значения расстояния означают более высокую схожесть.
results = vector_store.similarity_search_with_score(
"What is the hiring process at this company?", k=3
)
for doc, score in results:
source = doc.metadata["source"]
print(f"[dist={score:.4f}] [{source}] {doc.page_content[:80]}...")Вывод:
[dist=1.4648] [data/docs/employee_store.md] ## Refund Policy
Refunds are available within 7 days of purchase for unopened i...
[dist=1.4699] [data/docs/employee_store.md] ## Eligibility and Benefits
Employees can purchase company products at a 30% di...
[dist=1.4743] [data/docs/refund_policy.md] ## Subscription Services
Monthly subscriptions may be cancelled at any time. Re...Информации о процессе найма нет нигде. Все значения расстояния выше 1.4, что указывает на очень низкую схожесть, и тем не менее similarity_search_with_score() всё равно вернул три фрагмента. Давайте посмотрим, какой ответ выдаёт RAG-цепочка, когда эти фрагменты передаются ей.
from rag_chain import build_rag_chain # RAG-цепочка из главы 9
chain = build_rag_chain()
answer = chain.invoke("What is the hiring process at this company?")
print(answer)Вывод:
I don't have enough information to answer that question.LLM ответила, что у неё недостаточно информации для ответа. Это произошло потому, что мы включили инструкцию «скажи, что у тебя недостаточно информации» в системный промпт build_rag_chain(). Однако LLM не всегда способна вынести такое суждение. Если извлечённые фрагменты содержат фразы, которые кажутся связанными с вопросом, LLM может сгенерировать неверный ответ на основе этого содержимого.
Проблема 3: всегда ли помогает увеличение количества результатов поиска?
Вы можете подумать: «разве больше контекста — не лучше?». Увеличение k с 3 до 10 действительно повышает вероятность того, что нужные фрагменты будут включены. Но в то же время попадает и больше нерелевантных фрагментов. Поскольку LLM получает все эти фрагменты в качестве контекста и формирует ответы на их основе, ненужная или неверная информация может оказаться в ответе. Больше контекста не обязательно означает лучшие ответы.
Кроме того, все извлечённые фрагменты передаются LLM в виде токенов. По мере роста k стоимость API-вызовов увеличивается, а время ответа замедляется.
Мы выявили три проблемы. Теперь решим их одну за другой.
10.2) Фильтрация по метаданным: сужение пространства поиска
Когда мы искали условия возврата в Проблеме 1, политика возврата для клиентов и политика возврата корпоративного магазина появились вместе. Это произошло потому, что мы не указали similarity_search(), в каких документах искать.
Фильтрация по метаданным (metadata filtering) присоединяет к каждому фрагменту такие атрибуты, как категория, источник и год публикации, а затем фильтрует фрагменты на основе этих атрибутов перед запуском поиска по схожести. Через вычисление схожести проходят только фрагменты, удовлетворяющие условиям. Это выполняет роль, аналогичную оператору WHERE в SQL.
10.2.1) Содержимое документа против метаданных документа
Объект Document, о котором мы узнали в главе 9, содержит две вещи:
page_content: сам текст. Он встраивается в вектор и именно с ним работает поиск по схожести.metadata: словарь, который хранит такие атрибуты, как источник и категория. Эти значения не встраиваются.
Поиск по схожести работает с page_content, тогда как фильтрация по метаданным работает с информацией из metadata.
10.2.2) Перестроение векторного хранилища: добавление метаданных
Мы добавим атрибут category для фильтрации и перестроим векторное хранилище. Нам нужно лишь добавить код назначения метаданных в ingest.py из раздела 10.1.
# 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"Loaded {len(documents)} documents")
# Шаг 2: [НОВОЕ] назначение метаданных категории на основе имени файла
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"Created {len(chunks)} 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"Stored {len(chunks)} chunks in ChromaDB")Вывод:
Loaded 3 documents
Created 12 chunks
Stored 12 chunks in ChromaDBЕдинственное отличие от исходного ingest.py — добавление метаданных category к каждому документу.
10.2.3) Применение фильтров к поиску
Теперь мы можем использовать параметр filter метода similarity_search(), чтобы ограничить, в каких документах ведётся поиск. Мы выполним поиск с тем же запросом, что и в Проблеме 1, но добавим фильтр для поиска только по документам, предназначенным для клиентов.
results = vector_store.similarity_search(
"What are the refund conditions?",
k=3,
filter={"category": "customer"},
)
for i, doc in enumerate(results):
source = doc.metadata["source"]
category = doc.metadata["category"]
print(f"Result {i+1} [{category}] [{source}] {doc.page_content[:80]}...")Вывод:
Result 1 [customer] [data/docs/refund_policy.md] ## Standard Returns
All physical products may be returned within 30 days of pur...
Result 2 [customer] [data/docs/refund_policy.md] ## Subscription Services
Monthly subscriptions may be cancelled at any time. Re...
Result 3 [customer] [data/docs/refund_policy.md] ## Digital Products
Digital products (software licenses, e-books, online course...Политика возврата корпоративного магазина, которая примешивалась в Проблеме 1, больше не включена. Это произошло потому, что мы указали, что искать следует только фрагменты с category, равной "customer".
Комбинирование нескольких условий
В примере выше использовалось одно условие (category равна "customer"). Когда нужно применить несколько условий одновременно, их можно комбинировать с помощью логических операторов вроде $and и $or.
# $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 результатов.
Эту проблему можно решить, задав порог (threshold) расстояния. Результаты, расположенные дальше порога, не включаются в контекст, передаваемый LLM.
Так какой же порог нам установить? Давайте сначала сравним значения расстояния между вопросами, у которых есть релевантное содержимое в документах, и вопросами, у которых его нет.
10.3.1) Сравнение расстояний: вопросы с релевантным содержимым и без него
queries = [
"Can I cancel a subscription service?", # релевантное содержимое существует
"What is the hiring process at this company?", # релевантного содержимого нет
]
for query in queries:
print(f"\nQuery: {query}")
results = vector_store.similarity_search_with_score(query, k=1)
for doc, score in results:
print(f" [dist={score:.4f}] {doc.page_content[:80]}...")Вывод:
Query: Can I cancel a subscription service?
[dist=0.7511] ## Subscription Services
Monthly subscriptions may be cancelled at any time. Re...
Query: What is the hiring process at this company?
[dist=1.4648] ## Refund Policy
Refunds are available within 7 days of purchase for unopened i...У вопросов с релевантным содержимым значения расстояния около 0.75, тогда как у вопросов без релевантного содержимого — выше 1.4. Протестируйте несколько таких вопросов, а затем выберите порог, который чётко разделяет эти два случая. Правильный порог может варьироваться в зависимости от модели эмбеддингов, характера ваших документов и размера фрагмента, поэтому лучше всего определять его, тестируя непосредственно на ваших собственных данных.
10.3.2) Фильтрация результатов поиска по порогу расстояния
После того как порог установлен, давайте построим функцию, которая отфильтровывает результаты, превышающие порог. В контекст, передаваемый LLM, включаются только те фрагменты, которые прошли фильтр. Если ни один фрагмент не остаётся ниже порога, мы полностью пропускаем вызов LLM и отвечаем «У меня недостаточно информации, чтобы ответить на этот вопрос.».
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 "I don't have enough information to answer that question."
context = "\n\n".join(d.page_content for d in docs)
prompt = (
"Answer the question using ONLY the context below.\n\n"
f"Context:\n{context}\n\nQuestion: {query}"
)
return llm.invoke(prompt).contentДавайте протестируем на тех же вопросах, что и раньше.
# Вопрос с релевантным содержимым
print(safe_answer("Can I cancel a subscription service?"))
print("---")
# Вопрос без релевантного содержимого
print(safe_answer("What is the hiring process at this company?"))Вывод:
Yes. Monthly subscriptions may be cancelled at any time, and refunds are prorated
based on the remaining days in the billing cycle. Annual subscriptions may be refunded
in full within the first 14 days...
---
I don't have enough information to answer that question.10.4) K и MMR: управление количеством и разнообразием результатов поиска
В этом разделе мы напрямую сравним, как меняются результаты поиска при разных значениях k, и узнаем о MMR-поиске, который повышает разнообразие результатов.
10.4.1) Что происходит при увеличении K?
В Проблеме 3 мы сказали, что увеличение k также приводит к появлению большего числа нерелевантных фрагментов. Давайте проверим это. Мы выполним поиск с k=10 и изучим значения расстояния каждого фрагмента.
results = vector_store.similarity_search_with_score(
"What are the refund conditions?",
k=10,
filter={"category": "customer"},
)
for i, (doc, dist) in enumerate(results, 1):
source = doc.metadata["source"].split("/")[-1]
print(f"{i:>2}. [dist={dist:.4f}] [{source}] {doc.page_content[:80]}...")Вывод:
1. [dist=0.7798] [refund_policy.md] ## Standard Returns
**Effective Date**: January 1, 2026
All physical products ma...
2. [dist=0.8203] [refund_policy.md] ## Defective Items
Defective items may be returned at any time for a full refun...
3. [dist=0.9353] [refund_policy.md] ## Subscription Services
Monthly subscriptions may be cancelled at any time. Re...
4. [dist=1.4249] [shipping_info.md] ## Lost or Damaged Packages
If your package is lost or arrives damaged, contact...
5. [dist=1.5516] [shipping_info.md] ## Domestic Shipping
Standard shipping (5-7 business days): Free on orders over...
...У первых трёх результатов расстояние ниже 1.0, и все они связаны с возвратами. Начиная с четвёртого результата расстояние подскакивает выше 1.4, и начинают появляться фрагменты, не связанные с условиями возврата — например, информация о доставке. При k=10 все эти фрагменты передаются LLM.
Издержки увеличения k следующие:
- Шум: фрагменты с более низким рангом могут быть совершенно не связаны с вопросом. Когда такие фрагменты включаются в промпт, LLM может включить ненужную или неверную информацию в свой ответ.
- Более высокая стоимость: больше фрагментов означает больше токенов, отправляемых LLM, что увеличивает стоимость API-вызовов.
- Более медленные ответы: больше токенов для обработки означает большее время ответа.
10.4.2) MMR: достижение и релевантности, и разнообразия
В реальных условиях, по мере роста коллекции документов, часто возникают несколько фрагментов с похожим содержимым. Настройка chunk_overlap из главы 9, благодаря которой соседние фрагменты разделяли часть содержимого, также является источником дублирования. В таких случаях даже поиск с k=3 мог бы вернуть три практически идентичных фрагмента.
MMR (Maximum Marginal Relevance) — это метод поиска, который предотвращает смещение результатов в сторону одного и того же содержимого. Стандартный поиск по схожести возвращает k фрагментов, ближайших к запросу, из-за чего похожие фрагменты могут скапливаться вверху. MMR отдаёт приоритет фрагментам, которые одновременно релевантны запросу и отличаются от уже выбранных результатов.
Вот как это работает:
- Так же, как и стандартный поиск по схожести, сначала извлекаются
fetch_kфрагментов-кандидатов, ближайших к запросу. - В качестве первого результата выбирается фрагмент, ближайший к запросу.
- Из оставшихся кандидатов выбирается следующий фрагмент, который релевантен запросу, но отличается по содержимому от уже выбранных фрагментов.
- Шаг 3 повторяется, пока не будет выбрано
kфрагментов.
Результатом является набор фрагментов, которые сохраняют релевантность, избегая при этом дублирования содержимого.
results_mmr = vector_store.max_marginal_relevance_search(
"What are the refund conditions?",
k=3,
fetch_k=10,
)
for i, doc in enumerate(results_mmr):
print(f"{i+1}. {doc.page_content[:80]}...")Вывод:
1. ## Standard Returns
All physical products may be returned within 30 days of pur...
2. ## Defective Items
Defective items may be returned at any time for a full refun...
3. ## Digital Products
Digital products (software licenses, e-books, online course...На текущих данных большой разницы со стандартным поиском нет, поскольку набор данных мал. Однако по мере роста числа документов до сотен или тысяч похожие фрагменты часто скапливаются вверху результатов, и именно здесь MMR становится очень полезным. fetch_k — это размер пула кандидатов, из которого выбирает MMR; распространённый подход — начинать с 10–20.
10.4.3) Когда прекращать настройку
Существует несколько параметров для корректировки: k, fetch_k, фильтры по метаданным, пороги расстояния и другие. Следование этим простым правилам помогает настраивать эффективно:
- Подготовьте несколько вопросов — часть с релевантным содержимым в документах, а часть без него.
- Запустите вопросы и непосредственно изучите извлечённые фрагменты.
- Если обнаружена проблема, меняйте только один параметр за раз и повторно тестируйте на тех же вопросах.
Если вопросы с релевантным содержимым дают правильные ответы, а вопросы без релевантного содержимого приводят к отказу от ответа, вы достигли базового уровня качества. Добиться идеальных настроек с самого начала непросто. Реагируйте на проблемы, обнаруженные во время реального использования, и улучшайте систему постепенно.