---
title: 'TEXTIQ-393 — De-hardcode tokenizer and storage-diagram docstring in DE'
type: 'refactor'
created: '2026-04-24'
status: 'in-review'
baseline_commit: '485b1ca0d4297a2bf6e35ea2e85b023e84791f48'
context:
  - 'src/llm/factory.py'
---

<frozen-after-approval reason="human-owned intent — do not modify unless human renegotiates">

## Intent

**Problem:** Commit `b78daa2` landed the `LLMProvider` factory (Azure + vLLM) and env-var-driven model/dim config, but three hardcoded call sites in `airflow/dags/tasks/processing_tasks.py` still instantiate `tiktoken` with `text-embedding-3-large` / `cl100k_base`, and the Storage Architecture docstring in `document_extraction_dag.py` still declares `3072-dim` vectors and `text-embedding-3-large`. This breaks token budgeting on the vLLM path and contradicts the on-prem story.

**Approach:** Add a provider-aware `get_token_counter()` helper to `src/llm/factory.py` (tiktoken `cl100k_base` for Azure; HuggingFace `AutoTokenizer` for vLLM with a char/4 fallback). Replace the three hardcoded sites with calls to the helper and genericize the DAG docstring to reference env-driven configuration. Migration tooling is explicitly deferred (see `deferred-work.md`).

## Boundaries & Constraints

**Always:**
- Azure path keeps current behavior: `cl100k_base` tokenizer, byte-identical chunk output on the same input.
- vLLM path uses `AutoTokenizer.from_pretrained(settings.embedding_model_name)`; on load failure, log WARNING and return `lambda t: max(1, len(t)//4)`.
- `@lru_cache` the tokenizer build so Airflow workers don't re-download per task invocation.
- No breaking change for existing Azure prod runs — env-var default `azure_openai` preserves today's behavior.

**Ask First:** None.

