$ sudo teach IT

Семантический поиск из урока 3.1 хранил векторы в обычном массиве numpy. Для пары сотен фрагментов этого хватает, но у такого решения две проблемы. Во-первых, после перезапуска программы все эмбеддинги пропадают, и за них приходится платить заново. Во-вторых, при десятках тысяч чанков сравнение запроса с каждым вектором становится медленным, а добавлять, обновлять и удалять фрагменты по одному неудобно.

Векторное хранилище решает обе задачи: сохраняет векторы, тексты и метаданные на диск и быстро находит ближайших соседей. После урока вы сможете создать постоянное хранилище Chroma, наполнить его чанками, искать с оценкой близости и фильтром по метаданным и получить из хранилища ретривер — объект, который пригодится агенту в следующем уроке.

Что уже умеем

Вы получаете эмбеддинги через OpenAIEmbeddings, считаете косинусное сходство, загружаете файлы в объекты Document и режете их на чанки с перекрытием, сохраняя метаданные. Сегодня сложим всё это в одно место, откуда искать быстро и удобно.

Что вы узнаете в рамках статьи

  • что такое векторное хранилище и чем оно лучше списка в памяти;
  • как создать постоянное хранилище Chroma и добавить в него документы с идентификаторами;
  • как искать через similarity_search и similarity_search_with_score;
  • как ограничить поиск фильтром по метаданным;
  • как превратить хранилище в ретривер через as_retriever.

Ключевые термины

  • Векторное хранилище — база данных, которая хранит векторы вместе с текстами и метаданными и ищет ближайшие векторы к запросу.
  • Коллекция — именованный набор документов внутри Chroma, аналог таблицы в обычной базе.
  • Расстояние (distance) — мера различия векторов: чем меньше, тем документы ближе к запросу.
  • Фильтр по метаданным — условие, которое ограничивает поиск документами с нужными значениями в metadata.
  • Ретривер — объект с методом invoke(query), который возвращает список подходящих документов.

Создаём хранилище и добавляем документы

Chroma — открытая векторная база, которая работает прямо внутри вашей Python-программы и не требует отдельного сервера. Установим интеграцию:

pip install langchain-chroma langchain-openai

Создадим постоянное хранилище и добавим несколько документов:

from langchain_chroma import Chroma
from langchain_core.documents import Document
from langchain_openai import OpenAIEmbeddings

embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

vector_store = Chroma(
    collection_name="support_kb",
    embedding_function=embeddings,
    persist_directory="./chroma_db",
)

docs = [
    Document(page_content="Возврат денег возможен в течение 14 дней.",
             metadata={"source": "faq.txt", "topic": "returns"}),
    Document(page_content="Доставка по Москве занимает один рабочий день.",
             metadata={"source": "faq.txt", "topic": "delivery"}),
    Document(page_content="Заявку на возврат оформляют в личном кабинете.",
             metadata={"source": "rules.pdf", "topic": "returns", "page": 3}),
]
ids = ["faq-1", "faq-2", "rules-3"]

vector_store.add_documents(documents=docs, ids=ids)
print(vector_store.get_by_ids(["faq-2"])[0].page_content)

Что увидим в консоли: текст второго документа про доставку. Хранилище само вызывает embeddings для каждого документа: передавать векторы вручную не нужно. Благодаря persist_directory всё сохраняется в папку ./chroma_db, и при следующем запуске достаточно создать Chroma с теми же collection_name, embedding_function и persist_directory, чтобы продолжить работу без повторной оплаты эмбеддингов. Без persist_directory хранилище живёт только в оперативной памяти.

Обратите внимание на ids. Если запустить скрипт индексации дважды без идентификаторов, документы добавятся повторно, и поиск начнёт возвращать дубликаты. С явными ids повторное добавление перезапишет те же записи. Для чанков удобно составлять идентификатор из имени файла и номера чанка. Удалить запись можно через vector_store.delete(ids=["faq-2"]).

Поиск: similarity_search и оценки

Основной метод поиска принимает текст запроса и число результатов k:

results = vector_store.similarity_search("Как вернуть покупку?", k=2)
for doc in results:
    print(doc.metadata["source"], "|", doc.page_content)

Что увидим: два документа про возврат из faq.txt и rules.pdf. Запрос сам превратился в эмбеддинг той же моделью, что и документы.

Чтобы понимать, насколько уверенно найден результат, используйте вариант с оценкой:

pairs = vector_store.similarity_search_with_score("Как вернуть покупку?", k=3)
for doc, score in pairs:
    print(f"{score:.3f} {doc.page_content}")

Что увидим примерно такое:

0.742 Возврат денег возможен в течение 14 дней.
0.801 Заявку на возврат оформляют в личном кабинете.
1.468 Доставка по Москве занимает один рабочий день.

