Фраза «какие условия действовали в марте» требует другой выборки, чем «какие действуют сейчас». На первый взгляд разница косметическая: добавили слово «март», и всё. На деле это два разных вопроса к базе. Первый спрашивает: «что было в силе в конкретный момент прошлого». Второй: «что в силе сегодня». Если система умеет отвечать только на второй, она будет уверенно цитировать актуальный регламент там, где человеку нужен тот, что уже заменён. А если она умеет искать только по смыслу текста, то в выдачу попадут сразу три редакции одного пункта, и языковая модель аккуратно соберёт из них гибрид, которого никогда не существовало.
Эта статья о том, как устроить RAG так, чтобы редакции документов не смешивались: какие даты хранить, как описать период действия, в каком порядке применять фильтры и что делать, когда данные ведут себя неприятно: периоды пересекаются, документ отменён, а в вопросе нет даты. Примеры построены на Qdrant как векторном хранилище и на Docling как инструменте разбора, но вся логика периодов, интервалов и правил выбора ниже — проектное решение, а не встроенная функция какого-либо из них.
Три даты, которые нельзя называть одним словом «дата»
Когда в карточке документа появляется поле date, через полгода никто не помнит, что в нём лежит. Обычно за одним словом прячутся три разные вещи.
- Дата загрузки — когда файл попал в вашу систему. Это факт про вашу инфраструктуру, а не про документ. Если вы в понедельник загрузили в базу сразу весь архив за пять лет, у всех редакций будет одна и та же дата загрузки.
- Дата публикации — когда документ был утверждён, подписан или выложен. Она отвечает на вопрос «когда это стало известно», но не обязательно «с какого дня это применяется». Приказ может быть подписан в феврале, а вступать в силу с первого апреля.
- Период действия — с какого дня по какой редакция была в силе. Именно он нужен, когда пользователь спрашивает «на дату».
Путаница между ними даёт характерные ошибки. Если фильтровать по дате загрузки, архив, загруженный разом, будет выглядеть как один большой «сейчас». Если фильтровать по дате публикации, вы ответите по редакции, которая уже подписана, но ещё не начала действовать. И только период действия описывает то, о чём спрашивает человек.
Есть и четвёртая дата, которую стоит иметь в виду, — дата самого вопроса. Она нужна не для выборки документов, а для интерпретации слов «сейчас», «сегодня», «в этом месяце». Об этом ниже, в разделе про неизвестную дату.
Редакция как отдельная сущность
Самая частая архитектурная ошибка — считать редакцию свойством чанка. Тогда у каждого фрагмента текста лежит собственная пара дат, и когда документ меняется, приходится гадать, какие из сотни фрагментов надо обновить. Надёжнее разделить уровни: документ, его редакции и фрагменты редакций.
Документ — это устойчивая сущность: «Положение о возвратах», «Тарифная политика», «Регламент допуска». Редакция — конкретная версия этого документа с собственным периодом. Фрагмент принадлежит одной редакции и больше ничего не знает о времени, кроме ссылки на неё.
Для редакции предлагаю такой набор полей. Это проектная схема, а не стандарт:
| Поле | Что хранит | Зачем нужно |
|---|---|---|
doc_id | Устойчивый идентификатор документа | Объединяет все редакции одного документа |
revision_id | Идентификатор конкретной редакции | Ссылка из фрагментов и из ответа |
valid_from | Первый день действия | Нижняя граница периода |
valid_to | Последний день действия или пустое значение | Верхняя граница; пустое означает «действует до сих пор» |
published_at | Дата утверждения или публикации | Справочное поле, не для выборки по периоду |
ingested_at | Когда файл попал в систему | Служебное поле для отладки загрузок |
status | Действует, заменена, отменена | Отдельно от дат, см. ниже |
source_file | Путь или идентификатор исходного файла | Возможность открыть оригинал |
Одно решение здесь стоит оговорить сразу: как трактовать границы. Я предлагаю полуоткрытый интервал: редакция действует с valid_from включительно и до valid_to не включительно, то есть valid_to — это день, когда уже действует следующая. Тогда у соседних редакций граница одна и та же: у первой valid_to равен valid_from второй, и между ними нет ни дыры, ни нахлёста. С включительными границами с обеих сторон придётся постоянно прибавлять и вычитать по одному дню, а это идеальное место для ошибок «на единицу». Важно лишь зафиксировать правило один раз и написать его в описании схемы.
Отдельно о ссылке на исходник. Когда ответ сформулирован как «по редакции от такого-то числа», читатель захочет проверить. Поэтому в каждой записи нужны идентификатор файла и сведения о месте фрагмента в нём. Если вы разбираете документы через Docling, у его модели документа есть как раз то, что для этого нужно: по описанию модели DoclingDocument она хранит текст, таблицы, иерархию и сведения о происхождении элементов, а координаты доступны, если они получены при разборе. Это позволяет вести пользователя от ответа к странице и области в исходнике. Но юридическое определение того, какая редакция когда действовала, Docling не выполняет: он описывает содержимое файла, а не его правовой статус. Даты периода вы вносите сами — из реестра документов, из заголовка приказа или руками ответственного за документ.
Временная линия на трёх редакциях
Чтобы дальнейшее не повисало в воздухе, возьмём условный пример. Пусть есть документ «Условия возврата предоплаты» и три его редакции. Даты вымышлены и нужны только для иллюстрации.
| Редакция | valid_from | valid_to (не включая) | Срок возврата | Удержание |
|---|---|---|---|---|
| Ред. 1 | 2024-01-01 | 2024-04-01 | 14 дней | Без удержания |
| Ред. 2 | 2024-04-01 | 2024-09-01 | 10 дней | Без удержания |
| Ред. 3 | 2024-09-01 | не задан | 10 дней | 5% при отказе после начала работ |
Теперь архивный вопрос: «Какой срок возврата предоплаты действовал 20 марта 2024 года и удерживалась ли часть суммы?»
Дата 20 марта попадает в интервал первой редакции: она не раньше 1 января и раньше 1 апреля. Значит, ответ — 14 дней, удержания не было. Если бы система искала по смыслу без фильтра, вопрос про срок возврата одинаково хорошо подошёл бы ко всем трём редакциям, и на первое место легко мог выйти самый свежий или самый «похожий» фрагмент. Ответ «10 дней, 5% при отказе после начала работ» был бы гладким, уверенным и неверным для марта.
Обратите внимание, что редакции 2 и 3 различаются только удержанием, а срок у них одинаковый. Это важная деталь: фрагменты про срок возврата в двух редакциях почти дословно совпадают, и для векторного поиска они практически неразличимы. Отличить их можно только по периоду, а не по смыслу. Это и есть главный аргумент за то, чтобы время было строгим условием выборки, а не одним из признаков похожести.
Сначала отсечь по документу и дате, потом искать похожее
Порядок операций здесь решает всё. Неправильный вариант: найти десять самых похожих фрагментов по всей базе, а потом выбросить те, чей период не подходит. Он плох тем, что все десять мест могут занять фрагменты из неподходящих редакций, и после фильтрации останется пустота. Правильный: сначала определить допустимое множество редакций, а потом искать похожий текст только внутри него.
В Qdrant для этого служат фильтры по payload. В документации по фильтрации сказано, что условия фильтра проверяют поля payload, а условия must объединяются как AND. Для нашей задачи этого достаточно: все требования к фрагменту должны выполняться одновременно.
Чтобы фильтр работал, в payload каждого фрагмента нужно положить поля редакции. Даты удобно хранить в виде, который можно сравнивать, — например, числом в формате ГГГГММДД или меткой времени. Конкретный синтаксис условий диапазона и типы полей сверяйте с документацией вашей версии Qdrant; здесь важна логика, а не вид запроса. Она такова:
- Фрагмент принадлежит нужному документу (или набору документов):
doc_idвходит в допустимые. - Начало действия редакции не позже даты вопроса:
valid_from≤ дата. - Конец действия либо не задан, либо строго позже даты:
valid_to> дата или пусто. - Статус не «отменена» — если только вопрос не про историю отмен.
Первые три условия легко связать через must. А вот «либо не задан, либо позже» — это условие вида ИЛИ внутри условия И, и его выражение зависит от возможностей фильтров, которые вы используете. Есть простой способ обойти трудность и не зависеть от тонкостей: не оставлять valid_to пустым. Для действующей редакции записывайте условную далёкую дату, например 9999-12-31, и держите отдельное поле статуса, которое говорит, что редакция в силе. Тогда проверка границы всегда одна и та же, без особого случая «пусто». Это тоже проектное решение, и у него есть цена: далёкую дату нельзя показывать пользователю как «конец действия», её нужно превращать в «действует» при выводе.
Двухступенчатая выборка
На практике удобнее разбить работу на два шага, а не пытаться уместить всё в один запрос к векторному поиску.
Шаг первый — выбор редакций. Это обычный запрос к небольшой таблице редакций: «для этих документов дай мне revision_id, действовавшие на такую-то дату». Таблица маленькая, ответ однозначный, и здесь проще всего поймать нештатные случаи: ноль редакций (дыра), две редакции (пересечение), отменённый документ. Эти случаи мы разберём ниже, и лучше ловить их до обращения к векторам, а не после.
Шаг второй — векторный поиск с ограничением по revision_id. Теперь фильтр короткий: фрагмент принадлежит одной из выбранных редакций. Поиск по смыслу идёт только внутри допустимого множества, и все соревнующиеся фрагменты гарантированно относятся к нужному времени.
Побочное достоинство такого разделения: вы можете показать пользователю, какие редакции были выбраны, ещё до того, как модель что-то сказала. А при отладке сразу видно, на каком шаге произошла ошибка: выбрали не ту редакцию или внутри нужной взяли не тот фрагмент. Это разные поломки с разными лекарствами.
Если вы только собираете базовый стенд и ещё не определились с самим развёртыванием, общий путь описан в материале о поднятии RAG по своим документам на сервере; здесь же предполагается, что хранилище и разбор у вас уже работают.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.
Когда данные ведут себя неприятно
Схема на трёх аккуратных редакциях выглядит красиво. Реальные архивы красивыми не бывают. Разберём три типичные неприятности и для каждой предложим правило. Правила — тоже проектные: в вашей предметной области они могут быть другими, важно лишь, чтобы они были явными.
Пересекающиеся периоды
Допустим, при загрузке выяснилось, что у редакции 2 valid_to стоит 2024-09-15, а редакция 3 начинается 2024-09-01. Две недели действуют обе. Откуда это берётся? Из человеческих причин: одну дату записали по приказу, другую — по письму об отмене; кто-то ввёл дату в день подписания, а не вступления в силу; редакция действует для новых договоров, а старая — для заключённых ранее.
Последний случай особенно коварен, потому что пересечение иногда не ошибка, а реальность: два режима существуют параллельно. Поэтому первое правило — не чинить пересечение молча. При загрузке проверяйте соседние редакции одного документа и помечайте конфликт, а не сдвигайте даты автоматически.
Если пересечение всё же попало в базу и вопрос пришёлся на спорные дни, у системы три честных поведения:
- Показать обе редакции и прямо написать, что в этот период они действовали одновременно. Подходит, когда пересечение отражает реальность.
- Выбрать приоритетную по явному правилу, например более позднюю, и сказать об этом в ответе. Подходит, если вы уверены, что это ошибка в данных, а правило согласовано с владельцем документа.
- Отказаться отвечать и сообщить, что данные за эти даты противоречивы. Подходит для областей, где неверный ответ дороже молчания.
Какое поведение выбрать — вопрос не технический, а организационный. Но выбрать нужно заранее, иначе систему по умолчанию определит случайность порядка выдачи.
Дыры между редакциями
Обратная ситуация: редакция 1 закончилась 1 апреля, а редакция 2 началась 10 апреля. Что действовало с 1 по 9 апреля? Возможно, ничего, возможно — предыдущая версия продолжала действовать по инерции, а возможно, просто потерялась промежуточная редакция. Система этого не знает.
Здесь нельзя «подтягивать ближайшую». Это соблазнительно, потому что пользователь получит хоть какой-то ответ, но это ответ о другом периоде, поданный как ответ об этом. Правильное поведение: на запрос о дате внутри дыры вернуть «для этой даты редакции в базе нет» и показать ближайшие соседние периоды как справку, чётко отделив их от ответа.
Поэтому проверка непрерывности — полезная часть загрузки. Скрипт, который упорядочивает редакции документа по valid_from и сравнивает конец каждой с началом следующей, ловит и дыры, и нахлёсты за несколько строк. Его отчёт стоит смотреть человеку, который отвечает за документы, а не только разработчику.
Отмена документа
Отмена не равна окончанию редакции. Если редакция заменена следующей, у неё закрывается период, и она остаётся нормальной частью истории. Если документ отменён целиком, без замены, у последней редакции тоже закрывается период, но после него нет ничего, и это отличается от дыры: дыра означает «данных нет», отмена — «действующего документа нет».
Именно поэтому статус я предлагаю хранить отдельно от дат. Тогда отмену можно записать явно, с датой отмены, и ответ на вопрос о периоде после неё будет другим: не «редакции не найдено», а «документ утратил силу с такой-то даты». Для читателя разница принципиальная: во втором случае он знает, что искать дальше нечего.
Есть ещё тонкость: отмена может быть ретроспективной, то есть признанной недействительной с момента принятия. Тогда вопрос «что действовало в марте» требует ответа «ничего, документ признан недействительным с самого начала», и простого сравнения дат здесь уже недостаточно. Если ваша область допускает такие случаи, заведите отдельное значение статуса и отдельное правило вывода, а не пытайтесь выразить это границами периода.
Вопрос без даты: что значит «сейчас»
Пользователи редко пишут «по состоянию на 20 марта 2024». Они пишут «какой срок возврата», «что было в прошлом квартале», «как раньше». Система должна решить, какую дату подставить, и сделать это предсказуемо.
Можно выделить четыре случая.
- Дата названа явно. «На 20 марта 2024». Берём её. Если год опущен, это отдельная неопределённость: «на 20 марта» чаще означает ближайший прошедший март, но надёжнее переспросить или показать, какой год выбран.
- Дата выражена относительно сегодняшнего дня. «Вчера», «в прошлом месяце». Здесь нужна дата самого вопроса, и её надо брать из системных часов в момент запроса, а не из текста модели. Для периода вроде «в прошлом квартале» возникает ещё вопрос: если редакция сменилась посреди квартала, ответ должен учитывать обе части. Это уже не точечная дата, а интервал, и лучше прямо поддержать запрос по интервалу, возвращая все редакции, пересекающиеся с ним.
- Дата не названа и смысл вопроса настоящий. «Какой срок возврата?» — по умолчанию это «сегодня». В ответе тогда стоит явно написать, на какую дату он дан: пользователь мог иметь в виду другое.
- Дата не названа, но по контексту нужна историческая. «Почему мы отказали клиенту в возврате?» — спор о прошлом случае. Тут подстановка «сегодня» опасна, и разумнее переспросить, на какую дату рассматривать договор.
Различение третьего и четвёртого случаев — самое хрупкое место. Ни фильтр, ни векторный поиск его не решают. Надёжный путь — не угадывать, а в сомнительных случаях показывать выбранную дату в начале ответа и предлагать сменить её. Тогда ошибка в интерпретации видна пользователю сразу, а не обнаруживается через неделю.
Один вопрос — два среза
Вернёмся к условному документу и посмотрим, как меняется ответ на один и тот же вопрос в зависимости от среза: «Можно ли вернуть предоплату без удержания, если отказаться после начала работ, и в какой срок?»
Срез на 20 марта 2024. Выбирается редакция 1: период с 1 января по 1 апреля. Фрагменты про возврат берутся только из неё. Ответ: возврат возможен в течение 14 дней, удержание не предусмотрено. Здесь нет различия между отказом до и после начала работ, потому что редакция его не вводит.
Срез на 2 октября 2024. Выбирается редакция 3: период с 1 сентября, конец не задан. Ответ другой: срок возврата 10 дней, а при отказе после начала работ удерживается 5%. Один и тот же вопрос, одно и то же хранилище, разные ответы, и оба верны для своих дат.
Теперь представим дату 1 апреля 2024 ровно. Граница полуоткрытая, поэтому редакция 1 уже закончилась, а редакция 2 началась в этот день. Ответ — по редакции 2: 10 дней, без удержания. Это показательный тест: проверка границ — первое, что стоит закладывать в набор контрольных вопросов. Для нашего документа это даты 31 декабря 2023 (до первой редакции, ответ «редакции нет»), 1 января 2024, 31 марта, 1 апреля, 31 августа, 1 сентября. Шесть вопросов быстро покажут, нет ли ошибки на единицу.
Заметим, что вопрос про удержание в редакции 2 и в редакции 3 ставит модель перед ловушкой: фрагменты о сроке возврата там почти одинаковы, и если фильтр по периоду не сработал, модель может подмешать удержание из редакции 3 в ответ по редакции 2. Хорошая проверка — сравнить, какие revision_id попали в контекст, с ожидаемыми. Она проще, чем читать текст ответа, и ловит именно ту ошибку, которая здесь опаснее всего.
Как должен выглядеть ответ
Технически верная выборка бесполезна, если ответ не передаёт её результат читателю. Хороший ответ несёт в себе три вещи: на какую дату он дан, по какой редакции и откуда это можно проверить. Всё это должно быть понятно без знания внутреннего устройства системы.
Сравните две формулировки.
Срок возврата — 14 дней, удержание не предусмотрено.
Читатель не знает, к какому времени это относится, и вполне может принять за текущую норму. А теперь так:
На 20 марта 2024 года действовала редакция 1 документа «Условия возврата предоплаты» (период действия: с 1 января по 31 марта 2024 года). По ней предоплату можно было вернуть в течение 14 дней, удержание не предусматривалось. С 1 апреля 2024 года действует следующая редакция, срок в ней другой — 10 дней.
Вторая формулировка называет дату, редакцию и период обычными словами и без внутренних идентификаторов. Она заодно предупреждает, что последующая редакция иная, а значит, пользователь не примет мартовский ответ за сегодняшний. К ней добавляется ссылка на исходный файл и, если разбор это сохранил, на страницу и область, откуда взят текст.
Заметьте, что период в ответе записан включительно: «по 31 марта», хотя в базе хранится 1 апреля как не входящая граница. Внутренняя конвенция остаётся внутренней; человеку показывается то, что он понимает без пояснений. Это маленькая, но важная часть работы: пересчёт границ при выводе делается в одном месте кода, а не размазан по шаблонам.
Где всё это живёт
С точки зрения ресурсов такая схема неприхотлива. Таблица редакций крошечная, выборка по ней мгновенна, а основной расход — это хранение векторов и работа разбора документов. Если вы держите всё на собственном сервере, например на VPS MAATRIX с NVMe-накопителем и root-доступом, то вам доступно всё необходимое для установки векторного хранилища и скриптов загрузки. Для небольшого корпуса хватает младших тарифов (например, Ba: 1 vCPU, 2 ГБ RAM, 40 ГБ NVMe), а для более объёмного архива с тяжёлым разбором PDF имеет смысл смотреть в сторону более ёмких конфигураций, вплоть до Heka (4 vCPU, 8 ГБ, 160 ГБ) и выше. Это ориентиры по ресурсам, а не обещание ёмкости: реальную нагрузку стоит замерить на своих документах.
Одну практическую вещь стоит сказать отдельно. Историческая база растёт не только вверх по числу документов, но и вглубь: каждая редакция — это ещё один набор фрагментов и векторов. Если документ меняется раз в квартал и вы храните пять лет истории, то на один документ приходится около двадцати наборов. Это нормально, но объём диска полезно прикидывать с учётом именно такого множителя, а не по числу «живых» документов.
Что получится на выходе
Если свести работу к тому, что придётся сделать руками, список окажется коротким: завести отдельную запись редакции с периодом, статусом и ссылкой на исходный файл; закрепить правило границ и никогда не менять его молча; ловить дыры и пересечения при загрузке; выбирать редакции до векторного поиска и искать похожее только внутри них; решить заранее, что считать датой вопроса, когда пользователь её не назвал; и проверять систему вопросами на самих границах периодов.
А результат виден в формулировке ответа. Вместо «срок возврата — 14 дней» система скажет: «на 20 марта 2024 года действовала редакция 1 документа „Условия возврата предоплаты“, в силе с 1 января по 31 марта 2024 года; по ней срок возврата составлял 14 дней, удержание не предусматривалось; с 1 апреля 2024 года действует другая редакция». Дата, документ, редакция и период в одной фразе — и читателю не нужно знать ничего о том, как устроена система, чтобы понять, к какому времени относится ответ.