Поиск по ключевым словам ломается на синонимах и перефразировках. Запрос «как вернуть деньги» не находит абзац со словами «оформить возврат платежа», хотя смысл один.
Семантический поиск решает задачу иначе. Текст превращают в числовой вектор — embedding. Похожие по смыслу фрагменты дают близкие векторы. Запрос тоже превращают в вектор и ищут ближайшие куски в базе.
Статья разбирает устройство такого поиска, роль чанков и сохранение данных. В конце — рабочие примеры на Python без привязки к конкретному продукту.
Короткие выводы: семантический поиск сравнивает смысл текстов через векторы, а не совпадение слов.
Чем embedding отличается от обычного индекса
Embedding — массив чисел фиксированной длины. Модель
text-embedding-3-small от OpenAI возвращает вектор размерности 1536. Близкие смыслы дают близкие направления в пространстве. Метрику близости считают через cosine similarity.Обычный полнотекстовый индекс (BM25, Elasticsearch, PostgreSQL
tsvector) ищет совпадение токенов. Он силён на точных терминах, кодах и именах. Семантический поиск сильнее на перефразировках. На практике часто комбинируют оба подхода — hybrid search. В исследовании OpenHelm hybrid дал прирост точности около 11 процентных пунктов относительно чистого vector search.Короткие выводы: embedding кодирует смысл; keyword-индекс кодирует слова. Вместе они покрывают больше запросов.
Как устроен пайплайн от текста до ответа
Пайплайн строится из пяти шагов.
- 1Из документа извлекают полный текст.
- 2Текст режут на чанки — небольшие фрагменты.
- 3Для каждого чанка считают embedding.
- 4В базу пишут:
document_id,chunk_order,content,embedding. - 5При запросе считают embedding запроса, находят ближайшие чанки и отдают их в LLM или в UI.
Полный текст документа хранят отдельно. Векторы живут на уровне чанков. Один вектор на весь документ для поиска почти не используют: длинный текст «размазывает» тему.
Схема потока:
Text
Короткие выводы: ищут не «документ в среднем», а конкретный фрагмент с ответом.
Зачем резать текст на чанки
Интуиция «сумма частей равна целому» здесь не работает. Чанки не схлопывают в один вектор при индексации документа. Каждый кусок хранит свой
content и свой embedding.Смысл разреза — разрешение поиска. Нужен локальный сигнал: найти абзац в середине книги, а не среднее по всей книге.
Пример. Книга на 50 страниц. Вопрос касается одного абзаца в главе 7. Один вектор на всю книгу смешивает сотни тем. Cosine similarity с запросом слабый. Вектор именно того абзаца даёт точное попадание. В контекст модели можно отдать этот фрагмент, а не весь файл.
| Задача | Почему чанки нужны |
|---|---|
| Лимит токенов embedding-модели | Длинный текст часто нельзя отправить целиком |
| Поиск по смыслу | Локальные векторы находят нужный фрагмент |
| Контекст для LLM | В ответ уходит релевантный chunk, не вся книга |
Mean pooling — отдельный приём. Несколько векторов усредняют, когда нужен один вектор на длинный note или query, а модель не принимает весь текст. Это обход лимита входа, а не «лучшая математика суммы». Для поиска по документам mean pooling по книге ухудшает точность: среднее глушит локальный сигнал.
Короткие выводы: чанки дают разрешение поиска. Среднее по всей книге ≠ поиск по нужному куску.
Резать ли строго по абзацам
Абзац часто совпадает с одной мыслью. Embedding получается «чище», поиск точнее находит нужный кусок. На практике абзац — хорошая предпочтительная граница, но не жёсткое правило «один абзац = один chunk».
| Проблема | Что происходит |
|---|---|
| Слишком мелкие абзацы | «Да.», списки, подписи → шумный или пустой вектор |
| Слишком крупные | Глава без переносов → снова лимит и «размазывание» |
| Смысл на стыке | Условие в абзаце 2, ответ в абзаце 3 → одного абзаца мало |
| Плохое форматирование | PDF/HTML без нормальных \n\n → «абзацев» почти нет |
Рабочий компромисс — гибрид:
- •резать по структуре: заголовки → абзацы → предложения;
- •держать размер roughly 200–500 токенов;
- •давать overlap 10–20%, чтобы не рвать связку между соседями;
- •для Markdown/HTML сначала резать по заголовкам, потом дробить крупные секции.
Chroma Research показывает:
RecursiveCharacterTextSplitter около 400 токенов даёт recall порядка 85–90%. Overlap 10–20% стабильно улучшает результат. Жёсткое «только абзацы» проигрывает гибриду на реальных документах.Короткие выводы: абзац — предпочтительная граница. Потолок по токенам и overlap обязательны.
Как хранить данные в базе
Отдельное поле
embedding на всём документе для поиска не подходит. Нужна таблица чанков.Минимальная схема (PostgreSQL +
pgvector или любая vector DB):SQL
Пример строк после индексации:
| document_id | chunk_order | content | embedding |
|---|---|---|---|
| doc_refund | 0 | «Возврат оформляют в личном кабинете…» | [0.12, -0.03, …] |
| doc_refund | 1 | «Срок возврата — 14 дней с даты оплаты…» | [0.08, 0.41, …] |
| doc_ship | 0 | «Доставка занимает 3–5 рабочих дней…» | [-0.21, 0.17, …] |
При переиндексации документа старые чанки удаляют, затем пишут новые. Иначе поиск возвращает устаревшие фрагменты.
Короткие выводы: документ хранит полный текст; поиск работает по таблице чанков с векторами.
Практика на Python: от текста до поиска
Ниже — минимальный рабочий контур. Для эмбеддингов используют OpenAI API. Тот же паттерн работает с локальными моделями через совместимый endpoint.
Установка:
Bash
Шаг 1. Подготовить документы
Python
Шаг 2. Разрезать на чанки
Python
Сплиттер сначала режет по абзацам (
\n\n), затем по строкам и предложениям. Абзац остаётся целым, пока укладывается в лимит. Это и есть гибрид «абзац + потолок размера».Шаг 3. Посчитать embeddings
Python
Шаг 4. Сохранить в память как «база»
В проде сюда ставят PostgreSQL/
pgvector, Qdrant, Chroma или SurrealDB. Для демонстрации хватает списка словарей.Python
Шаг 5. Найти ближайшие чанки по запросу
Python
Ожидаемый результат: сверху окажется чанк из
doc_refund про возврат, даже без слов «вернуть деньги» в тексте. Запрос про трек-номер вытянет чанк из doc_ship.Шаг 6. Отдать фрагменты в LLM (RAG)
Python
Модель отвечает по найденным кускам. Галлюцинации падают, потому что в промпте лежит конкретный фрагмент источника.
Короткие выводы: минимальный контур — chunk → embed → store → cosine search → контекст для ответа.
Частая ошибка: один вектор на весь документ
Ниже — демонстрация, почему «среднее по книге» ломает поиск. Код учебный: показывает разницу сигналов.
Python
Score к чанку про возврат будет выше, чем к среднему по книге. Среднее смешивает доставку, возврат и бонусы. Локальный вектор сохраняет тему.
Короткие выводы: для индексации источников хранят отдельный вектор на каждый чанк. Mean pooling оставляют для коротких сущностей с лимитом входа.
Что проверить перед продакшеном
- •Одна и та же embedding-модель для документов и запросов.
- •Размерность вектора в схеме совпадает с моделью.
- •При обновлении документа чанки пересобирают целиком.
- •Размер чанка держат в диапазоне 200–500 токенов, overlap — 10–20%.
- •Для оценки качества готовят набор «вопрос → ожидаемый фрагмент» и меряют recall@k.
- •Keyword-поиск оставляют рядом: артикулы, ID, точные имена лучше ловит BM25.
Полезные материалы:
- •
- •
- •
- •
- •
Короткие выводы: продакшен упирается в переиндексацию, единую модель и измерение recall, а не в «магическое» поле embedding на документе.
Краткие итоги
Семантический поиск переводит текст и запрос в векторы и сравнивает смысл. Документ хранят целиком, а ищут по чанкам: у каждого куска свой embedding.
Чанки нужны не чтобы «сохранить ту же сумму», а чтобы найти и вернуть нужный фрагмент. Абзац — удобная граница, но без потолка по размеру и overlap точность падает.
Минимальная практика: разрезать текст → посчитать embeddings → сохранить в таблицу чанков → искать cosine similarity → отдавать top-k в ответ или в LLM.