Это важная деталь: Chroma возвращает не сходство, а расстояние, и по умолчанию это квадрат евклидова расстояния (l2). Чем меньше число, тем ближе документ. Векторы OpenAI имеют длину 1, поэтому расстояние и косинусное сходство связаны простой формулой: distance = 2 − 2 × cos, то есть cos = 1 − distance / 2. Расстояние 0.742 соответствует сходству около 0.63, а 1.468 — около 0.27. Если перепутать смысл оценки и отбирать документы «со score больше порога», вы получите самые неподходящие результаты.

Фильтр по метаданным

Метаданные, которые вы аккуратно сохранили при загрузке, позволяют сузить поиск. Параметр filter принимает словарь условий:

only_rules = vector_store.similarity_search(
    "Как вернуть покупку?",
    k=2,
    filter={"source": "rules.pdf"},
)
print([d.metadata["source"] for d in only_rules])

returns_after_p2 = vector_store.similarity_search(
    "возврат",
    k=5,
    filter={"$and": [{"topic": "returns"}, {"page": {"$gte": 2}}]},
)
print(len(returns_after_p2))

Что увидим: ['rules.pdf'] — второго документа из этого файла нет, поэтому вернулся один, — и 1, так как условие «тема возврата и страница не меньше 2» выполняется только для третьего документа. Простой фильтр {"ключ": "значение"} проверяет равенство. Для сравнений используются операторы $gt, $gte, $lt, $lte, $in, а несколько условий объединяют через $and или $or.

Фильтр применяется до выбора top-k, поэтому он не только ускоряет поиск, но и защищает от лишнего: например, сотрудник одного отдела не получит фрагменты чужих документов, если вы фильтруете по полю department.

Ретривер

Агенту и другим компонентам LangChain удобнее работать не с самим хранилищем, а с ретривером. Это объект с единым методом invoke, у которого параметры поиска заданы заранее:

retriever = vector_store.as_retriever(
    search_type="similarity",
    search_kwargs={"k": 2, "filter": {"topic": "returns"}},
)

docs = retriever.invoke("Сколько дней на возврат?")
for d in docs:
    print(d.page_content)

Что увидим: два документа на тему возврата. Кроме "similarity", доступны search_type="mmr", который отбирает не только похожие, но и разнообразные фрагменты, и "similarity_score_threshold", который отсекает результаты по порогу score_threshold в search_kwargs. Ретривер можно передать в функцию, которая ничего не знает про Chroma: если позже вы смените хранилище на другое, остальной код не изменится.

Chroma — не единственный вариант. Для экспериментов подойдёт InMemoryVectorStore из langchain_core, для больших объёмов — FAISS, pgvector или облачные базы. Интерфейс LangChain у них общий, поэтому всё, что вы изучили сегодня, переносится почти без изменений.

Частые ошибки

  • Повторная индексация без ids. Каждый запуск скрипта добавляет копии документов, и в результатах появляются дубликаты. Задавайте стабильные идентификаторы.
  • Путать расстояние и сходство. В similarity_search_with_score у Chroma меньшее число означает более похожий документ. Не сортируйте результаты по убыванию оценки.
  • Другая модель эмбеддингов при повторном открытии. Если коллекция построена одной моделью, а запросы векторизуются другой, поиск выдаёт случайные результаты.
  • Фильтр по полю, которого нет. Если при загрузке не сохранили topic, фильтр по нему вернёт пустой список. Проверяйте метаданные чанков до индексации.

Наиболее частые вопросы

  • Где физически лежат данные? — В папке из persist_directory. Её можно скопировать на другой компьютер вместе с кодом, и хранилище откроется там.
  • Нужно ли вызывать сохранение вручную? — Нет, при заданном persist_directory Chroma записывает изменения на диск автоматически.
  • Как обновить один документ? — Добавьте документ с тем же идентификатором или используйте update_document; эмбеддинг пересчитается только для него.
  • Сколько документов выдержит Chroma? — Локально без проблем работает с сотнями тысяч чанков. Для миллионов и высокой нагрузки смотрят в сторону серверных решений.

Итоги

Вы молодцы: теперь ваш поиск по документам стал настоящим инструментом, а не учебной моделью. Вы создали постоянное хранилище Chroma, научились добавлять документы с идентификаторами, искать с оценками, правильно понимать расстояние, сужать поиск фильтрами и получать ретривер с заранее заданными параметрами.

Всё готово для главного шага модуля. В следующем уроке вы соедините хранилище с агентом из второго модуля: поиск станет инструментом, а агент начнёт отвечать на вопросы по вашим документам и указывать, откуда взял ответ.

На следующий урок