ADR 0198 — О семантическом дубликате истории и правила предупреждает embedding с порогом, а расход попадает в metering
Статус
Accepted. 2026-10-01 (AT-171, история HS-047, эпик EP-026, раунд 36;
решение владельца от 01.10: «embedding с порогом, только предупреждает» — сравнивает при
испускании с существующими историями и правилами проекта и ПРЕДУПРЕЖДАЕТ выше
порога, никогда не блокирует; расход embedding этой проверки попадает в metering
отдельной строкой). Открывает ОДНО исключение из отсечения metering
ADR 0075, не редактируя его:
отсечение продолжает действовать для индексации и поиска RAG
(ADR 0080); только эта проверка начинает
записывать token_usage. Закрывает половину, которую RN-080
и RN-081 объявляли недостижимой,
не меняя ни одну из них: ТОЧНЫЙ дубликат по-прежнему отклоняется.
Контекст
Измерено в коде dev 2026-10-01, до изменения:
- Где происходит испускание. История:
create_storyу PO (apps/engine/lib/engine/harness/tools/create_story.ex) вызываетPOST /internal/sessions/:id/stories, а создаётCreateStoryUseCaseв api — одинаковый заголовок отклоняется, содержащееся обоснование предупреждает (RN-081). Бизнес-правило:emit_artifact(Criativo и ещё пять разговорных) записывает событиеartifact.business_ruleчерез api (append_event), после того какArtifactDedupeотклонил одинаковый заголовок (RN-080). Таблицы правил нет: правило — ЭТО событие. Ни один из двух серверов (po_server.ex,criativo_server.ex) не пришлось менять — испускание живёт в двух инструментах. - Как RAG вызывает
embed. Через одну точку,RagEmbeddingService, с ФИКСИРОВАННЫМИ provider и моделью (ollama/nomic-embed-text, 768 измерений — RN-222/233): единственный provider, объявляющийcapabilities.embeddings(RN-191). Сбой становитсяavailable: falseс причиной, никогда исключением. Сервис отбрасывалinputTokens, который возвращает контракт, — отсечение 0075. - Есть ли сохранённые векторы. Нет.
chunksиндексируетchat.message,agent.response,docs/и ADR; ни одногоartifact.*, ни одной истории. Проверке приходится векторизовать на лету. - Почему отсечение 0075 не достигает этой проверки. Его причина была
структурной:
token_usage.session_id—NOT NULL, а индексация репозитория не происходит в сессии. Испускание истории или правила происходит ВСЕГДА внутри сессии агента —session_idсуществует.
Решение
- Что сравнивается. Новый ЗАГОЛОВОК против заголовков существующих элементов ТОГО ЖЕ
типа в ПРОЕКТЕ (история с историей, правило с правилом), по косинусу между
векторами модели RAG, вызываемой через ТОТ ЖЕ
RagEmbeddingService, — никогда второй путь кembed. Заголовок, а не заголовок+описание: пара из находки R — это заголовки, а описание меняет длину и тон между ходами модели (стоимость и шум без случая, который бы этого требовал). Точный дубликат (одинаковый нормализованный заголовок) — не предмет этого решения: он отклоняется раньше, по RN-080/081, и поэтому тоже исключается из сравнения. - Только ПРЕДУПРЕЖДАЕТ. Проверка выполняется ПОСЛЕ записи: история и правило
существуют с предупреждением или без. Предупреждение возвращается агенту как часть результата
инструмента (
create_story,emit_artifact) и остаётся в log какbacklog.semantic_duplicate_warned, называя похожий элемент и число. - Порог — 0,80, НЕОТКАЛИБРОВАННАЯ ОТПРАВНАЯ ТОЧКА, как четыре числа гибридного поиска 0080. «Начиная с»: равное предупреждает. См. «Что не было измерено». Ошибка в меньшую сторону стоит лишнего предупреждения, которое ничего не блокирует; поэтому предварительное число уместно здесь и не было бы уместно в отказе.
- Без embedding provider проверка ПРОПУСКАЕТСЯ и ОБЪЯВЛЯЕТСЯ. Отсутствие capability,
демон недоступен, модель не скачана или потолок времени — всё
становится
status: skippedс причиной, описанной какbacklog.semantic_duplicate_check_skippedи во фразе агенту. Никогда не проваливает испускание, никогда не молчит: без этого «нет предупреждения» читалось бы как «не дубликат». - Расход попадает в metering ОТДЕЛЬНОЙ СТРОКОЙ. Через единственный путь,
RecordLlmUsageUseCase, с акторомsystem/duplicata-semantica(вне сумм по агентам, RN-038, и расходов области, ADR 0110), provider/модель RAG,output_tokens = 0,input_tokensиestimated— из сказанного provider, цена каталога, когда модель в нём есть (иначе 0, с нулевымmodel_id).RagEmbeddingServiceначинает ВОЗВРАЩАТЬ суммарное использование (uso); индексация и поиск его игнорируют, и именно это сохраняет отсечение 0075 для них. - Потолки. Не более 100 САМЫХ СВЕЖИХ существующих
(
TETO_DE_COMPARACOES_DE_DUPLICATA), одним пакетом; когда срезает, результат говорит, сколько из скольких (RN-180). И 10 с по часам на всю проверку (TETO_DE_TEMPO_DA_CHECAGEM_MS), ниже 15 сReqengine, который её вызывает: таймаут Ollama в установке достигает 300 с, а проверка стоит на пути инструмента агента. - Без migration. Ничего не сохраняется: ни вектор, ни новый столбец. Отдельная
строка в
token_usageотличается актором, который уже существует.
Последствия
Стоимость испускания начинает расти вместе с проектом. Каждое испускание векторизует
min(N, 100) + 1 заголовков, N = существующие того же типа. Заголовок истории или
правила — от 5 до 15 слов; с токенизатором nomic-embed-text это
ОЦЕНИВАЕТСЯ в 10–30 токенов на заголовок, то есть до ~3 000 входных токенов на
испускание на потолке — с локальным Ollama цена каталога 0 и стоимость 0 micros;
растёт ЗАДЕРЖКА, здесь не измеренная. Реальное число даёт metering, ради
этого строка и существует. Хранение вектора каждого элемента (столбец в
stories, таблица для правил-событий) сократило бы это до одного вызова на испускание, и
это migration — остаётся до тех пор, пока этого не потребует измеренная стоимость.
Пара из находки R перестаёт оставаться незамеченной — если порог верен. И это «если» — пробел этого решения (ниже).
У фразы предупреждения ОДИН источник, fraseParaOAgente в api; engine лишь
передаёт её. Одна и та же проверка служит обоим типам.
Что по-прежнему проходит. Дубликат, чей смысл в ОПИСАНИИ, а не в
заголовке («Saudação com nome» × «Quem chama pode se identificar», пример из
RN-080), не сравнивается. Предупреждение, потерянное из-за таймаута, объявляется, но проверка
не повторяется. А budget с policy = 'block' (RN-019)
не проверяется ДО этого вызова: строка прибавляется к расходам и может пересечь
пороги, но проверка не отклоняется из-за превышенного бюджета — она ничего
не блокирует, а с локальным Ollama записанная стоимость — 0. Если появится платный
embedding provider, этот выбор вернётся на стол.
Чего этот ADR НЕ меняет: отказ при точном дубликате (RN-080/081), отсечение metering индексации и поиска (ADR 0075), фиксированные модель и provider RAG (ADR 0080) и ни одного разговорного сервера.
Что не было измерено
Решение владельца требовало откалибровать порог на паре из находки R и на парах, которые
НЕ являются дубликатами, доказав тестом с записанными векторами. Окружение, в котором
родилось это изменение, не достигало registry Ollama
(registry.ollama.ai: 403 в proxy) и Hugging Face (403), и в нём не было
демона — измерено 2026-10-01. Вектор, написанный вручную, доказал бы руку, а не
модель. Поэтому существует следующее:
apps/api/test/fixtures/duplicata-semantica/pares.json— калибровочные пары: пара из находки R и две перефразировки, которые ДОЛЖНЫ предупреждать; пять пар соседней темы, которые НЕ должны.apps/api/scripts/gravar-vetores-de-duplicata.ts— записываетvetores.jsonпротив реального Ollama, с моделью, которую, по словам демона, он использовал, и печатает косинус каждой пары относительно действующего порога.apps/api/test/domain/backlog/limiar-de-duplicata.calibracao.spec.ts— запускается сама, когда существуетvetores.json: каждая параduplicataпредупреждает, каждаяdistintaпроходит. Без файла она ПРОПУСКАЕТСЯ с названным предупреждением (линейка smoke embedding Ollama), никогда не проходит молча.
Пока кто-нибудь не запишет векторы, 0,80 — это общее знание о моделях embedding фраз, а не измерение. Если запись покажет, что ни одно число не разделяет две стороны, ответ — пересмотреть пары или сравниваемый текст, никогда не ослаблять тест.