Перейти к основному содержимому

ADR 0075 — Embeddings в контракте LLMProvider

  • Статус: принят
  • Дата: 2026-08-14
  • Расширяет: ADR 0041, ADR 0043

Контекст​

Грядущему Chat RAG нужно превращать текст в вектор, а продукт не умеет этого делать. Поиск буквальный: слово «embedding» встречалось в одном комментарии, в прозе bitdeer-provider.ts, объяснявшей, что единственный пример аутентифицированного curl, найденный в документации Bitdeer, был для их API embeddings. Ни один provider не реализует операцию, ни один тип её не описывает, ни одна capability её не объявляет.

Три вещи делают это чем-то большим, чем «добавить метод».

1. Capability — новое измерение, а не вариация существующих. Сегодняшние capabilities — streaming, toolCalling, listModels и столбцы supports_* в models — говорят о том, как модель БЕСЕДУЕТ. Embedding — не более бедная беседа: это другая операция, с другим endpoint, другим телом и другим типом ответа.

2. Слой модели здесь — ИСКЛЮЧЕНИЕ, а не градиент. Tool calling допускает деградацию: модель, которая не запрашивает инструменты, всё равно отвечает текстом, и поэтому RN-040 лишь отклоняет binding агента, а не запрещает модель. Embedding этого не допускает: nomic-embed-text не отвечает на вопрос, а llama3.2 не возвращает вектор. Это два непересекающихся множества моделей, и вопрос «является ли эта модель моделью embedding?» не помещался ни в один существующий столбец.

3. Объявление по чтению документации уже обошлось дорого. ADR 0043 фиксирует два отката ВЖИВУЮ — DeepInfra и Vultr — capabilities, которые документация обещала, а выполнение опровергло. У каждого облачного provider есть страница, говорящая, что он обслуживает /embeddings; ни одна из них не является доказательством.

Решение​

Операция: embed, опциональная, с ошибкой, которая БРОСАЕТСЯ​

embed?(
inputs: readonly string[],
options: EmbeddingOptions,
): Promise<EmbeddingResult>;

Опциональна по тому же двустороннему контракту, который listModels соблюдает с Фазы 9c: кто объявляет capability, реализует метод, а кто не объявляет, его не предоставляет. Потребитель деградирует, глядя на capability, а не обнаруживая это при сбое.

Пакет, а не единица. Индекс получает N фрагментов сразу, и каждый provider принимает input как массив. Порядок — единственная связь между входом и вектором, и из этого следует самая важная гарантия контракта: по вектору на каждый вход или ошибка — никогда более короткий список. Частично отклонённый ответ необнаружим потом, потому что i-й вектор начинает принадлежать другой фразе, и индекс молча становится неверным.

Возврат говорит четыре вещи, и каждая существует по причине:

полепочему
vectorsрезультат, в порядке входов
dimensionsсверяется с тем, что ПРИШЛО, а не копируется из каталога — у векторного индекса фиксированная размерность, и запись другого размера проваливается далеко от причины
modelто, что, по СЛОВАМ provider, он использовал, а это не всегда запрошенное (alias разрешается в датированную версию) — именно оно идёт в metering, по той же причине, что и замороженная цена (RN-044)
inputTokens + estimatedembedding тратит, а различие «сказал ноль» × «ничего не сказал» — то же, что в RN-041

Ошибка БРОСАЕТСЯ, нормализованная по code, а не становится chunk, как в chat. Причина chunk — сохранить расход уже случившегося хода; здесь нечего сохранять — либо provider вернул векторы и взял плату, либо не вернул и не взял. Это тот же выбор, что сделал listModels, с тем же аргументом, и таксономия ТА ЖЕ (auth, rate_limit, model_not_found, context_length, timeout, connection, upstream): ни одного нового кода.

Capability в два слоя​

Provider — LLMProviderCapabilities.embeddings, обязательная (не опциональная), по тому же аргументу, по которому code стал обязательным в ChatErrorChunk: при опциональном поле новый provider забывает объявить, и никто не замечает.