**Never:**
- Do NOT add Ollama-specific branches (use the vLLM branch with Ollama's OpenAI-compat endpoint).
- Do NOT refactor the existing `src/llm/factory.py` chat/embed APIs — only additive (new helper).
- Do NOT introduce a migration DAG or re-embed tooling in this spec — deferred.
- Do NOT change chunk_size token budgets (4096/1024/256) — they are model-agnostic token counts.

## I/O & Edge-Case Matrix

| Scenario | Input / State | Expected Output / Behavior | Error Handling |
|----------|--------------|---------------------------|----------------|
| Azure provider | `EMBEDDING_PROVIDER=azure_openai` | `get_token_counter()` returns tiktoken `cl100k_base` counter; chunker produces same byte output as before | N/A |
| vLLM + BGE-M3 | `EMBEDDING_PROVIDER=vllm`, `EMBEDDING_MODEL_NAME=BAAI/bge-m3` | Returns BGE-M3 tokenizer counter; chunker respects new token budget | N/A |
| vLLM tokenizer load fails | `from_pretrained` raises (bad model name, no network) | Returns `lambda t: max(1, len(t)//4)`; single WARNING logged | Fallback active; chunking proceeds |
| DAG docstring inspection | Open `document_extraction_dag.py` in Airflow UI | Storage diagram shows `{EMBEDDING_DIM}-dim vectors` / `configured EMBEDDING_MODEL_NAME` | N/A |

</frozen-after-approval>

## Code Map

- `src/llm/factory.py` -- Add `get_token_counter() -> Callable[[str], int]` with `@lru_cache`-backed builder.
- `airflow/dags/tasks/processing_tasks.py:308` -- Excel NL chunker uses helper; drop top-level `import tiktoken`.
- `airflow/dags/tasks/processing_tasks.py:449,460-465` -- Hierarchical chunker (text/PDF) uses helper.
- `airflow/dags/tasks/processing_tasks.py:2449,2489-2493` -- Excel Detailed hierarchical chunker uses helper.
- `airflow/dags/document_extraction_dag.py:279-280` -- Genericize storage-diagram docstring.
- `tests/unit/test_factory_token_counter.py` -- NEW. Unit test for Azure branch, vLLM happy path, vLLM fallback on load failure.

## Tasks & Acceptance

**Execution:**
- [x] `src/llm/factory.py` -- Added `get_tokenizer()` + `get_token_counter()` (counter composes over tokenizer). Azure branch: `tiktoken.get_encoding("cl100k_base")`. vLLM branch: `AutoTokenizer.from_pretrained(settings.embedding_model_name)`. `@lru_cache(maxsize=1)` on the builder. vLLM load error → `logger.warning(...)` + char/4 fallback.
- [x] `airflow/dags/tasks/processing_tasks.py` -- Replaced three hardcoded tiktoken sites with `get_token_counter()` / `get_tokenizer()`. Removed unused `import tiktoken` from the three task functions.
- [x] `airflow/dags/document_extraction_dag.py:279-280` -- Storage diagram now shows `EMBEDDING_DIMENSIONS-dim` and `configured embedding model` (column widths preserved).
- [x] `tests/unit/test_factory_token_counter.py` -- 4 tests (Azure happy, vLLM happy w/ monkeypatched AutoTokenizer, vLLM fallback on load error, fallback-never-zero invariant). All pass.
- [x] **Scope-creep fixes (approved mid-step):** `src/extraction_v2/excel_parser_service.py:1285`, `src/extraction_v2/record_builder.py:316-318`, `src/extraction_v2/semantic_chunker.py:160-173` — same pattern; semantic_chunker uses docling's `HuggingFaceTokenizer` for vLLM with tiktoken fallback.

**Acceptance Criteria:**
- Given `EMBEDDING_PROVIDER=azure_openai`, when the hierarchical chunker runs on a fixed input, then the produced chunk `.text` list is byte-identical to the pre-change output.
- Given `EMBEDDING_PROVIDER=vllm` and `EMBEDDING_MODEL_NAME=BAAI/bge-m3`, when `get_token_counter()("hello world")` is called, then it returns the BGE-M3 token length (distinct from cl100k_base's 2).
- Given a reviewer opens `document_extraction_dag.py`, when they read the Storage Architecture diagram, then no literal `text-embedding-3-large` or `3072` appears in the docstring.
- `grep -rn "text-embedding-3-large\|cl100k_base\|\\b3072\\b" airflow/ src/` returns no hits outside comments that describe historical behavior without prescribing it.
- `uv run pytest tests/unit/test_factory_token_counter.py -v` passes all three cases.

## Design Notes

Tokenizer helper sketch:

```python
# src/llm/factory.py
from functools import lru_cache
from typing import Callable

@lru_cache(maxsize=1)
def _build_token_counter() -> Callable[[str], int]:
    if settings.embedding_provider == "vllm":
        try:
            from transformers import AutoTokenizer
            tok = AutoTokenizer.from_pretrained(settings.embedding_model_name)
            return lambda t: len(tok.encode(t, add_special_tokens=False))
        except Exception as e:
            logger.warning("HF tokenizer load failed (%s); using char/4 fallback", e)
            return lambda t: max(1, len(t) // 4)
    import tiktoken
    enc = tiktoken.get_encoding("cl100k_base")
    return lambda t: len(enc.encode(t))

def get_token_counter() -> Callable[[str], int]:
    return _build_token_counter()
```

## Verification

**Commands:**
- `grep -rn 'text-embedding-3-large\|cl100k_base\|\b3072\b' airflow/ src/` -- expected: no code matches.
- `uv run pytest tests/unit/test_factory_token_counter.py -v` -- expected: all pass.
- `uv run python -c "from src.llm.factory import get_token_counter; print(get_token_counter()('hello world'))"` -- expected: prints a positive int.
- Airflow UI: DAG `document_extraction` parses without errors; docstring shows no hardcoded model/dim.

## Spec Change Log

**2026-04-24 — Post-implementation adversarial review (3 parallel sub-agents):**

- **P1 fix:** `SemanticChunker._build_tokenizer` was duplicating `factory._build_tokenizer` and not `@lru_cache`-backed. Added `get_docling_tokenizer(max_tokens)` in `src/llm/factory.py` with `@lru_cache(maxsize=4)`; `SemanticChunker` now delegates. Drops 2 imports (`HuggingFaceTokenizer`, `OpenAITokenizer`) and the `settings` import from `semantic_chunker.py`.
- **P2 fix:** char/4 fallback changed from `max(1, len(t)//4)` to `[0] * (-(-len(t) // 4)) if t else []` — now returns 0 for empty string, `ceil(len/4)` otherwise. Matches real tokenizer semantics (empty → 0 tokens) and avoids floor-rounding underestimates.
- **P3 fix:** `get_token_counter()` now composes over a cached builder via `_build_token_counter()` rather than wrapping `get_tokenizer()` per call — saves an allocation on hot path.
- **P4 fix:** `src/extraction_v2/__init__.py:68` stale comment about tiktoken-as-optional-dep updated.
- **D1/D2 dismissed:** HF fork-safety audit (tokenizer downloads are fast, no fork hazard) and DAG-parse-time settings read (lazy-inside-task pattern causes no harm) — both judged non-issues, not deferred.

Test updates: `_clear_tokenizer_cache` fixture now clears all three caches (`_build_tokenizer`, `_build_token_counter`, `_build_docling_tokenizer`). Renamed `test_fallback_never_returns_zero` → `test_fallback_semantics` to reflect new contract (empty → 0).

Blind-reviewer CRITICAL finding (logger undefined in `semantic_chunker._build_tokenizer`) was REJECTED after source inspection — `logger = structlog.get_logger(__name__)` present at module scope. Moot anyway now that the method is deleted via P1.
