Коли посилання виглядають ідеально, а кількість осіб все одно неправильна
Сайт RAG заробітної плати використав правильний PDF, але все одно об’єднав дванадцять працівників в одного — гібридний пошук, стиснення з урахуванням таблиць та відстеження потоків даних виправили цю проблему.
Питання щодо заробітної плати має бути нудним. Якщо запитати, скільки осіб значиться у реєстрі заробітної плати за квітень 2025 року, очікуєш лише цифру чи посилання, без якихось цікавих деталей. Система повернула витончений речення: один працівник, назва PDF-файлу вказана, сторінка позначена. У реєстрі було зазначено дванадцять осіб.
Саме така ситуація невдачі робить генерацію з підтримкою пошуку небезпечною на практиці. Процес не зупинився, журнали подій залишилися без змін. Відповідь виглядала як будь-яка інша правильна відповідь з того ж тижня. „М’яка“ невдача з приміткою гірша за повну зупинку, адже ніхто не буде піднімати на вищий рівень просто ввічливий абзац.
У цьому описі реконструйовано ланцюжок операцій, який призвів до появи брехні, методи діагностики, що її виявили, та конкретні зміни, які дозволили повернути кількість до дванадцяти. Склад компонентів є цілеспрямовано звичайним: pdfplumber для роботи з текстом PDF із урахуванням макету, модуль LangChain для розділення тексту на частини, ембеддинги MiniLM, система Qdrant для роботи з векторами, гібридний метод пошуку типу dense-plus-BM25, модуль cross-encoder для переранжування результатів та компресор, призначений для скорочення обсягу контексту перед запуском генератора. Жоден із цих виборів не був незвичайним. Проблеми виникали через спосіб їх взаємодії з таблицями та ідентифікаторами.
Один раз завантаження, відповідь щоразу
Документи надходять лише один раз. Запити ж приходять постійно. Розділення цих шляхів має велике значення під час дебаггінгу, адже неправильна відповідь може бути наслідком застарілих векторів, некоректних фільтрів чи генератора, який так і не отримав потрібних даних.
Процес обробки проймає кожен PDF, витягує текст з урахуванням його форматування, ділить його на перекриваючіся фрагменти, вбудовує їх та додає до Qdrant разом із метаданими, які згодом можуть використовуватися для фільтрації:
PDF → extract text per page → cut into chunks
→ convert each chunk to 384 numbers → store in a vector database
Отримання відповідей — це інша схема. Вбудовуються запитання, отримуються кандидатські варіанти, за бажанням поєднуються результати пошуку за ключовими словами, відбувається переранжування та стиснення, після чого до моделі подається запит із доданими посиланнями:
question → search → rerank → compress → ask the LLM → cited answer
↓ ↓ ↓
5 chunks 3 chunks ~600 chars
На папері цей процес є класичним. Однак у реальних умовах класичні припущення не спрацьовують у випадку реєстрів працівників, таблиць інвойсів та будь-яких документів, де відповідь є структурою, а не гаслом.
Чому кожен етап знайшов своє місце
pdfplumber — це не найшвидша бібліотека для роботи з PDF. Це одна з небагатьох, яка зберігає достатньо інформації про форматування, щоб рядки з даними про зарплати не зливалися в один довгий абзац. Швидші інструменти для вилучення даних, які перетворюють таблиці на послідовності речень, ускладнюють подальші запитання на кшталт «скільки працівників», оскільки модель ніколи не бачить меж рядків.
Для цього проекту для розділення тексту на частини використовувався рекурсивний розділювач символів від LangChain, а не інші інструменти цієї фреймворк-системи. Метою було досягнення передбачуваних розмірів окон із перекриттям, а не створення певної схеми обробки даних. Після короткого тестування було обрано модель all-MiniLM-L6-v2 — менші моделі не враховували токени-ідентифікатори, а більші спричиняли затримки без усунення справжніх проблем. Qdrant виявився кращим завдяки здатності забезпечувати безперервність роботи від локальних систем до хмарних та фільтрам даних, які відповідали способу, у який додаток вже класифікував типи документів.
Гібридний пошук поєднував семантичну схожість із оцінкою ключових слів у стилі BM25 у співвідношенні приблизно 0,65/0,35. Чистий семантичний пошук добре справлявся з запитом «Яка політика щодо батьківської відпустки?», але погано — з STL/2025-26/003. Переоцінка результатів за допомогою крос-кодеру видалила непотрібні варіанти. Компресія мала на меті зберегти лише речення з високою якістю перед викликом LLM. Саме на цьому останньому етапі почалася історія про те, як з дванадцяти залишилося одне.
Впевнена неправильна відповідь
На запит про заробітну плату була знайдена правдоподібна сторінка, яку було процитовано, проте її значення було недооцінено. При розгляді первинних оцінок пошуку стала помітна перша проблема: семантичні «сусіди» здавалися «достатньо близькими», тоді як рядки з точним формулюванням погано конкурували з наративними текстами про кадри в інших частинах корпусу.
0.781 Invoice_INV2025001_Arvind.pdf <- returned
0.779 Invoice_INV2025002_Trident.pdf
0.776 Invoice_INV2025003_Welspun.pdf <- actually wanted
Це означає, що від алгоритму схожості не можна вимагати точної обробки окремих елементів тексту. Додавання методу BM25 та змішування оцінок змінило порядок ранжування, внаслідок чого ідентифікатори та фрагменти з таблицями піднялися вище:
0.854 Invoice_INV2025003_Welspun.pdf <- correct
0.549 Invoice_INV2025001_Arvind.pdf
0.545 Invoice_INV2025002_Trident.pdf
Однак цього було недостатньо для відновлення правильної кількості елементів. Це лише дозволило використати правильний документ. Наступні перешкоди знаходилися всередині самого документа.
ID співробітників як експеримент з виживання
Замість обговорень щодо „атмосфери“ ID співробітників ST001 до ST012 були простежені на кожному етапі: вилучення, розділення на фрагменти, пошук, переранжування, стиснення та остаточна компіляція запиту.
retrieved chunk ST001 ST002 ST003 ST004 ST005 ... ST012 (12 present)
after rerank ST001 ST002 ST003 ST004 ST005 ... ST012 (12 present)
after compression ST001 ST002 (2 present)
prompt sent ST001 ST002 (2 present)
Кілька ідентифікаторів зникло між процесом стиснення та обробкою моделлю. Компресор використовував фіксований ліміт на кількість „ключових речень“. Одна рядок у таблиці вважається реченням. Якщо таблиця заробітної плати є дуже густою, ліміт витрачається на перші рядки, тоді як пізніші рядки — а також текст, що їх пояснює — ніколи не потрапляють до запиту. Модель потім чесно повідомляє про ту частину інформації, яку бачила.
# before — keep the top N
best = scored[:40]
# after — keep the best until the character budget runs out
budget = MAX_CONTEXT_CHARS // TOP_K_AFTER_RERANK
for sentence, score in ranked:
if best and len(sentence) + 2 > budget:
continue
best.append(sentence)
budget -= len(sentence) + 2
Після того, як процес стиснення перетасував та обрізав рядки, таблиця заробітної плати нагадувала колоду карт, розкладену в неправильному порядку. Кожне число могло існувати десь у вікні контексту, проте структура, необхідна для підрахунку окремих працівників, зникла. Підрахунок людей у таблиці, розділеній на частини, — це інше завдання, ніж просто читання таблиці.
Оцінки реранкерів, які здавалися нормальними, але все одно завдали шкоди
Оцінки крос-енкодера для критичних рядків виглядали „разумними“. Однак разумність — це не те саме, що збереження повної таблиці.
0.446 keep ST001 Rajesh Kumar M CFO 75,000 ...
0.323 keep ST002 Priya Sundaram Finance Mgr 45,000 ...
0.420 keep ST003 Murugan K Sr Accountant 28,000 ...
0.259 DROP ST005 Selvam R Production Mgr 38,000 ... ← below 0.30
0.422 keep ST006 Karthik P Supervisor 18,000 ...
# pick the best by score…
chosen = set(id(p) for p in best)
# …then put them back in document order before joining
best = [p for p in scored if id(p) in chosen]
Ремонт не полягав у „підвищенні порогу скрізь“. Йшлося про виявлення рядків, схожих на таблицю, та звільнення їх від агресивного стиснення. Практична евристика: якщо рядок містить чотири або більше чисел та знаходиться серед принаймні трьох схожих рядків у тому ж блоку, його слід вважати рядком таблиці та зберегти, незалежно від оцінки речення.
ROW_MIN_NUMBERS = 4
TABULAR_MIN_ROWS = 3
def is_row(sentence):
return len(NUMBER_RE.findall(sentence)) >= ROW_MIN_NUMBERSif looks_tabular(sentences):
survivors = [p for p in scored if is_row(p[0]) or p[1] >= threshold]
else:
survivors = [p for p in scored if p[1] >= threshold]
Після того, як рядки таблиці були звільнені, усі дванадцять ID залишилися у запиті під час наступної обробки. Генератор нарешті отримав структуру, яку можна було підраховувати.
Невеликі корективи, які усунули інші проблеми
Поки був відкритий шлях до таблиці, кілька суміжних проблем отримали однакове рішення: порожні змінні середовища, які тихо замінювали заплановані значення за замовчуванням, фільтри типу документа, які поводилися по-різному у локальній версії Qdrant та в Qdrant Cloud, а також дані відповідей, які повертали лише прозу, хоча операторам була потрібна інформація про ланцюжок обробки.
# the context cap must be at least CHUNK_SIZE × TOP_K_AFTER_RERANK
MAX_CONTEXT_CHARS = 5000 # was 2000 while 1200 × 3 = 3600
Тепер кожна відповідь повертає всю інформацію про ланцюжок обробки, а не лише остаточне речення — отримані ID, оцінки, рішення щодо стиснення та фільтри — щоб наступну помилку можна було детально проаналізувати без здогадок:
{
"answer": "12 employees are listed...",
"citations": [{"doc": "Salary_Register_April_2025.pdf", "page": 1}],
"pipeline": {
"retrieved": 5,
"after_rerank": 3,
"chars_before_compression": 3847,
"chars_after_compression": 2103
}
}
Фільтрація за типом документа працювала у локальній версії Qdrant, але так само зазнавала невдач у Qdrant Cloud, доки індекси та формати фільтрів у данах відповідей не збігалися з тим, що насправді індексувалося в хмарній версії:
500 — Index required but not found for "doc_type"
Схожий підхід у Python: функція os.getenv("KEY", "default") повертає порожню стрічку, коли змінна існує, але є порожньою. Це не є стандартною поведінкою. Краще використовувати оператор or, якщо необхідно, щоб у разі порожнього значення виконувався наступний код:
QDRANT_URL = os.getenv("QDRANT_URL") or "http://localhost:6333"
Де зупинився процес
Після гібридного отримання даних, стиснення з урахуванням структури таблиці та використання спеціальних засобів моніторингу потоку даних, запит щодо реєстру за квітень повернув дванадцять результатів із посиланнями на рядки, які обґрунтовували цю кількість.
Retrieval: hybrid, 0.65 semantic + 0.35 BM25
Reranking: cross-encoder, 5 in → 3 out
Compression: sentence-level, table-aware, ~35% reduction
Grounding: prompt + threshold + temperature 0.0
Evaluation: 13/15 on the golden set, refusals tested
Latency: ~4s end to end
Cost: ~$0.003 per fifteen-question run
Для всього цього не було потреби у новій моделі. Необхідно було розглядати RAG як потік даних із вимірюваними показниками виживання токенів, які мають значення. У навчальних матеріалах пропонується послідовність „вбудовувати, отримувати, генерувати“. У продакшні ж важливо „довести, що рядки дійсно потрапили до запиту“.
Професійні звички, які забезпечують точність підрахунків
Зберігайте набір золотих запитань, який включає принаймні один запит на точний підрахунок елементів у таблиці, один запит на пошук за точним ідентифікатором та одне семантичне запитання. Якщо пройдуть лише семантичні запитання, демонстрація бреше щодо готовності.
Журналізуйте ідентифікатори частин даних під час стиснення. Якщо ідентифікатор, присутній після отримання даних, відсутній після стиснення, це є помилкою, навіть якщо остаточна відповідь випадково буде правильною сьогодні.
Для корпусів, які поєднують прозу та різні стилі, віддавайте перевагу явним гібридним підходам. Лише інтенсивний пошук буде ефективним для есе, але неспроможним для коду.
Вважайте посилання необхідними, але недостатніми. Номер сторінки поруч із неправильним підрахунком все одно означає неправильний підрахунок.
Під час переходу від локальної версії Qdrant до хмарної перевіряйте знову індекси даних та поведінку фільтрів за допомогою тих самих тестових даних. Сумісність API не означає ідентичних стандартів індексування.
Бюджетний контекст стосується структури, а не лише „важливих речень“. Таблиці є частиною структури. Їх стиснення, як у блогових абзацах, — це спосіб того, як дванадцять перетворюється на одне.
Чому „м’які“ збої потребують суворіших критеріїв
Команди часто дозволяють запуск RAG на основі критерію „відповіді виглядають добре у таблиці з запитами типу happy-path“. Цей критерій не враховує справжні проблеми у виплатах заробітної плати, управлінні запасами та дотриманні правил: неправильне підрахунок. Необхідно додати автоматизовані перевірки, які будуть переконуватися, що витягнуті набори елементів залишаються у запиті для відомих елементів конфігурації. Заблокуйте процес будування, якщо після стиснення елементи ST001–ST012 не з’являються у конфігурації заробітної плати.
Той самий критерій може перевіряти, чи справді у діючій конфігурації активовані ваги гібридного поєднання, а не лише у документі README. Розбіжності між експериментами в ноутбуці та конфігурацією сервісу — це поширена причина того, чому алгоритм BM25 „зникає“ після рефакторингу.
Нарешті, навчіть операторів не довіряти привабливим цитатам. Інтерфейс продукту має відображати дані про пошук та стиснення поруч із відповіддю для внутрішніх користувачів, доки «золотий набір» залишатиметься зеленим протягом циклу випуску. Зовнішні користувачі можуть залишити чистий абзац; внутрішнім користувачам потрібен набір інструментів для детального аналізу.
Заключення
У реєстрі ніколи не зазначалося, що є один працівник. Пайплайн так зазначив, після того як тихо видалив рядки, які могли б це виправити. Щоб виправити ситуацію, знадобився гібридний пошук ідентифікаторів, стиснення з урахуванням структури таблиць та відповіді, які містили б власну інформацію про джерело. Коли всі ці елементи були впроваджені, дванадцять залишилося дванадцятьом — і наступна впевнена цитата мала реальні підстави.
Ось який стандарт варто підтримувати: не те, чи може модель звучати впевнено, а те, чи може система довести факти, які обґрунтовують цю впевненість.
Більш детальний погляд на гібридну оцінку на практиці
Метод щільного пошуку кодує запит та кожен фрагмент у один і той самий векторний простір та ранжує результати за косинусом або скалярним добутком. Цей підхід працює, коли мова користувача збігається з мовою документа: батьківська відпустка, вихідна допомога, політика віддаленої роботи. Він не функціонує, коли користувач вставляє незрозумілий токен, який майже не зустрічається у навчальних даних та майже не зустрічається разом із сусідніми словами у векторному просторі. Коди співробітників, номери рахунків-фактур та ідентифікатори контрактів належать саме до цієї категорії токенів.
BM25 та схожі інструменти оцінки лексичного складу змінюють підхід до проблеми. Вони звертають увагу на те, чи зустрічається токен, наскільки він рідкісний у корпусі та як часто з’являється у кандидатському фрагменті. Їм байдуже, що “STL/2025-26/003” семантично майже ні з чим не пов’язаний. Поєднання цих двох показників не є філософським рішенням; це просто визнання того, що корпуси з оплатою праці містять як текстові документи, схожі на есе, так і таблиці, схожі на реєстри.
Співвідношення 0,65 / 0,35, яке використовується тут, є лише відправною точкою, а не законом природи. Корпуси, які містять багато кодів, можуть потребувати більшої ваги лексичних показників, тоді як корпуси, засновані на наративах, — меншої. Важливо вимірювати обидва типи запитань у „золотому наборі“ та уникати використання поєднань, які працюють лише з наративними запитами.
Під час застосування методу змішування необхідно нормалізувати оцінки перед їх поєднанням. Чисті показники щільності схожості та чисті оцінки BM25 належать до різних масштабів. Команди, які використовують ненормалізовані числа, часто виявляють, що один канал випадково домінує. Методи об’єднання типу min-max чи rank fusion (RRF) є прийнятними, якщо їх тестувати з даними, що містять точні ідентифікатори.
Компресія як втратний кодек для таблиць
Компресія контексту існує через обмежену кількість елементів у моделях та через те, що нерелевантні речення відволікають увагу. Стандартний підхід полягає у оцінюванні речень за їхньою релевантністю, збереженні найкращих k речень та видаленні решти. Цей підхід передбачає, що речення є взаємозамінними одиницями значення. Однак рядки таблиць не є взаємозамінними одиницями значення. Рядок 7 без рядків 1–6 — це не просто таблиця з трохи гіршими показниками; це пошкоджена таблиця.
Гіпотеза про звільнення — багато чисел у одному рядку, кілька таких рядків поруч — є навмисно примітивною. Вона дозволяє залишити деякі рядки, що не є таблицями, але виглядають числовими, і це прийнятно. Хибні позитивні результати коштують токенів, а хибні негативні — точності. Для розрахунку заробітної плати та управління запасами точність має перевагу.
Більш складні детектори можуть використовувати структуру PDF з pdfplumber: рамки клітинок, вирівняні стовпці, повторювані координати X. Ці сигнали є чудовими, коли вони доступні. Гіпотеза речення залишається корисною як альтернатива, коли текст вже було перетворено на markdown або звичайний текст перед процесом стиснення.
Ще один спосіб збою — перетасування. Навіть якщо всі рядки збережуться, їх переупорядкування за рейтингом значущості може зруйнувати суми та підрахунки. Для рядків таблиць, що підлягають звільненню, краще зберігати стабільний порядок документа. Прозу можна переставляти на свій розсуд; таблиці слід зберігати у порядку читання.
Цитати, які обґрунтовують неправильну відповідь
Citation UX часто підкреслює PDF та сторінку, які надали найбільшу кількість отриманих даних. Коли пізніше стиснення скорочує обсяг таблиці вдвічі, посилання все одно вказує на правильний файл. Користувачі сприймають це як підтвердження. Дизайн продукту має або вказувати конкретні рядки, які використовувалися у кінцевому запиті, або показувати сліди пошуку. Інакше інтерфейс стає співучасником проблеми.
Для регульованих доменів необхідно зберігати точний хеш контексту запиту разом із відповіддю. Якщо аудитор запитає, чому система вказала „один співробітник“, можна переглянути скорочену таблицю та показати помилку, замість того щоб обговорювати властивості моделі.
Локальні та хмарні векторні бази даних
Фільтр типу документа, який працював локально, але не спрацював у Qdrant Cloud, нагадує про те, що «один і той самий API» не означає «однакову конфігурацію індексу». Індекси вантажу, фільтри за ключовими словами та обробка значень null відрізняються у різних режимах розгортання. Тести мають виконуватися у тому ж режимі розгортання, що й у продакшені. Зелений статус локальних тестів разом із червоним статусом у хмарі означає, що операції отримання даних проходять без проблем.
Порожні змінні середовища також вимагають особливої уваги. Системи конфігурації, які експортують порожні рядки для неприсвоєних секретів, обійдуть значення за замовчуванням у мовах, де порожнє значення вважається істинним. Нормалізуйте конфігурацію на початку процесу: вважайте порожнє значення відсутнім, потім застосуйте значення за замовчуванням, а якщо необхідні ключі все ще відсутні — припиніть роботу процесу.
Вимірювання стабільності, а не вражень
Експеримент з визначенням стабільності ідентифікатора працівника є шаблоном. Виберіть елементи, які повинні бути присутні для отримання правильної відповіді. Перевірте їх наявність після вилучення, розділення на частини, пошуку, переранжування та стиснення. Побудуйте графік зниження якості. Перша криза зазвичай спричинена помилкою.
Розширіть цю ідею на числові агрегати. Якщо запитання стосується суми, переконайтеся, що кожен елемент, який береться до сумування, дістався до вхідних даних. Якщо запитання стосується підрахунку унікальних елементів, переконайтеся, що набір ключів унікальних елементів є повним. Ці перевірки є дешевими у порівнянні з інцидентами в реальних умовах роботи.
Що не варто звинувачувати спочатку
Є спокуса звинувачувати ШІ, коли підрахунок є неправильним. Іноді модель справді не може рахувати. Частіше ж модель взагалі не отримувала структуру, придатну для підрахунку. Змінюйте моделі лише тоді, коли графік стабільності буде зеленим. Інакше ви „виправите“ помилку підрахунку, перейшовши на більш потужну модель, яка створить хибні значення, яких ви очікуєте — аж до наступної інформації.
Аналогічно, утримуйтеся від повного заміни всієї системи через неправильну роботу одного компресора. Ізолюйте проблемну частину, додайте винятки, проведіть тестування та рухайтесь далі. Масштабні зміни здаються ефективними, але часто призводять до появи тих самих проблем під новими назвами.
Мінімальний перелік кроків для посилення надійності
- Ключові запитання: кількість елементів у таблиці, точний ідентифікатор, семантична політика.
- Гібридний метод пошуку з нормалізованим поєднанням даних або RRF.
- Компресія з урахуванням структури таблиці та стабільним порядком рядків.
- Журналування кожної внутрішньої відповіді.
Перевірте цей перелік перед тим, як вважати роботу системи RAG з оплатою праці завершеною. Журнал за квітень не буде останнім документом, який здається простим, але приховує проблеми.
Наслідки для команди
Коли робота дванадцяти співробітників стабілізувалась, та сама система моніторингу виявила ще дві менш помітні проблеми: застарілий псевдонім колекції після повторного завантаження даних та проблеми з тайм-аутом ранжувальника, через які використовувалися неранжовані результати, без позначки про погіршення якості відповіді. Обидві проблеми залишилися б непоміченими, якби API повертав лише рядок тексту. Повернення структурованих даних — оцінок, часів виконання, альтернатив — перетворило асистента на інструмент, якому оператори могли довіряти настільки, щоб виправляти його проблеми о 2 годині ночі.
Ось справжній урок з продукту. Системи RAG — це не просто чат-інтерфейси над PDF-файлами. Це канали передачі даних, які формулюють інформацію у вигляді абзаців. Ставтеся до них як до каналів: вимірюйте втрати даних, зберігайте структуру та ніколи не дозволяйте цитатам замінювати докази.
Ще один аналіз початкової неправильної відповіді
Повторне переглядання поганої відповіді з доданими слідами робить ситуацію майже нудною. Система пошуку майже знайшла правильний PDF-файл; гібридна система оцінювання завершила цю роботу. Потім система стиснення витратила свій бюджет на обробку перших числових рядків та видала решту. Модель підрахувала те, що залишилося, та навела посилання на файл. Кожен етап окремо виконував щось обґрунтоване; разом вони створили відчуття надійності.
Саме тому локальні метрики на етапі обробки можуть вводити в оману. Показник Retrieval@k може здаватися нормальним, тоді як стиснення руйнує результат пошуку. Справжньою метрикою, яка відображає шкоду для користувача, є рівень виживання ентитетів від початку до кінця процесу. Впроваджуйте її якомога раніше, особливо коли документи мають вигляд таблиць, запакованих у формат PDF.
Якщо ви не збираєтесь запам’ятати нічого іншого з цього інциденту з дванадцятьма співробітниками, запам’ятайте це: ввічливі помилки системи RAG є багами у процесі обробки, доки не буде доведено інше. Встановіть засоби моніторингу, захистіть структуру даних та змусьте систему показувати свою роботу, перш ніж довіряти її результатам. М’які помилки вимагають суворих контрольних механізмів, постійної перевірки та операторів, які можуть бачити кожну деталь, перш ніж користувачі знову будуть довіряти наведеним цифрам.
Виживання ентитетів під час стиснення залишається найпростішим та найчеснішим критерієм для корпусів даних, що складаються переважно з таблиць.
Коли надійде наступний набір даних, знову проведіть аналіз виживання ID, перш ніж довіряти будь-яким новим цитуванням.