Files

8 lines
22 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 100,
"slug": "editorial-2025-03-field-knowledge-retrieval",
"title": "Когда поиск по базе знаний должен остановиться",
"excerpt": "Похожий фрагмент не становится доказательством только из-за высокого score. Разбираем проверку версии, доступа и точной цитаты, а также воспроизводимый stop-ответ для неполного результата.",
"contentHtml": "<p>Представим рабочий вопрос: «Как откатить релиз?» Поиск возвращает абзац из runbook с высоким score. В нём есть знакомые слова, но документ уже заменён, фрагмент закрыт для текущей роли или ссылка ведёт только на главную страницу. Если сразу передать текст в ответ, система превратит совпадение слов в указание к действию.</p>\n<p>Здесь опасен не сам поиск. Ошибка возникает на границе между найденным кандидатом и разрешённым утверждением. Старый runbook может привести к неправильной последовательности отката. Закрытый фрагмент нельзя раскрывать даже ради «полезного контекста». Ссылка без точного места не позволяет коллеге перепроверить вывод. Поэтому зрелый retrieval должен уметь завершаться ответом <code>stop</code>, когда доказательств недостаточно.</p>\n<h2>Похожесть отвечает только на один вопрос</h2>\n<p>Retrieval — этап, который выбирает фрагменты из корпуса по запросу. Текстовый индекс, embeddings и reranker помогают упорядочить кандидатов. Их score отвечает на вопрос «насколько запись похожа на запрос по данной модели». Он не отвечает на вопросы «действует ли правило», «можно ли его показать этой роли» и «есть ли в источнике точное место для цитирования».</p>\n<p>Эти вопросы нужно разделить в контракте. Пусть кандидат проходит три независимые проверки: доступ разрешён субъекту запроса, срок пригодности не истёк по политике конкретного типа вопроса, а источник содержит устойчивый anchor — заголовок, номер раздела или другой способ указать точное место. Только после этого разрешённый фрагмент можно передавать в слой формирования ответа.</p>\n<p>Если лучший по score кандидат не прошёл проверку, это ещё не означает немедленный stop: можно проверить следующий кандидат. Но если все подходящие записи отклонены, система обязана вернуть безопасную причину и маршрут к владельцу. Она не должна «додумывать» недостающий фрагмент из похожих документов.</p>\n<h2>Карточка источника и границы дат</h2>\n<p>Поиск не сможет проверить то, чего нет в метаданных. Для каждой записи полезно хранить стабильный <code>sourceId</code>, <code>version</code>, канонический <code>uri</code>, <code>anchor</code>, класс доступа, владельца, <code>publishedAt</code>, <code>indexedAt</code> и управляемый политикой <code>expiresAt</code>. Это не универсальная схема базы данных, а минимальный набор для рассматриваемого решения. Поля надо согласовать с владельцем корпуса и правилами доступа.</p>\n<p>Даты имеют разные смыслы. <code>publishedAt</code> описывает версию, <code>indexedAt</code> показывает задержку индексатора, а <code>expiresAt</code> — локальное правило, после которого операционный вопрос нельзя закрывать этой записью без подтверждения. Для исторического вопроса срок может быть другим: старый документ допустим как описание прошлого, но ответ должен назвать версию и дату.</p>\n<p>Не подменяйте это правило заголовком HTTP. RFC 9110 определяет <code>Last-Modified</code> как время, когда origin, по собственному мнению, в последний раз изменил выбранное представление. Заголовок полезен для условных запросов и кэша, но не говорит, отменено ли содержание документа, изменился ли владелец или разрешён ли доступ. Семантический срок действия остаётся частью политики корпуса.</p>\n<figure><img src='/assets/editorial/2025/knowledge-retrieval-2025-verification-loop.svg' alt='Цикл проверки ответа по базе знаний: запрос, кандидаты, проверка доступа и срока, точный якорь, сверка человеком, ответ или остановка' loading='lazy' /><figcaption>Кандидат становится частью ответа только после проверки прав, срока и точного места в источнике; любая невыполненная проверка ведёт в безопасную ветку остановки.</figcaption></figure>\n<h2>Доступ проверяется до передачи фрагмента</h2>\n<p>Индексатор может читать больше, чем конкретный пользователь. Поэтому факт, что поисковый сервис нашёл запись, ничего не доказывает о праве показать её субъекту запроса. Сначала определите субъекта, ресурс и цель обращения, затем получите решение authorization. Только после положительного решения можно раскрывать excerpt.</p>\n<p>Безопасный stop-ответ не пересказывает запрещённый текст. В нём достаточно общего кода <code>access-denied</code>, <code>expired</code>, <code>missing-anchor</code> или <code>policy-unknown</code> и следующего действия: запросить доступ, назначить владельца, обновить карточку или выбрать другой корпус. Даже название закрытого документа может быть чувствительным, поэтому его возврат тоже должен проходить через policy.</p>\n<p>Это согласуется с моделью Zero Trust из NIST SP 800-207: доверие не выводится из нахождения в локальной сети или из принадлежности ресурса организации, а authentication и authorization рассматриваются как разные функции до установления сессии к ресурсу. Стандарт не описывает ваш индекс и не выдаёт готовую ACL; он задаёт границу, которую нельзя заменять сетевой близостью.</p>\n<h2>Воспроизводимый пример с положительной и отрицательной веткой</h2>\n<p>Ниже — самостоятельный пример для Node.js 18+ без внешних пакетов. Данные синтетические, дата намеренно зафиксирована, чтобы результат не менялся завтра. Сохраните фрагмент как <code>retrieval-check.mjs</code> или вставьте его в оболочку <code>node --input-type=module</code>. Ожидаемый вывод: сначала разрешён текущий документ с меньшим score, затем stop после искусственного истечения всех записей.</p>\n<pre><code>const records = [\n {\n sourceId: 'runbook-legacy',\n version: '4',\n score: 0.99,\n accessClass: 'internal',\n expiresAt: '2025-02-01',\n uri: 'https://docs.example.test/rollback',\n anchor: 'rollback-release'\n },\n {\n sourceId: 'runbook-private',\n version: '7',\n score: 0.98,\n accessClass: 'restricted',\n expiresAt: '2025-08-01',\n uri: 'https://docs.example.test/rollback',\n anchor: 'rollback-release'\n },\n {\n sourceId: 'runbook-current',\n version: '8',\n score: 0.75,\n accessClass: 'internal',\n expiresAt: '2025-08-01',\n uri: 'https://docs.example.test/rollback',\n anchor: 'rollback-release'\n }\n];\n\nconst policy = {\n asOf: '2025-03-20',\n allowedClasses: new Set(['internal'])\n};\n\nfunction checkCandidate(record, currentPolicy) {\n if (!currentPolicy.allowedClasses.has(record.accessClass)) {\n return { accepted: false, reason: 'access-denied' };\n }\n if (!record.expiresAt || record.expiresAt &lt; currentPolicy.asOf) {\n return { accepted: false, reason: 'expired' };\n }\n if (!record.uri || !record.anchor) {\n return { accepted: false, reason: 'missing-anchor' };\n }\n return { accepted: true };\n}\n\nfunction retrieve(candidates, currentPolicy) {\n const reasons = [];\n for (const record of [...candidates].sort((a, b) =&gt; b.score - a.score)) {\n const decision = checkCandidate(record, currentPolicy);\n if (decision.accepted) {\n return {\n status: 'accepted',\n sourceId: record.sourceId,\n version: record.version,\n citation: record.uri + '#' + record.anchor\n };\n }\n reasons.push(decision.reason);\n }\n return { status: 'stop', reasons: [...new Set(reasons)], next: 'owner-review' };\n}\n\nconsole.log(retrieve(records, policy));\nconsole.log(retrieve(records.map((record) =&gt; ({ ...record, expiresAt: '2025-01-01' })), policy));</code></pre>\n<p>Первая строка выбирает <code>runbook-current</code>: два более похожих кандидата отклонены по разным причинам, а доступный свежий документ имеет точный anchor. Вторая возвращает <code>status: 'stop'</code> и причины без excerpt. Это проверяет форму контракта, но не настоящую авторизацию: <code>allowedClasses</code> здесь передаётся вручную и не является решением доверенного сервиса.</p>\n<h2>Диагностика симптома по слоям</h2>\n<table><caption>Что проверять, если ответ выглядит убедительно</caption><thead><tr><th scope='col'>Симптом</th><th scope='col'>Что он доказывает</th><th scope='col'>Что проверить</th><th scope='col'>Следующий шаг</th></tr></thead><tbody><tr><td>Высокий score у старого документа</td><td>Кандидат похож на запрос по модели ранжирования</td><td><code>expiresAt</code>, тип вопроса, владелец политики</td><td>Проверить следующий кандидат или вернуть <code>expired</code></td></tr><tr><td>Индекс возвращает закрытый excerpt</td><td>Индексатор видит запись</td><td>Решение доступа до формирования контекста</td><td>Убрать текст из результата и вернуть безопасный код</td></tr><tr><td>URI открывается, но место не найдено</td><td>Ссылка существует</td><td>Версию и устойчивый заголовок или fragment</td><td>Остановить цитирование и исправить карточку</td></tr><tr><td>Документ не менялся год</td><td>Представление долго не изменялось по данным origin</td><td>Отменённые правила, владельца и семантический срок</td><td>Не считать <code>Last-Modified</code> доказательством актуальности</td></tr><tr><td>Ответ говорит «ничего нет»</td><td>После фильтров не осталось разрешённого кандидата</td><td>Сохранённые reject-коды и границу раскрытия</td><td>Показать причину и адресата, не скрывая следующий шаг</td></tr></tbody></table>\n<h2>Негативный путь должен быть наблюдаемым</h2>\n<p>Stop полезен только тогда, когда его можно отличить от пустого индекса и от сбоя самого retrieval. Внутри системы сохраняйте request id, тип запроса, количество кандидатов, причины отклонения и версию policy. Отдельно измеряйте долю остановок по причинам. Не записывайте в диагностический журнал закрытый excerpt или чувствительные названия без разрешения.</p>\n<p>Разделяйте три результата: <code>no-match</code> — подходящих кандидатов не найдено; <code>stop</code> — кандидаты были, но ни один нельзя использовать; <code>system-error</code> — проверка не завершилась из-за сбоя. Пользовательский интерфейс может показывать один безопасный текст, но внутренний контракт должен сохранять различие. Иначе команда начнёт чинить ranking, когда сломана policy, или повторять запрос при отказе авторизации.</p>\n<p>Проверяйте и положительную ветку вручную. Откройте exact source, сравните формулировку утверждения с указанным anchor, убедитесь в версии и проверьте, что право относится к тому же субъекту и ресурсу. Высокий score, зелёный health-check или наличие ссылки не заменяют эту сверку.</p>\n<h2>Порядок внедрения для одной базы</h2>\n<ol><li>Разделите запросы на операционные, справочные и исторические. Для каждого типа назначьте владельца и правило свежести.</li><li>Опишите карточку источника: id, версия, URI, anchor, access class, владелец и раздельные даты публикации, индексации и истечения.</li><li>Определите порядок: сначала policy доступа, затем свежесть, затем наличие точной цитаты. Не передавайте excerpt до завершения этих проверок.</li><li>Назначьте стабильные stop-коды и безопасный текст для пользователя. Закрытый фрагмент не должен попадать ни в ответ, ни в обычный журнал.</li><li>Соберите фиксированный набор тестов: свежий разрешённый кандидат, просроченный кандидат с высоким score, запрещённый кандидат, ссылка без anchor и пустой индекс.</li><li>Запустите отрицательные тесты на каждом релизе индексатора. Проверяйте не только статус, но и отсутствие полей <code>excerpt</code> и <code>citation</code> в stop-ответе.</li><li>Для разрешённого результата сохраните citation с версией и anchor. Человек должен суметь открыть источник и восстановить границу утверждения.</li><li>Наблюдайте причины остановок и регулярно возвращайте карточки с истёкшим сроком владельцам корпуса. Ranking настраивайте только после исправления контракта данных.</li></ol>\n<h2>Что этот подход не доказывает</h2>\n<p>Проверка доступа, срока и anchor не делает документ истинным. Она не оценивает полноту корпуса, качество формулировки, причинность рекомендации или пригодность бизнес-решения. Утверждение может быть точно процитировано и всё равно оказаться неверным из-за ошибки самого источника. Поэтому для рискованных операций нужен отдельный review владельца процесса.</p>\n<p>Score нельзя сравнивать между разными индексами и моделями без отдельной калибровки. Значение <code>0.99</code> в учебном примере не является универсальным порогом. Текстовый поиск, embeddings и reranking имеют разные свойства; выбор алгоритма остаётся инженерным экспериментом на вашем корпусе и eval-наборе.</p>\n<p>Локальное поле <code>expiresAt</code> не является стандартным HTTP-заголовком и не появляется автоматически из <code>Last-Modified</code>. Срок должен поддерживаться процессом владельца. NIST SP 800-207 не заменяет вашу матрицу ролей, а RFC 9110 не определяет жизненный цикл wiki-документа. Эти границы нужно оставить видимыми, иначе учебный контракт начнут выдавать за готовую платформу.</p>\n<h2>Критерий готовности</h2>\n<p>Маршрут готов к использованию, если независимый проверяющий может восстановить запрос, policy и выбранную версию источника. Для свежей разрешённой записи ответ содержит точный URI и anchor, а утверждение совпадает с открытым фрагментом. Для просроченной, запрещённой или нецитируемой записи результатом становится <code>stop</code> без раскрытия текста и с понятным следующим действием.</p>\n<p>Начинайте с одного типа операционных вопросов и пяти негативных fixtures из списка выше. Если система не проходит этот малый набор, добавление ещё одного reranker только увеличит число убедительных, но непроверяемых ответов. Правильный stop — не провал поиска, а честная граница его доказательной силы.</p>\n<h2>Проверяемые источники</h2><ul><li><a href='https://www.rfc-editor.org/rfc/rfc9110.html#section-8.8.2' target='_blank' rel='noopener noreferrer'>IETF RFC 9110: HTTP Semantics, раздел 8.8.2</a> — определяет смысл <code>Last-Modified</code> и описывает его применение как валидатора представления. RFC не задаёт срок действия бизнес-документа.</li><li><a href='https://csrc.nist.gov/pubs/sp/800/207/final' target='_blank' rel='noopener noreferrer'>NIST SP 800-207: Zero Trust Architecture</a> — описывает отсутствие неявного доверия по сетевому расположению и разделяет authentication и authorization до доступа к ресурсу. Документ не является готовой политикой прав для вашей базы.</li><li><a href='https://docs.opensearch.org/latest/im-plugin/similarity/' target='_blank' rel='noopener noreferrer'>OpenSearch Documentation: Similarity</a> — показывает, что similarity-модели вычисляют score для совпадающих документов и имеют разные алгоритмы и параметры. Это не проверка фактической истинности или доступа.</li></ul>"
}