{ "index": 101, "slug": "editorial-2025-03-mechanism-knowledge-retrieval", "title": "Почему высокий vector score не доказывает ответ", "excerpt": "Vector search ранжирует похожие фрагменты, но не подтверждает их смысл, свежесть и право показа. Разбираем decision trace, отрицательный путь и проверку источника до ответа.", "contentHtml": "
Проблема. Поиск по инженерной базе возвращает фрагмент с vector score 0.98, и система сразу строит из него ответ. Через несколько дней выясняется, что фрагмент относится к старой редакции API, закрыт для автора запроса или содержит исключение, а не общее правило.
Симптом. В интерфейсе есть title, excerpt и число похожести, но нет версии источника, результата проверки доступа, срока действия и точного места цитаты. Инженер меняет код по устаревшей инструкции. Цена ошибки — повторное расследование и потеря доверия к базе знаний: после нескольких ложных попаданий команда начинает игнорировать и полезные результаты.
\nВывод. Vector score решает задачу ranking: ставит candidates в порядок по выбранной моделью поиска мере близости. Он не является вероятностью истины и не доказывает, что claim (утверждение в ответе) поддержан источником. Перед генерацией нужно получить отдельное решение по доступу, свежести и происхождению. Если пересечение условий пусто, система должна остановиться, а не заполнять пробел правдоподобным текстом.
\nVector query сравнивает вектор запроса с векторами документов и возвращает ближайшие результаты по настроенной similarity metric. Такая сортировка сокращает объём чтения: вместо тысячи документов инженер получает несколько candidates. Это полезный этап поиска, но не этап доказательства.
\nСамо число зависит от реализации. При cosine similarity, dot product и L2 distance используются разные шкалы и преобразования. Даже один движок может считать итоговый score по-разному для разных типов полей и запросов. Поэтому порог score >= 0.90 нельзя переносить между моделями, индексами и метриками без размеченного набора проверок. Значения 0.98, 0.91 и другие числа ниже — учебные, не измерение качества конкретного сервиса.
Score также не знает контекст requester. Он не видит, отозвал ли владелец документ, и не умеет определить, что цитата отвечает только на частный случай. Эти свойства должны приходить из источника и policy, а не угадываться из текста фрагмента.
\n| Сигнал | Какой вопрос решает | Что можно сделать | Чего не доказывает |
|---|---|---|---|
vectorScore | Насколько candidate близок к query по выбранной метрике? | Отсортировать очередь чтения. | Истинность claim, право доступа и актуальность. |
sourceVersion | Из какой редакции получен chunk? | Сопоставить результат с известным snapshot. | Что редакция подходит к данному вопросу. |
accessDecision | Может ли этот requester увидеть ресурс? | Исключить закрытый candidate из answer path. | Что открытый текст подтверждает claim. |
expiresAt | Не вышел ли source за объявленный срок? | Отклонить устаревшую запись или направить к владельцу. | Что документ семантически полон и верен. |
citationAnchor | Можно ли открыть точное место в нужной версии? | Показать адресуемую цитату. | Что узкий фрагмент поддерживает более широкий вывод. |
При индексации документ обычно разбивают на chunks. Если сохранить только текст и embedding, после поиска останется удобный snippet без ответа на вопрос «из какой редакции он взят?». Одинаковая фраза может встретиться в текущем руководстве, старом RFC и закрытом исключении.
\nМинимальный контракт связывает каждую запись с исходным документом. У source record есть sourceId, sourceVersion, владелец, статус публикации и URI. У chunk есть тот же sourceId, текст, anchor, время индексации и access labels. У retrieval event есть зафиксированные query, requester scope, retrievalAt, список candidates и причины отказа. Пользовательскому интерфейсу не обязательно показывать всё, но компонент, принимающий решение, должен иметь эти поля.
Не стоит восстанавливать происхождение по заголовку или совпадению текста. При обновлении документа старый chunk должен быть выведен из индекса или помечен как недопустимый для текущего вопроса. Иначе высокий score может быть честным результатом поиска по неправильному snapshot.
\nРешение полезно представлять не флагом relevant: true, а набором проверяемых полей. Ниже — самостоятельный пример на Node.js без внешних пакетов. Он фиксирует момент поиска, применяет одну policy к четырём синтетическим candidates и печатает причины отказа. Команда не моделирует identity provider или настоящую БД; она воспроизводит именно порядок принятия решения.
node <<'NODE'\nconst retrievalAt = new Date('2025-03-18T12:00:00Z');\nconst requester = 'engineering';\nconst candidates = [\n { id: 'fresh-v3', score: 0.91, access: ['engineering'],\n publishedAt: '2025-03-01T00:00:00Z', expiresAt: '2025-04-01T00:00:00Z',\n sourceVersion: 'v3', citation: 'https://docs.example.test/runbook/v3#rollback' },\n { id: 'expired-v1', score: 0.96, access: ['engineering'],\n publishedAt: '2024-01-01T00:00:00Z', expiresAt: '2025-01-01T00:00:00Z',\n sourceVersion: 'v1', citation: 'https://docs.example.test/runbook/v1#rollback' },\n { id: 'closed-v2', score: 0.98, access: ['security'],\n publishedAt: '2025-02-01T00:00:00Z', expiresAt: '2025-04-01T00:00:00Z',\n sourceVersion: 'v2', citation: 'https://docs.example.test/runbook/v2#rollback' },\n { id: 'no-anchor', score: 0.89, access: ['engineering'],\n publishedAt: '2025-03-01T00:00:00Z', expiresAt: '2025-04-01T00:00:00Z',\n sourceVersion: 'v3', citation: null },\n];\n\nfunction decide(candidate) {\n const accessOk = candidate.access.includes(requester);\n const date = retrievalAt.getTime();\n const fresh = Date.parse(candidate.publishedAt) <= date &&\n (!candidate.expiresAt || date < Date.parse(candidate.expiresAt));\n const citable = Boolean(candidate.sourceVersion && candidate.citation);\n const reasons = [];\n if (!accessOk) reasons.push('access_denied');\n if (!fresh) reasons.push('expired_at_retrieval');\n if (!citable) reasons.push('missing_citation');\n return { id: candidate.id, score: candidate.score,\n ok: reasons.length === 0, reasons };\n}\n\nconsole.table(candidates.sort((a, b) => b.score - a.score).map(decide));\nNODE\nОжидаемый результат: closed-v2 окажется первым по score, но получит access_denied; expired-v1 — expired_at_retrieval; no-anchor — missing_citation. В answer path попадёт только fresh-v3. Если убрать его из массива, код не должен выбирать следующий candidate: приложение должно вернуть stop-статус с причиной no_admissible_evidence.
Доступ. Проверяйте requester scope до передачи фрагмента генератору. Даже если индекс технически вернул закрытый текст, это не разрешает показать его пользователю или пересказать его содержание. Проверка массива labels в примере — только замена policy для демонстрации. В реальном сервисе authorization должен опираться на подтверждённую identity и правила владельца ресурса.
\nСвежесть. Сравнивайте фиксированный retrievalAt с правилами домена: сроком действия, статусом публикации и версией контракта. HTTP Last-Modified сообщает время, когда origin считает выбранное представление изменённым. Он не говорит, что правило всё ещё подходит к эксплуатационному вопросу. Документ может не меняться, но стать неприменимым после миграции.
Происхождение и смысл. Открываем URI в той же редакции и проверяем anchor. Затем сопоставляем каждое предложение ответа с фрагментом. Доступная ссылка может вести на исключение, а не на общее правило; наличие anchor не превращает локальное условие в универсальный вывод.
\n| Симптом | Гипотеза | Проверка | Следующее действие |
|---|---|---|---|
| Top-1 описывает старый API-контракт. | Ranking не учитывает version или expiry policy. | Сравнить source version, опубликованный статус и retrievalAt. | Отклонить candidate и вернуть текущую редакцию либо stop. |
| Релевантный текст нельзя открыть. | Индекс и answer path используют разные access boundaries. | Сверить requester scope с policy владельца ресурса. | Не раскрывать фрагмент и записать access_denied. |
| Ссылка открывается, но правило не находится. | Сохранён URI без версии или стабильного anchor. | Открыть тот же snapshot и проверить точное место. | Оставить candidate вне citations до исправления ingestion. |
| Ответ шире приведённой цитаты. | Генератор обобщил условие или исключение. | Разметить claim-to-source mapping по предложениям. | Сузить ответ, добавить ограничение или передать вопрос человеку. |
| Все candidates отфильтрованы, но ответ всё равно есть. | Fallback подменяет отсутствие evidence вероятным текстом. | Запустить negative case с expired, закрытым и no-anchor набором. | Вернуть no_admissible_evidence, а не догадку. |
Удачный top-1 показывает лишь, что поиск нашёл похожий текст. Надёжность видна в обратной ситуации: все candidates просрочены, закрыты или не имеют точного locator. Пустой ответ можно объяснить и исправить — обновить документ, запросить доступ, добавить anchor или уточнить вопрос. Уверенный ответ без основания скрывает причину сбоя.
\nРазделяйте reason codes. access_denied направляет к владельцу policy, expired_at_retrieval — к владельцу документа, missing_citation — к ingestion или разметке источника, а relevance_low — к качеству retrieval. Такая диагностика не заставляет команду менять embedding model, когда проблема находится в правах или жизненном цикле документа.
retrievalAt. Без момента проверки нельзя воспроизвести решение по сроку.retrievalAt при неизменных corpus и policy.Эта схема не доказывает полноту поиска, не выбирает лучшую embedding model и не заменяет оценку retrieval на размеченных вопросах. Она также не утверждает, что keyword search всегда лучше vector search. Score помогает ранжировать candidates; качество покрытия корпуса, reranking и стоимость запроса нужно измерять отдельно.
\nУчебный код не реализует authentication, RBAC, ABAC, SSO, аудит, отзыв сессий, конкурентное обновление документов или защиту кэша. В нём намеренно используются домен example.test, четыре записи и упрощённый массив access labels. Перед применением в сервисе нужно описать policy, владельцев полей, формат дат и поведение при отсутствии версии или anchor.
Критерий готовности ограниченного запуска — полный decision trace для фиксированного запроса: candidates со score, access result, freshness result, source version, citation URI с anchor и результат проверки claim. В отрицательном наборе expired, closed и no-anchor записи должны получать разные причины и не попадать в citations.
\n