Допустим, в базе лежат два руководства к одной условной насосной станции PX-200. В обоих есть раздел «5.3. Проверка давления в контуре», в обоих по три абзаца и таблица с допустимыми значениями. Заголовки совпадают до последней запятой. Для векторного поиска такие фрагменты почти близнецы: смысл одинаковый, лексика одинаковая, расстояние между эмбеддингами ничтожное. А вот числа в таблицах могут различаться, потому что у модификации B другой датчик и другой регулятор.
Если спросить такую базу «до какого давления можно поднимать контур при проверке», система с радостью найдёт самый похожий фрагмент. Но «самый похожий» и «из нужного руководства» — разные свойства. Первое считает модель, второе нужно установить заранее. Эта статья о том, как выстроить цепочку, в которой ответ приходит с указанием модификации, редакции документа и страницы, а страницу можно открыть и проверить собственными глазами. Все названия и значения ниже условные: настоящие параметры оборудования здесь не нужны и не приводятся.
Сначала модификация: вопрос о самом аппарате
Прежде чем что-либо искать, надо понять, о каком именно аппарате идёт речь. Звучит как формальность, но на практике именно здесь теряется больше всего точности. Пользователь пишет «станция PX-200», хотя у него стоит модификация B с серийным номером определённого диапазона. В каталоге компании это одно изделие, на шильдике — разные позиции.
Полезно разделять три сущности, которые в быту сливаются в одно слово «версия»:
- Модель — семейство изделия, например PX-200.
- Модификация — конкретное исполнение внутри семейства: другой датчик, другая плата, другая комплектация. Обычно она читается на шильдике или определяется диапазоном серийных номеров.
- Редакция руководства — версия самого документа. Один и тот же аппарат может иметь руководство 2.1, потом 2.2 с исправленной опечаткой в таблице, потом 3.0 после пересмотра процедур.
Связь между ними не обязана быть взаимно однозначной. Руководство 3.0 может покрывать сразу модификации B и C. Модификация A может остаться на руководстве 2.1 и никогда не получить новую редакцию. Бывает и наоборот: два руководства выпущены под одну модификацию, и действующим считается более позднее. Поэтому в системе нужна небольшая таблица соответствия: какая модификация какими редакциями описана и какая из них действует. Её ведёт человек, который отвечает за документацию. Автоматически вывести её из текста PDF ненадёжно: титульная страница редко содержит исчерпывающий перечень покрываемых серийных диапазонов в удобном для разбора виде.
Как узнать модификацию у пользователя? Самый честный способ — спросить. Интерфейс может предложить выбрать модель из списка и ввести серийный номер или код с шильдика, а затем сопоставить его с таблицей диапазонов. Если серийный номер не указан, но в диалоге уже упоминалась модификация, её можно подставить, но стоит показать в ответе, из какой версии система исходила. Скрытая подстановка опаснее явной: пользователь не заметит, что ответ дан для чужого исполнения.
Какую редакцию считать нужной
Когда модификация известна, остаётся выбрать документ. Здесь два разумных режима, и они дают разные ответы на один и тот же вопрос.
Первый режим — «действующая редакция». Пользователь хочет знать, как делать сейчас, и система берёт самую свежую редакцию, назначенную модификации. Второй режим — «редакция, с которой поставлен аппарат». Он нужен, когда разбирается спор, ретроспектива или обслуживание старой партии, на которую компания специально сохранила прежние инструкции. Эти режимы не стоит смешивать молча. Если по умолчанию включено «действующее», то и в ответе должно быть видно: «руководство редакции 3.0, действующая для модификации B».
Чтобы разные редакции вообще можно было различать, каждому документу при загрузке присваиваются явные поля. Вот условная пара, на которой удобно держать в голове всю схему:
| Поле | Руководство 1 | Руководство 2 |
|---|---|---|
| doc_id | px200-manual-a-2.1 | px200-manual-b-3.0 |
| model | PX-200 | PX-200 |
| modification | A | B |
| manual_revision | 2.1 | 3.0 |
| status | superseded для новых поставок | current |
| раздел «Проверка давления» | 5.3, PDF-страница 41 | 5.3, PDF-страница 47 |
Обратите внимание на последнюю строку. Раздел называется одинаково и нумеруется одинаково, а страницы разные, потому что во второй редакции добавилась глава. Если в ответе вместо номера страницы написано только «раздел 5.3», пользователь не поймёт, в каком из двух файлов его искать, а система не сможет проверить, что цитата вообще оттуда.
Статус документа — тоже поле, а не комментарий в названии файла. Название вроде «PX-200_final_v2_new.pdf» ничего системе не говорит. Явные значения current и superseded позволяют фильтровать, а не угадывать.
Что должен сохранить разбор документа
Руководство к оборудованию — не плоский текст. Это иерархия глав, таблицы, подписи к рисункам, колонтитулы, примечания и предупреждения. Если превратить PDF в одну строку и нарезать её по тысяче символов, пропадёт именно то, что нужно для проверяемого ответа: где фрагмент находился, под каким заголовком, на какой странице.
Модель документа в Docling как раз рассчитана на такую работу: по документации, DoclingDocument хранит текст, таблицы, иерархию и сведения о происхождении элементов. Там же сказано, что координаты элементов доступны, если они были получены при разборе. Эту оговорку стоит запомнить: рамка элемента на странице бывает не всегда, поэтому логика «подсветить место на странице» должна уметь работать и без неё, ограничившись номером страницы.
Из этих сведений мы собираем запись для каждого фрагмента. Условный пример того, что будет лежать рядом с вектором как метаданные:
{"doc_id":"px200-manual-b-3.0","model":"PX-200","modification":"B","manual_revision":"3.0","status":"current","language":"ru","section_path":"5 / 5.3","section_title":"Проверка давления в контуре","chunk_type":"paragraph","page_pdf":47,"page_printed":"5-12","element_ref":"#/texts/312"}Разберём, зачем каждое поле. Поля model, modification, manual_revision и status нужны фильтру. Поле doc_id связывает фрагмент с файлом, который потом откроется. Поле section_path берётся из иерархии документа и защищает от потери контекста: фрагмент «Значение указано в таблице» бессмыслен, пока неизвестно, под каким заголовком он стоит. Поле element_ref указывает на элемент в сохранённой модели документа, то есть на тот самый источник происхождения, из которого берутся страница и, при наличии, координаты.
Два номера страницы — не прихоть. В руководствах нумерация в нижнем колонтитуле («5-12») редко совпадает с порядковым номером страницы в PDF (47). Если в ответе указать печатный номер, а ссылка откроет PDF по порядковому, читатель увидит соседнюю страницу и решит, что система ошиблась. Поэтому в данных хранятся оба номера, ссылка строится по порядковому, а человеку показывается печатный с пометкой «стр. 5-12 (PDF 47)».
Саму модель документа имеет смысл сохранять целиком, а не только метаданные чанков. Тогда при сомнении можно вернуться от записи в индексе к исходному элементу и проверить, что текст, страница и положение согласованы. Индекс остаётся производным: его можно перестроить, а вот разобранная модель с происхождением — это первичный материал.
Как резать на фрагменты, чтобы не терять привязку
Нарезка по границам структуры лучше нарезки по числу символов. Абзац, пункт списка, примечание, предупреждение — естественные единицы. К заголовку главы стоит присоединять путь по иерархии, а не только последний уровень. Для текстового фрагмента из раздела 5.3 в индекс удобно подавать строку вида «PX-200, модификация B, руководство 3.0. Раздел 5 / 5.3 Проверка давления в контуре. …текст абзаца…». Это усиливает поиск и не мешает фильтру: версия в тексте помогает модели эмбеддингов, а настоящее исключение чужих документов делают поля, не текст.
Если абзац переходит на следующую страницу, у фрагмента должно быть два значения: страница начала и страница окончания. Цитата, которая начинается внизу страницы 47 и заканчивается наверху 48, проверяется на двух страницах, и ссылка ведёт на первую из них.
Чужие руководства отсекаются до поиска
Самая частая ошибка — искать по всей базе, а версию проверять потом. Выглядит логично: найдём десять ближайших фрагментов, отбросим те, что не подходят. Но у такого подхода есть арифметическая беда. Если в базе двенадцать почти одинаковых руководств, то десять ближайших фрагментов вполне могут все оказаться чужими. После отбрасывания остаётся ноль, система «ничего не нашла» или, хуже, передаёт модели то, что осталось, пусть и не подходит.
Фильтр должен действовать внутри поиска: пространство кандидатов сначала сужается, и только потом внутри него ищутся ближайшие. В Qdrant это делается условиями по полям payload. Как сказано в документации Qdrant, условия фильтра проверяют поля payload, а условия в блоке must объединяются как логическое AND. Для нашей пары это значит: фрагмент попадёт в выдачу, только если совпали и модель, и модификация, и редакция.
{"filter":{"must":[{"key":"model","match":{"value":"PX-200"}},{"key":"modification","match":{"value":"B"}},{"key":"manual_revision","match":{"value":"3.0"}}]}}Покажем, что именно даёт AND, на условной таблице из четырёх фрагментов, которые одинаково хорошо отвечают на вопрос про проверку давления:
| Фрагмент | model | modification | manual_revision | Проходит |
|---|---|---|---|---|
| f1 | PX-200 | A | 2.1 | нет |
| f2 | PX-200 | B | 2.2 | нет |
| f3 | PX-200 | B | 3.0 | да |
| f4 | PX-300 | B | 3.0 | нет |
Любое одиночное совпадение вводит в заблуждение. Фрагмент f2 подходит по модели и модификации, но это устаревшая редакция. Фрагмент f4 из соседнего изделия совпал и по модификации, и по номеру редакции, потому что буквы и версии у разных линеек выглядят похоже. Только пересечение трёх условий оставляет f3. Поэтому каждое поле здесь — обязательное звено, а не необязательный довесок: убрав любое, вы возвращаете в выдачу двойника.
Статус current добавлять в must стоит тогда, когда режим запроса — «действующая редакция». Для режима «редакция, с которой поставлен аппарат» вместо него используется точное значение manual_revision, а статус не проверяется. Одна и та же база обслуживает оба режима, потому что различие вынесено в поля, а не в разные индексы.
Пустой результат фильтра — тоже результат. Если по трём условиям не нашлось ни одного фрагмента, это значит, что для комбинации в базе нет документа, а не что надо ослабить фильтр. Ослабление «на всякий случай» — прямая дорога обратно к ответу из чужого руководства.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.
От найденного фрагмента к цитате и открываемой странице
Допустим, фильтр отработал и нашёл фрагмент f3. Теперь надо превратить его в ответ, который можно проверить. Цепочка короткая, но каждое звено можно сломать.
- Берём из индекса запись. В ней текст, doc_id, редакция, section_path, страницы и ссылка на элемент исходной модели.
- Просим модель сформулировать ответ только по переданным фрагментам и вернуть идентификатор фрагмента, на который она опирается, вместе с короткой дословной цитатой.
- Проверяем цитату кодом, а не доверием. Цитата должна находиться как подстрока в тексте именно этого фрагмента, с учётом нормализации пробелов и переносов. Если не нашлась — ответ не уходит пользователю.
- Сверяем страницу. Номер страницы подставляем не из ответа модели, а из записи фрагмента. Модель может перепутать цифры, запись — нет.
- Формируем ссылку. Адрес документа плюс якорь страницы собираются из doc_id и page_pdf.
Третий шаг стоит пояснить. Языковая модель склонна слегка перефразировать «цитату»: заменить слово, склеить два предложения, поправить числительное. В обычной болтовне это терпимо, в инструкции к оборудованию — нет. Поэтому дословность проверяется механически. Короткая цитата длиной в одно-два предложения достаточна: её задача — дать читателю якорь для быстрого поиска глазами на открывшейся странице, а не заменить документ.
Четвёртый шаг важен по другой причине. Номер страницы, который «вспомнила» модель, — это её генерация, не данные. Пока страница приходит из записи индекса, она связана с фрагментом так же жёстко, как и текст. Это и есть смысл хранения происхождения: возможность сказать «этот текст находился там-то» без вероятностных догадок. Если при разборе были получены координаты, ссылку можно усилить и подсветить область на странице; если нет, достаточно открыть страницу целиком.
Итоговый ответ в интерфейсе выглядит примерно так: сначала короткая формулировка по существу, затем строка источника — «PX-200, модификация B, руководство 3.0, раздел 5.3, стр. 5-12 (PDF 47)» — и открывающая ссылка. Строка источника нужна не как украшение: она позволяет человеку мгновенно заметить, что система исходила из неверной модификации, если вы ошиблись при выборе.
Таблица, которая живёт дольше одной страницы
Таблицы — отдельная история, ради которой всё и затевалось. Именно в них лежат значения, ради которых спрашивают. И именно с ними чаще всего происходят неприятности: таблица делится между страницами, шапка повторяется или не повторяется, объединённые ячейки сбивают столбцы, а строка, вырванная из контекста, превращается в набор чисел без смысла.
Раз модель документа хранит таблицы как отдельные элементы, а не как поток текста, этим можно пользоваться. Для таблицы стоит создавать несколько представлений:
- Фрагмент на целую таблицу с подписью и заголовками столбцов — для вопросов вроде «что есть в таблице допустимых значений».
- Фрагмент на строку или логическую группу строк, у которого в начало добавлены заголовки столбцов и подпись таблицы. Тогда строка «Режим 2 — значение — единица» читается как законченное утверждение.
- Диапазон страниц, если таблица разорвана: поля page_pdf_start и page_pdf_end, а у каждой строки — своя страница.
Для строки-фрагмента страницей считается та, на которой строка физически находится, а не страница начала таблицы. Иначе читатель откроет страницу 47, а нужная строка окажется на 48, и доверие к системе тоже отправится на следующую страницу.
Теперь проверка цитаты для таблицы. Дословная подстрока из плоской строки «Режим 2 | 12 | ед.» выглядит странно для человека, который смотрит на страницу и видит сетку. Лучше, чтобы ответ называл оба элемента: подпись таблицы («Таблица 4») и заголовки столбца и строки. Вместо «цитаты» из ячейки он приводит координаты значения в таблице, а проверка сверяет, что такое сочетание «строка — столбец — значение» действительно есть в сохранённой структуре. Это тот случай, когда структура модели документа даёт больше, чем текст.
Единицы измерения, как правило, стоят в шапке, а не в каждой ячейке. Если фрагмент строки не содержит шапку, модель может подставить единицу по привычке. Поэтому шапку нужно дублировать в тексте фрагмента, а в промпте требовать приводить единицу только из переданного текста. Если единицы в фрагменте нет, правильное поведение — не угадывать, а отправить читателя на страницу.
Отдельно о значениях, которые связаны с безопасностью. Система поиска по документам показывает, что написано в руководстве, но не разрешает выполнять операцию. Если вопрос касается опасных работ, ответ должен приводить цитату и вести на страницу, а решение о допустимости принимает обученный специалист по руководству именно этого оборудования. Полезно прямо в шаблоне ответа оставить строку: «Проверьте значение на открывшейся странице и сверьте модификацию с шильдиком». Это не юридическая отписка, а часть механики проверки.
Когда версия неизвестна: отказ как рабочая функция
Самое сложное поведение — не найти, а отказаться. Система, у которой нет нужной модификации, не должна пытаться быть полезной любой ценой. Рассмотрим три ситуации.
Модификация не указана. Пользователь пишет «PX-200, какое давление при проверке». Фильтр по трём полям построить нельзя. Правильный ответ — не цифра, а вопрос: «Уточните модификацию по шильдику: для A и B руководства различаются». Можно показать, какие модификации есть в базе, чтобы человеку проще было выбрать. Попытка угадать «самую частую» превращается в тихую ставку на чужие цифры.
Модификация известна, но документа в базе нет. Фильтр по трём полям вернул пустой набор. Ответ: «В базе нет руководства для PX-200, модификация C». Не нужно подбирать соседнюю модификацию «похожей» редакции. Если организация считает, что руководство B подходит и для C, это должно быть записано в таблице соответствия модификаций и редакций, а не выбрано во время запроса.
Разные документы дают разные ответы. Допустим, пользователь просит показать, как изменился раздел между редакциями 2.2 и 3.0. Тогда система выполняет два отдельных поиска, каждый со своим фильтром, и показывает ответы рядом с отдельной ссылкой на страницу каждой редакции. Это единственный случай, когда в одном ответе законно соседствуют два документа, и они не смешиваются: у каждого свой блок источника.
Формулировку отказа стоит продумать заранее и сделать короткой, без извинений на полэкрана. Три предложения: чего не хватает, что нужно сообщить, куда можно обратиться за документом. Нетерпеливому пользователю короткий отказ помогает больше, чем многословное «к сожалению, наша система…».
Ещё один важный отказ — после проверки цитаты. Если модель вернула цитату, которая не нашлась в переданном фрагменте, пользователю не нужно сообщать о внутренней ошибке. Достаточно ответить, что система не смогла подтвердить ответ текстом руководства, и предложить открыть раздел по ссылке на найденный фрагмент. Ссылка на раздел остаётся честным результатом поиска, даже если пересказ не прошёл проверку.
Что всё это требует от сервера
Хорошая новость: такая схема не требует экзотического железа. Разбор PDF происходит при загрузке документа, а не при каждом вопросе. Индекс для нескольких десятков руководств с таблицами остаётся небольшим по меркам современного диска. Исходные PDF и сохранённые модели разобранных документов лежат на файловой системе, индекс с payload — в векторной базе, а веб-часть отдаёт страницы по ссылкам из ответов.
На VPS это раскладывается просто. У MAATRIX в публичном предложении указаны NVMe-диски, процессоры AMD EPYC и root-доступ, а линейка тарифов начинается с Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe) и идёт через Uab (2 vCPU, 4 ГБ, 80 ГБ), Heka (4 vCPU, 8 ГБ, 160 ГБ) и Maat (8 vCPU, 16 ГБ, 240 ГБ) до Djed (16 vCPU, 32 ГБ, 320 ГБ). Для небольшой библиотеки руководств логично начинать с малых конфигураций и смотреть на реальную загрузку при разборе PDF и построении индекса: разбор сложных документов — единственный по-настоящему тяжёлый этап, а запросы с фильтром по payload обходятся скромно. Ресурсы тарифа — это параметры машины, а не обещание, сколько документов или пользователей она выдержит; это нужно измерять на своих файлах. Если вы хотите, чтобы разбор и поиск шли на машине, к которой у вас есть полный доступ, а документы не покидали вашего сервера, root-доступ даёт нужную свободу выбора компонентов. Общую схему развёртывания, от установки до первых запросов, мы описывали в материале о подъёме RAG по своим документам на сервере; здесь же речь шла о том, что надстраивается поверх него, когда документы почти одинаковые, а цена ошибки не нулевая.
Заметьте, что ничего из перечисленного не зависит от размера модели или от того, есть ли графический ускоритель: версия, страница и фильтр — это данные и логика проверки. Большая часть надёжности ответа обеспечивается дисциплиной полей, а не мощностью железа.
Как проверить, что всё сложилось
Хороший способ убедиться, что система различает близнецов, — собрать маленький набор контрольных вопросов именно на пересечении версий. Берём раздел, который встречается в обоих руководствах с разными значениями, и задаём один и тот же вопрос трижды: с указанием модификации A, с указанием B и без указания. Ожидаемое поведение таково:
- для A ответ ссылается на документ 2.1 и страницу 41;
- для B — на документ 3.0 и страницу 47;
- без указания система просит уточнить, а не выбирает сама.
Если первые два ответа поменялись местами, проблема в полях или в фильтре. Если третий ответ содержит цифру — фильтр не обязателен в коде, а значит, он есть только на словах. Отдельно проверьте обрыв таблицы на границе страниц: строка, которая физически на 48-й странице, должна вести именно туда.
И последняя проверка, самая простая и самая человеческая: возьмите три ответа системы и пройдите по их ссылкам. Если вы видите страницу нужного руководства, находите цитату глазами за десять секунд и совпадает печатный номер в колонтитуле — цепочка работает. Если приходится листать вперёд и назад, значит, где-то потерялась связь между фрагментом и страницей.
Ответ, который заканчивается страницей
Вернёмся к вопросу про давление. Пользователь выбрал модификацию B, система применила фильтр по модели, модификации и редакции, нашла фрагмент из раздела 5.3, проверила дословность цитаты и достала страницу из записи индекса. Ответ получается коротким, под ним — строка источника «PX-200, модификация B, руководство 3.0, раздел 5.3, стр. 5-12 (PDF 47)» и ссылка. Значения в таблице 4 система не пересказывает от себя, а называет таблицу и столбец, чтобы читателю было куда смотреть.
Читатель нажимает на ссылку и оказывается на 47-й странице именно того руководства, которое относится к его аппарату. Подпись «Таблица 4» на месте, колонтитул подтверждает печатный номер, а нужная цитата находится глазами за несколько секунд. Теперь он сам сверяет модификацию с шильдиком и читает значение там, где его написал производитель. Ответ системы выполнил свою роль: он не заменил руководство, а довёл читателя до правильной страницы.