fix(embedding): configurable tokenizer for non-OpenAI embedding models#916
fix(embedding): configurable tokenizer for non-OpenAI embedding models#916Vansh-Sharma27 wants to merge 2 commits into
Conversation
The embedding client resolved every model's tokenizer through tiktoken, silently falling back to cl100k_base for models tiktoken doesn't know (e.g. baai/bge-m3). cl100k_base undercounts vs the model's real tokenizer on technical/mixed text (runtime-measured +44% for bge-m3), so prepare_chunks emits "within-limit" chunks the provider then rejects with HTTP 400. The reconciler retries the unchanged payload 20 times over ~3h, marks MessageEmbedding.sync_state='failed', and the message is permanently excluded from vector search (search.py filters embedding IS NOT NULL). Add EMBEDDING_MODEL_CONFIG__TOKENIZER: unset keeps tiktoken auto-detection (backwards compatible); tiktoken:<encoding>, hf:<repo>, or file:<path> select an explicit tokenizer. HF/file tokenizers use the optional honcho[tokenizers] extra. The HuggingFace adapter encodes without special tokens and reserves the special-token overhead ([CLS]/[SEP]) from the chunk budget so provider-side counts stay exactly within limit. Unknown models now log a warning pointing at the new setting. Invalid specs raise ValidationException (repo-standard). The singleton rebuild signature includes tokenizer so runtime config changes take effect. Runtime-verified end-to-end without a live provider: 24,360 chars of technical text with bge-m3 went from 1 chunk (8,355 real tokens > 8,192 -> provider 400 -> failed) to 2 chunks (8,192 / 1,803, both within limit). Out of scope (noted for follow-up): recovery/reindex of existing failed rows, scripts/generate_message_embeddings.py chunk-identity bug, ConclusionCreate o200k_base validator alignment, typed dimension-vs-token-limit exceptions, live-embedding CI matrix. Fixes plastic-labs#827
WalkthroughEmbedding configuration now accepts tokenizer specifications, the embedding client resolves and uses them for token-aware chunking, and application startup eagerly warms the client. Documentation, optional dependencies, validation tests, and startup wiring are included. ChangesEmbedding tokenizer configuration
Estimated code review effort: 3 (Moderate) | ~25 minutes Sequence Diagram(s)sequenceDiagram
participant Operator
participant ApplicationStartup
participant EmbeddingClient
participant TokenizerResolver
participant EmbeddingProvider
Operator->>ApplicationStartup: configure model and tokenizer
ApplicationStartup->>EmbeddingClient: warmup()
EmbeddingClient->>TokenizerResolver: resolve tokenizer specification
TokenizerResolver-->>EmbeddingClient: return tokenizer
EmbeddingClient->>EmbeddingClient: count tokens and split text
EmbeddingClient->>EmbeddingProvider: send tokenizer-sized chunks
Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/embedding_client.py (1)
191-213: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick winAvoid
hf:downloads on the async embedding path.Tokenizer.from_pretrained(...)can fetch from the Hugging Face Hub on first use, and_get_client()constructs_EmbeddingClientsynchronously under a class lock. That means the firstembed/batch_embedcall for a new tokenizer config can stall the event loop and block other callers behind the download. Consider pre-warming at startup, keepinghf:tokenizers in an offline cache, or moving the load off the request path.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/embedding_client.py` around lines 191 - 213, Move Hugging Face tokenizer loading out of the synchronous request path used by _get_client and _EmbeddingClient, or pre-warm/cache hf: tokenizers during startup so Tokenizer.from_pretrained is never invoked on the first embed or batch_embed call. Preserve the existing validation and file: loading behavior in _load_huggingface_tokenizer while ensuring concurrent callers are not blocked by a Hub download under the class lock.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@src/embedding_client.py`:
- Around line 191-213: Move Hugging Face tokenizer loading out of the
synchronous request path used by _get_client and _EmbeddingClient, or
pre-warm/cache hf: tokenizers during startup so Tokenizer.from_pretrained is
never invoked on the first embed or batch_embed call. Preserve the existing
validation and file: loading behavior in _load_huggingface_tokenizer while
ensuring concurrent callers are not blocked by a Hub download under the class
lock.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 5cb89f41-9417-4ffc-a8d1-e175e3207aeb
⛔ Files ignored due to path filters (1)
uv.lockis excluded by!**/*.lock
📒 Files selected for processing (8)
.env.templateconfig.toml.exampledocs/v3/contributing/changing-embeddings.mdxdocs/v3/contributing/configuration.mdxpyproject.tomlsrc/config.pysrc/embedding_client.pytests/llm/test_embedding_client.py
Addresses coderabbit review: Tokenizer.from_pretrained for the hf: spec fetches from the Hugging Face Hub and ran synchronously under the singleton lock on the first embed/batch_embed call, stalling the event loop and blocking concurrent callers behind the download. Add EmbeddingClient.warmup() and call it from the API lifespan and the deriver startup, right after validate_embedding_schema. This moves any tokenizer download off the request path and fails the process fast on a bad hf: spec instead of stalling the first request. tiktoken: and file: paths are unaffected (no network); unset tokenizer still auto-detects lazily but never hits the network.
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/main.py (1)
146-149: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low valueOffload blocking initialization to a worker thread.
The
warmup()method is synchronous and can perform blocking network I/O (such as downloading HuggingFace tokenizer files). Calling a synchronous I/O function directly inside anasync deffunction blocks the asyncio event loop. Even during application startup, it is considered best practice to offload blocking operations to prevent delaying or stalling background tasks (such as signal handlers or telemetry).
src/main.py#L146-L149: Consider wrapping this call withawait asyncio.to_thread(embedding_client.warmup). You may need toimport asyncioif it is not already imported.src/deriver/__main__.py#L74-L78: Consider wrapping this call withawait asyncio.to_thread(embedding_client.warmup).🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/main.py` around lines 146 - 149, Offload the synchronous embedding initialization from the asyncio event loop by awaiting asyncio.to_thread around embedding_client.warmup() in src/main.py lines 146-149 and src/deriver/__main__.py lines 74-78; add the asyncio import where needed and preserve the existing startup flow.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Nitpick comments:
In `@src/main.py`:
- Around line 146-149: Offload the synchronous embedding initialization from the
asyncio event loop by awaiting asyncio.to_thread around
embedding_client.warmup() in src/main.py lines 146-149 and
src/deriver/__main__.py lines 74-78; add the asyncio import where needed and
preserve the existing startup flow.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: ea21182e-d9f3-4d1e-890d-9c1685d7381a
📒 Files selected for processing (3)
src/deriver/__main__.pysrc/embedding_client.pysrc/main.py
🚧 Files skipped from review as they are similar to previous changes (1)
- src/embedding_client.py
|
Finn-loop review of bfa06a5 CI: not configured (stale branch protection) ReviewAdds 1. Must fix before merge
2. Should fix soonNone. 3. Safe to mergeCode is solid — comprehensive tests, clean backwards-compatible design, well-scoped to the reported issue. Blocked only by the stale branch-protection rule (repo config, not code). Labels to apply: |
Problem
When a non-OpenAI embedding model is configured via
EMBEDDING_MODEL_CONFIG__MODEL(made possible by #678), the embedding client still counts tokens withtiktokenand silently falls back tocl100k_basefor any model name tiktoken doesn't recognize (src/embedding_client.py:208-211). For models likeBAAI/bge-m3(XLM-RoBERTa tokenizer),cl100k_baseundercounts vs the provider's real tokenizer, soprepare_chunksemits "within-limit" chunks the provider then rejects with HTTP 400. The reconciler retries the unchanged payload 20 times (~3h,MAX_SYNC_ATTEMPTS=20×SYNC_BACKOFF=10min) and marksMessageEmbedding.sync_state='failed', permanently excluding the message from vector search (src/utils/search.py:166filtersembedding IS NOT NULL).Cause
The chunk-size decision is made locally with
tiktokenbefore the provider call, and the chosen chunk text is persisted. Retry re-embeds that stored text without re-chunking, so a deterministic size error can't self-heal. There was no way to tell Honcho which tokenizer the configured model actually uses.Runtime-measured undercount (cl100k_base vs
BAAI/bge-m3, via thetokenizerspackage):End-to-end repro without a live provider: 24,360 chars of technical text →
prepare_chunksreturns 1 chunk (cl100k est 5,801 < 8,192) → bge-m3 real count 8,355 > 8,192 → provider 400 → 20 retries →failed→ invisible to vector search.Solution
Add
EMBEDDING_MODEL_CONFIG__TOKENIZER(config +.env.template+config.toml.example+docs/v3/contributing/configuration.mdx+docs/v3/contributing/changing-embeddings.mdx):tiktoken:<encoding>: explicit tiktoken encodinghf:<repo-id>: HuggingFace tokenizer via the optionalhoncho[tokenizers]extrafile:<path>: local HF tokenizer JSONThe
TokenizerLikeprotocol (encode/decode) decouples the embedding pipeline fromtiktoken.Encoding. The_HuggingFaceTokenizeradapter encodes without special tokens and reserves the special-token overhead ([CLS]/[SEP]) from the chunk budget so provider-side counts stay exactly within limit. Unknown models now log a warning pointing at the new setting. Invalid specs raiseValidationException(repo-standard, persrc/exceptions.py). The singleton rebuild signature (_get_settings_signature) includestokenizerso runtime config changes rebuild the client.Runtime-verified the fix: same 24,360-char input with
tokenizer=hf:BAAI/bge-m3→ 2 chunks (8,192 / 1,803 real tokens, both ≤ 8,192), zero over-limit.Testing
uv run pytest tests/llm/test_embedding_client.py— 30 passed (16 pre-existing + 14 new covering: spec resolution, tiktoken/hf/file branches, unknown-encoding/invalid-prefix/empty-name ValidationExceptions, missingtokenizerspackage, unknown-model warning, HF special-token overhead, chunking follows configured tokenizer, singleton signature invalidation).uv run pytest tests/llm/— 252 passed (includestest_model_config.py, schema callers).uv run ruff check+uv run ruff format --check— clean.uv run basedpyright— 0 errors, 0 warnings.tests/deriver/,tests/integration/) need Postgres+pgvector, which isn't available in this environment; they fail identically on a pristine checkout (connection refused). Those tests mock the embedding client entirely (tests/conftest.py:484-536), so they don't exercise this diff. CI runs the full matrix with a real pgvector service.Risk
TOKENIZERpreserves tiktoken auto-detection; existing OpenAI deployments see no change.uv.lock: addstokenizers,huggingface-hub,hf-xet,fsspec;click8.3.3 → 8.4.2 is forced byhuggingface-hub>=1.x's click dependency (satisfies existingtyper/uvicornconstraints).max_embedding_tokens. For pathological configs where the limit is ≤ the overhead, the budget could go non-positive — such a config is already inoperable (limit smaller than 2 tokens) and no guard is added.Out of scope (follow-ups)
The RCA identified several adjacent issues. They're intentionally not bundled here to keep this PR focused on the reported bug; happy to open separate issues/PRs for any the maintainers want:
sync_state='failed'(a future-only fix can't heal them; the reconciler never re-chunks).scripts/generate_message_embeddings.pystores fullmessage.contentper chunk embedding rather than the actual chunk text.ConclusionCreatevalidates the embedding limit witho200k_basewhile the client may use a different encoding (src/schemas/api.py:506-519).ValueError, misclassified as "token limit" insrc/utils/search.py:390-394)..github/workflows/live-llm-tests.ymlpath filters don't includesrc/embedding_client.py).Fixes #827
Summary by CodeRabbit
EMBEDDING_MODEL_CONFIG__TOKENIZER.tiktokenencodings, Hugging Face tokenizers, and local tokenizer files (with an optionaltokenizerspackage).