Иерархический чанкинг: почему нарезка таблиц ломает аудит рецептур
Пайплайн автоматической сертификации проверяет техническую документацию поставщика на растительное овсяное молоко для получения европейского знака V-Label.
Технический паспорт сырья содержит многоуровневую таблицу состава:
Продукт: Овсяная основа Barista #402
├── Подсистема: Регулятор кислотности
│ └── Компонент: Дикалийфосфат (E340ii)
│ └── Вспомогательное вещество: Синтетическая фосфорная кислота (минеральная)
└── Подсистема: Эмульсионная матрица
└── Компонент: Моно- и диглицериды жирных кислот (E471)
├── Субкомпонент: Стеариновая кислота (пальмовая, чистота 98%)
└── Растворитель-носитель: Глицерин (CAS 56-81-5)
└── Происхождение: Гидролиз жирных кислот говяжьего жира
Команда инженеров настроила обработку документов стандартным методом: нарезкой текста на блоки фиксированного размера (chunk_size=500, chunk_overlap=50).
Текстовый сплиттер идет по файлу как механический шредер. Дойдя до вложенной таблицы, он разрезает строки строго по лимиту символов:
- Фрагмент №1 забирает заголовок и верхние строки: «Овсяная основа Barista #402… Регулятор кислотности… Дикалийфосфат (E340ii)… Синтетическая фосфорная кислота (минеральная)…»
- Фрагмент №2 начинается прямо посреди ячеек: «| Стеариновая кислота | Пальмовая | 98% | Глицерин | CAS 56-81-5 | Гидролиз кислот говяжьего жира |»
Когда поисковая система ищет запрещенные компоненты животного происхождения, векторная база выдает Фрагмент №2.
Векторное сходство высокое. Однако внутри Фрагмента №2 нет ни слова о том, к какому продукту относится эта строка: упоминание основы Barista #402 и эмульгатора осталось во Фрагменте №1. Языковая модель видит изолированную строку таблицы, не может определить привязку к рецептуре и считает ее посторонней справочной записью из каталога. А Фрагмент №1 получает статус соответствия, поскольку в его строках фигурирует только минеральный фосфат.
Механическая нарезка текста разорвала связь между родительским продуктом и свойствами его компонентов.
Аналогия с инструкцией LEGO
Представьте инструкцию по сборке звездолета LEGO из двух тысяч деталей.
Перед сборкой страницы разрезают ножницами на одинаковые квадратики пять на пять сантиметров.
Вы берете один квадратик. На нем крупно нарисована маленькая красная деталь. Но ножницы отрезали номер страницы, номер шага и общую схему сборки, на которой показано, что эта деталь устанавливается внутрь левого двигателя на сорок восьмом шаге.
Деталь видна, но понять ее место в общей конструкции невозможно.
Человек может разложить все обрезки на столе и сопоставить края бумаги по рисунку. Векторный эмбеддинг не умеет сопоставлять разорванные границы: вектор строки таблицы в пространстве координат отражает только те слова, которые попали в границы конкретного фрагмента.
Анатомия таблиц комплаенса
Технические спецификации на сырье, паспорта безопасности и рецептурные карты — не сплошной текст. Это иерархические деревья связей, оформленные в виде табличной сетки.
В рецептуре смысл передается строго по цепочке:
Продукт (Овсяная основа Barista)
│
└── Технологическая функция (Эмульгатор)
│
└── Химическое вещество (E471)
│
└── Растворитель-носитель (Глицерин)
│
└── Биологический источник (Говяжий жир)
Каждый дочерний элемент (Говяжий жир) обретает юридическое значение только через цепочку предков (Глицерин → E471 → Эмульгатор → Овсяная основа Barista).
При разрыве этой цепочки возникают системные ошибки:
- Потеря идентификатора: строка теряет код сырья, и нарушение невозможно привязать к конкретной партии.
- Утрата заголовков колонок: числовое значение вроде «0.05 мг/кг» теряет заголовок столбца, из-за чего непонятно, что это: допустимый предел обнаружения, концентрация примеси или норма ввода.
- Отрыв примечаний: сноски со звездочками («Растительное происхождение, если иное не указано в паспорте качества») отделяются от строк, к которым они относятся.
Решение: иерархический чанкинг и связь родитель-потомок
Для сохранения табличного контекста без раздувания задержки поиска применяют иерархический чанкинг (parent-child chunking).
Вместо плоского разбиения парсер разбирает документ в структурированное дерево:
┌─────────────────────────────────────────────────────────────┐
│ РОДИТЕЛЬСКИЙ ФРАГМЕНТ (Полный контекст таблицы для LLM) │
│ Содержит рецептуру целиком, заголовки колонок и сноски. │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ДОЧЕРНИЙ ФРАГМЕНТ 1 (Якорь векторного поиска) │ │
│ │ "Продукт: Основа Barista > Эмульгатор > E471 > │ │
│ │ Глицерин > Источник: Говяжий жир" │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ДОЧЕРНИЙ ФРАГМЕНТ 2 (Якорь векторного поиска) │ │
│ │ "Продукт: Основа Barista > Стабилизатор > │ │
│ │ E340ii > Фосфорная кислота > Источник: Минерал" │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Система работает на двух уровнях:
- Дочерние фрагменты (компактные векторы для точного поиска): каждая строка таблицы извлекается и дополняется полным путем предков (Продукт → Подсистема → Компонент). Полученная строка индексируется для векторного и лексического поиска.
- Родительские фрагменты (широкий контекст для модели): дочерний элемент хранит идентификатор связи (
parent_id), указывающий на полную таблицу. - Подмена контекста при выдаче: когда поиск находит дочернюю строку, среда выполнения подтягивает по ссылке родительский блок и передает в языковую модель всю таблицу целиком.
Поисковый индекс получает точность отдельной строки, а языковая модель — всю структуру связей документа.
Реализация: обогащение строк контекстом предков
from dataclasses import dataclass
@dataclass
class DocumentNode:
node_id: str
parent_id: str | None
breadcrumb_path: str
content: str
full_table_context: str
class HierarchicalTableParser:
def parse_ingredient_matrix(self, raw_table: dict) -> list[DocumentNode]:
"""
Разбирает вложенные таблицы сырья на дочерние поисковые узлы,
связанные с полным родительским контекстом.
"""
product_name = raw_table["product_name"]
full_table_text = raw_table["markdown_representation"]
nodes = []
for section in raw_table["sections"]:
category = section["category_name"]
for row in section["ingredients"]:
ingredient = row["name"]
carrier = row.get("carrier", "Нет")
origin = row.get("origin", "Не указано")
# Собираем путь предков для поискового вектора
breadcrumb = f"{product_name} → {category} → {ingredient}"
searchable_content = (
f"Иерархия: {breadcrumb} | Носитель: {carrier} | "
f"Происхождение: {origin} | CAS: {row.get('cas_number', 'Н/Д')}"
)
nodes.append(DocumentNode(
node_id=f"{raw_table['doc_id']}_{row['row_id']}",
parent_id=raw_table["doc_id"],
breadcrumb_path=breadcrumb,
content=searchable_content,
full_table_context=full_table_text
))
return nodes
При таком подходе запрос «носитель животного происхождения в овсяном молоке» находит конкретную строку. Система извлекает parent_id и подставляет полную таблицу в контекст генерации.
Модель видит и нарушение, и его точное положение в производственной рецептуре.
Инженерные показатели
Переход на иерархический парсинг меняет характеристики поискового пайплайна:
- Объем поискового индекса: добавление цепочки предков в каждую строку увеличивает объем токенов в индексе на 22%, полностью устраняя потерю контекста.
- Точность извлечения пороговых значений: точность привязки пределов обнаружения и примесей вырастает с 61% (при обычной нарезке) до 96% (при иерархическом связывании).
- Доказуемость аудита: каждая находка ссылается на конкретный идентификатор строки, позволяя инспектору мгновенно открыть как точечный факт, так и полный исходный документ.
В понедельник, в девятой главе (финале серии), мы разберем Неумолимого инспектора (детерминированные валидаторы и Ready Score): как объединить RAG с регулярными выражениями, таблицами запрещенных добавок, оценкой готовности разделов и циклами повторных проверок для генерации финального досье.
Как ваш пайплайн индексации обрабатывает многостраничные вложенные таблицы, и тестировали ли вы обогащение строк контекстом предков для защиты от разрыва связей?