Модель — ModeloDoCatalogo.supportsEmbeddings и embeddingDimensions, обе опциональные, потому что отсутствие — это «provider не сказал» и никогда не выдуманный false (ADR 0041). Ollama — единственный из девяти, кто публикует это по модели: capabilities: ["embedding"] в /api/tags, с details.embedding_length, дающим размерность.

Охранник assertCanEmbed проверяет обе в порядке, в котором они лучше проваливаются: сначала provider, потому что смена модели не решит provider, который не делает embedding. Он также отклоняет модель без объявления, с другим сообщением — действие читающего — синхронизировать каталог, а не менять модель. Выводить capability из ИМЕНИ модели было бы догадкой, переодетой в данные, ровно тем, что ADR 0041 запрещает.

Доказательство: один true, восемь false​

Набор контрактных тестов получил пять случаев, запускаемых против каждого provider, объявляющего capability: по вектору на вход в правильном порядке, размерность и использованная модель, неполный пакет, становящийся ошибкой, ошибка provider, нормализованная по code, и пустой список входов, отклоняемый до выхода в сеть.

Только ollama объявляет embeddings: true, и доказательство — выполнение: POST /api/embed против демона 0.32.1 с nomic-embed-text, два входа → два вектора по 768 и prompt_eval_count: 10. То же выполнение дало находку, ставшую тестом: модель чата отвечает 501 («This server does not support embeddings») — слой модели проваливается в самом позднем возможном месте, что и есть причина существования assertCanEmbed.

Остальные восемь объявляют false, и по двум разным причинам, которые проза каждого фиксирует. Семь — это отсутствие доказательства: их ключа нет в окружении, а единственный платный smoke, который когда-либо запускался (OpenRouter, Фаза 13a), был для ЧАТА — в хабе embedding маршрутизируется к другим провайдерам, нежели чат, и доказательство одного endpoint — не доказательство другого. Восьмой — отсутствие операции: у Anthropic нет собственного endpoint embedding, и её документация советует использовать третью сторону, то есть другого provider с другим ключом и другим диалектом.

Диалект /embeddings OpenAI-совместимой базы, однако, доказан — набор контрактных тестов запускается второй раз над базой, настроенной с включённой capability. Именно это делает дешёвым переключение provider в true в день, когда появится ключ: меняется одна строка литерала, а разбор уже проверен.

Последствия​

Расход на embedding пока не измеряется, и это объявленное отсечение, а не забывчивость. Возврат несёт inputTokens/estimated именно для того, чтобы питать RecordLlmUsageUseCase, который остаётся единственным путём metering, — и calculateCostMicros(input, 0, …) уже подходит, потому что у embedding нет выхода в токенах. Недостающее структурно и не помещалось сюда: у token_usage session_id NOT NULL с FK на sessions, а индексация репозитория не происходит внутри сессии. Импровизация синтетической сессии, чтобы было куда записать, вызвала бы исправление сразу после; решение принадлежит волне, которая реализует потребителя.

У слоя модели пока нет столбца. Сегодня он живёт в строке каталога (ModeloDoCatalogo.supportsEmbeddings), а models не получил supports_embeddings, потому что слот migration этой волны принадлежит другому направлению — две конкурирующие migrations сталкиваются в _journal.json и в snapshots drizzle, что является объявленным ограничителем программы. Честное следствие: sync каталога читает capability и пока не имеет, где её сохранить, поэтому потребителю придётся спрашивать provider или нести столбец вместе. assertCanEmbed намеренно получает узкий Pick, чтобы служить обоим источникам, не меняясь, когда столбец появится.

Девять providers начали объявлять на одно поле больше. Цена реальна и была уплачена разом: обязательная capability — это то, что не даёт следующему provider родиться без ответа. TracedLLMProvider условно пробрасывает embed, как делает с listModels, — и получил собственный тест, потому что именно там listModels однажды молча исчез из всего продукта.