4.0 KiB
ADR 0005: Extract Embedding into a Dedicated Package, Fully Decoupled from Chat Clients
- Status: Accepted
- Date: 2026-06-24
Context
Embedding (vectorization) was coupled into the text/chat LLM clients: embed()
lived on OpenAIClient, HTTPLLMClient (which carried its own inline
_EmbeddingBackend classes), AnthropicClient (raising), and LazyLLMClient.
The service routed vectorization through an LLM profile (_get_step_embedding_client
returned a chat client), and some retrieval paths reused a single client for both
chat and embed().
This had several problems:
- embedding providers were limited to whatever the chat client happened to support, so embedding-only providers (Jina, Voyage) had no clean home;
- embedding payload/parse logic was duplicated (an orphaned
memu.embeddingmodule plus the inline backends insideHTTPLLMClient); - the
visioncapability already had a clean sibling package (memu.vlm, see the LLM/VLM gateway refactor), so embedding was the odd one out; - mixing chat and embedding on one client blurred capability boundaries and made per-capability provider/transport selection impossible.
ADR 0004 deliberately deferred this as an isolated, independently revertable step ("Decouple embedding").
Decision
Make embedding a first-class capability in its own package, memu.embedding,
structured identically to memu.llm and memu.vlm, and remove embedding from the
chat clients entirely.
Package layout (memu.embedding):
backends/: per-provider request/response shapes —openai,jina,voyage,doubao(incl. multimodal),openrouter; the HTTP client falls back to an OpenAI-compatible backend for any other provider.http_client/openai_sdk: transport clients;embed()returns(vectors, raw_response)so usage metadata is preserved.gateway.build_embedding_client(cfg): dispatch onclient_backend(sdk/httpx/lazyllm_backend;anthropicraises — Claude has no embeddings API).defaults: per-provider default models and embedding-only endpoints.
Configuration and wiring:
- New
EmbeddingConfig/EmbeddingProfilesConfig, sibling toLLMConfig/VLMConfig.MemoryServiceaccepts an optionalembedding_profiles; when omitted, profiles are derived from the LLM profiles viaembedding_config_from_llm, so existing configs keep working. MemoryServicebuilds/caches embedding clients per profile and wraps them with the sameLLMClientWrapper, so interceptors and usage tracking are identical to chat/vision.- All vectorization call sites (query embedding, category/item embedding, RAG
ranking) go through
_get_step_embedding_client/_get_embedding_client.
Decoupling the chat clients:
OpenAIClient,HTTPLLMClient, andAnthropicClientno longer exposeembed();HTTPLLMClientno longer carries inline embedding backends.LazyLLMClientkeepsembed()because it is a multi-capability framework adapter and serves as the embedding transport forlazyllm_backend.LLMConfig.embed_model/embed_batch_sizeare retained only as a backward-compat bridge consumed byembedding_config_from_llm; they no longer affect chat clients. New configs should setembedding_profilesdirectly.
Consequences
Positive:
- clear capability boundaries:
memu.llm(text),memu.vlm(vision),memu.embedding(vectors) are isomorphic and independent; - embedding-only providers (Jina, Voyage) are first-class; new providers are a single backend module plus a registry entry;
- no duplicated embedding logic; one source of truth;
- per-capability provider/transport selection (e.g. chat via OpenAI, embed via Voyage) with zero-config derivation as the default.
Negative:
LLMConfigstill carriesembed_model/embed_batch_sizeas a bridge, so the decoupling is not yet "pure" at the config layer (kept for backward compatibility);- an explicit
embedding_profilesis required to use an embedding provider that differs from the chat provider; - one more package/gateway to maintain in parity with
llm/vlm.