У команды появляется аккуратный retrieval: запрос возвращает top-k документов, у каждого есть score, а первый фрагмент почти дословно отвечает на вопрос. Через неделю выясняется, что лучший candidate описывал прежний contract, а актуальная заметка набрала меньше. Ошибка обычно обнаруживается уже после изменения: validation проходит не там, owner видит другую версию документа, а в answer нет anchor, по которому можно быстро сверить формулировку. Цена ошибки — не только неверный совет; она включает повторное расследование и потерю доверия к базе.
Причина не в том, что vector search «плохой». Similarity search решает узкую задачу: упорядочить candidates в выбранном пространстве. Права, актуальность, цитируемость и истинность утверждения — другие свойства и другие owners. Когда их склеивают с score, система получает один красивый number, которым нельзя объяснить ни reject, ни allow. Я предпочитаю сделать это разложение явным: index хранит metadata, retrieval даёт candidates, а decision допускает к ответу только пересечение access, freshness и exact citation.
Что именно сообщает score
В зафиксированном README OpenSearch k-NN сказано, что инструмент делает nearest-neighbor similarity search и допускает filters для уточнения similarity search. Это достаточная отправная точка: score связан с тем, насколько candidate похож на query в выбранной модели. В нём нет поля «утверждение истинно». В нём нет информации о том, кто может читать fragment, когда owner прекратил действие документа и открывал ли человек source по указанному anchor.
Отсюда следует практическое правило: score ранжирует очередь проверки, а не разрешает публикацию claim. Это собственный инженерный вывод, не цитата из спецификации. Его легко проверить на фиксированном corpus: expired record может иметь 0.96, closed record — 0.98, а свежая разрешённая запись — 0.91. Если filter использует только порог score ≥ 0.9, он выберет как раз два записи, которые не должны стать ответом.
| Сигнал | На какой вопрос отвечает | Что делает с candidate | Чего нельзя выводить |
|---|---|---|---|
| vectorScore | насколько record похож на query в fixed ranking | ставит в очередь чтения | что claim верен или source актуален |
| accessLabels | допускает ли declared label этот source class | отсеивает закрытый fragment | что requester прошёл реальную authentication |
| publishedAt / expiresAt | входит ли record в declared freshness window | отсеивает future и expired record | что в мире нет другой редакции |
| citationUri#anchor | можно ли показать точное место в source | делает candidate цитируемым | что человек согласился с трактовкой |
| human verification | совпадает ли claim с открытым fragment и его boundary | разрешает сформулировать answer | что работа заменяет owner policy |
Index должен не терять provenance при chunking
Chunking часто обрезает именно тот контекст, который нужен для проверки. Вектор хранит фрагмент, а version, owner и chapter остаются в исходном документе. После retrieval появляется удобный snippet без ответа на вопрос «из какой редакции он взят?». Поэтому metadata наследуются каждым index record либо доступно связываются по stable source id. Нельзя ожидать, что генератор восстановит provenance из текста: одинаковая фраза может встречаться в migration guide, old RFC и закрытом exception.
Минимальная связь проста. Source record задаёт URI, version, publishedAt, owner и policy. Chunk record хранит sourceRecordId, anchor, indexedAt, expiry и access labels, применимые к fragment. Retrieval output возвращает оба слоя. Если какой-то слой отсутствует, candidate остаётся candidate. Он может подсказать, где искать, но не может попасть в answer как exact citation. Это важнее дополнительного reranker, потому что без provenance даже идеально ранжированный fragment остаётся непроверяемым.
| Слой | Хранит | Проверка перед answer | Типичная ошибка |
|---|---|---|---|
| source record | title, sourceVersion, owner, publishedAt | связь с конкретной редакцией | разрешить chunk без source id |
| chunk record | anchor, excerpt, indexedAt, expiresAt, access labels | fragment addressable и допустим | сохранить только text и embedding |
| retrieval event | query, retrievedAt, candidates, reject reasons | можно повторить decision на том же срезе | перезаписать output готовым answer |
| human review | claim, citation, conclusion, limitation | source действительно поддерживает формулировку | считать top-1 автоматическим подтверждением |
Последовательность допуска должна быть детерминированной
- Зафиксировать query и retrievalAt. Без момента проверки невозможно понять, почему expiry приняла или отклонила record.
- Получить candidates. Сохранить recordId, score и metadata, а не только text snippet.
- Проверить access. Неподходящий label не становится приемлемым из-за сильного semantic match.
- Проверить freshness. Сравнить publishedAt и expiresAt с retrievalAt по явно записанной policy.
- Проверить citation. URI и anchor должны вести к конкретной редакции и фрагменту, а не к title source.
- Открыть source человеку. Сопоставить claim с fragment, назвать limitation и только затем ответить.
Такой порядок помогает спорить конструктивно. Если ответ не вышел, можно назвать точную причину: access-label-not-granted, expired-at-fixed-retrieval-time или missing-exact-citation-anchor. Это лучше статуса «relevance low», который смешивает разные проблемы. Owner access policy исправляет access; owner documentation исправляет expiry или anchor; owner index pipeline исправляет ingestion. Одна red status не заставляет всех искать ошибку в embedding model.
Небольшая модель показывает неприятные ветки
import {
createFixedSyntheticKnowledgeRetrievalInput,
inspectFixedSyntheticKnowledgeRetrieval,
prepareSyntheticKnowledgeHumanReview,
} from './upgrade-2025-03.mjs';
const report = inspectFixedSyntheticKnowledgeRetrieval(
createFixedSyntheticKnowledgeRetrievalInput('fixed-fresh-allowed-v1'),
);
const review = prepareSyntheticKnowledgeHumanReview(report);
console.log({
accepted: report.accepted,
allowedCitation: report.citations[0]?.citation,
rejected: report.retrieval.candidateOutput
.filter((candidate) => !candidate.admissible)
.map((candidate) => [candidate.recordId, candidate.reasons]),
needsHumanVerification: review.status,
});
// All values are fixed synthetic in-memory records.
// No real source, identity, clock, file, network, Git, CI, telemetry or production system is read.
В первом fixed case output содержит четыре records. Сортировка ставит closed exception с 0.99 первым, expired guideline с 0.97 вторым, а fresh guideline с 0.91 ниже. После access, freshness и anchor checks остаётся один citation. Fixture не говорит, что этот fragment верен для чьего-то реального adapter. Она только доказывает контракт кода: reject reasons не исчезли, closed и expired snippets не попали в citations, а accepted report требует human verification.
Во втором и третьем fixed cases хорошего candidate нет вовсе. Один corpus состоит только из expired record, другой — только из inaccessible record. Function возвращает stop result вместо пустого ответа, дополненного догадкой. Это важная разница для UX и audit: если assertion нельзя доказать, система должна сообщить, какого evidence не хватает, а не заставить человека отличать осторожный тон от уверенной галлюцинации.
Freshness — это policy, а не одно HTTP поле
RFC 9110 называет Last-Modified timestamp, в который origin server считает selected representation изменённым. Там же сказано, что способ его определения — implementation detail. Для retrieval это означает две вещи. Во-первых, timestamp полезно хранить как evidence от HTTP source. Во-вторых, нельзя автоматически подменить им правила инженерной актуальности. Документ может быть не изменён, но потерять применимость после migration; наоборот, versioned specification может оставаться пригодной для historical question после более новой публикации.
Политика должна быть обозримой: например, у записи есть publishedAt, indexedAt, expiresAt и explicit reason срока. Какое поле воздействует на answer, фиксируется до query. Для historical question expiry может быть осознанно шире, но тогда answer должен назвать версию и дату, а не выдавать старое правило за текущую конфигурацию. Для operational question record с expiredAt в прошлом отвергается до генерации. Модель не угадывает это из semantic similarity; policy появляется в metadata и в owner decision.
Права доступа — независимая ветка
NIST SP 800-207 говорит о resource-centric подходе: no implicit trust только из места в сети, authentication и authorization выполняются отдельно перед сессией к enterprise resource. Этот документ не проектирует поиск по wiki и не даёт формат labels. Но он помогает не допустить простую подмену: если retriever технически увидел closed fragment, это ещё не означает, что его можно отдать requester или положить в citation. Read path и answer path должны уважать одну access boundary.
В production label редко равен одному слову, а policy зависит от identity, resource, purpose и срока. В fixed model labels намеренно упрощены до строк, чтобы проверить ветвление без реальных identities. Это ограничение важно: PASS fixture не является проверкой RBAC, ABAC или SSO. Он лишь проверяет, что код не считает vectorScore полномочием и не выкидывает access reason из report. Реальная интеграция потребует approved policy owner и безопасного способа получить decision.
Ограничения и следующий проверяемый шаг
Здесь нет benchmark, live corpus, автоматической оценки ответов, наблюдения за пользователями или утверждения, что vector search всегда уступает keyword search. Внутри модуля есть только fixed synthetic records, timestamps, labels, scores и excerpts. Source URLs в разделе ниже подтверждают узкие protocol и security facts; они не подтверждают наши synthetic output. Все выводы о том, как совместить signals, явно помечены как инженерная политика статьи.
Следующий шаг: выберите один failure mode из своей базы — expired document, inaccessible document или fragment без anchor. Добавьте в index record только одно отсутствующее поле и напишите один rejection test. Ожидаемый эффект: result начинает объяснять, почему candidate нельзя цитировать. Затем закрепите owner и policy для этого поля. Только после этого имеет смысл тратить время на настройки top-k, reranking или дополнительные embeddings: их выигрыш не заменяет evidence boundary.
Историческая граница марта 2025
OpenSearch commit 150c589849a8ec3bc442d830b43a3eaf4e25fa0c датирован 12 июня 2024 и закрепляет использованный факт о similarity search. RFC 9110 опубликован в июне 2022, NIST SP 800-207 — 11 августа 2020. Они доступны до марта 2025. В статье не сказано, что любой HTTP timestamp является всей freshness policy, что NIST разрешает конкретную схему labels или что OpenSearch score даёт проверку фактов; это были бы более сильные и ложные атрибуции.
Проверяемые источники
- OpenSearch k-NN README, tag 2.15.0.0, immutable commit 150c589 (12 June 2024) — Официальный README описывает k-NN как nearest-neighbor similarity search по документам и измерениям, а также возможность уточнять similarity search фильтрами. Граница: Источник описывает поиск похожих кандидатов и фильтры. Он не говорит, что score подтверждает истинность утверждения, актуальность документа, право пользователя читать фрагмент или полноту корпуса.
- RFC 9110: HTTP Semantics, section 8.8.2 Last-Modified (June 2022) — Last-Modified сообщает время, в которое origin server считает выбранное представление изменённым; способ определения значения остаётся implementation detail. Граница: Это метаданные представления HTTP, а не доказательство семантической правильности текста, владельца документа, прав читателя или отсутствия более нового источника.
- NIST SP 800-207: Zero Trust Architecture (August 2020) — NIST описывает отсутствие неявного доверия по сетевому положению и отдельные authentication/authorization functions до установления сессии к enterprise resource. Граница: Публикация не задаёт схему vector index, формат цитаты или policy свежести инженерной документации. Она поддерживает только принцип явного контроля доступа к ресурсу.