ADR 0075 — Embeddings в контракте LLMProvider
Контекст
Грядущему 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 + estimated | embedding тратит, а различие «сказал ноль» × «ничего не сказал» — то же, что в 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 однажды молча исчез из всего продукта.