SIGN IN SIGN UP

perf(tokenizer): replace tiktoken with quicktok and default to o200k_base (#3788)

Token counting is on the hot path of both retain and recall. Recall counts once
per candidate fact, per candidate chunk, per source fact and per reranker
document; retain counts whole documents. All of it went through
`len(encoding.encode(text))` — which builds a full Python list of ids only to
take its length.

Measured on this repo's own text with the microbenchmark added here, against
tiktoken 0.12.0 on a 14-core M-series, both on o200k_base:

  workload                        tiktoken   quicktok   speedup   peak alloc
  200 ranked facts                 4.39 ms    0.75 ms      5.8x   3 KiB -> 1 KiB
  500 source facts                 6.92 ms    1.12 ms      6.2x   2 KiB -> 1 KiB
  50 candidate chunks             12.02 ms    1.57 ms      7.7x   21 KiB -> 1 KiB
  100 reranker documents          10.84 ms    1.55 ms      7.0x   12 KiB -> 1 KiB
  one 77k-token document          36.08 ms    3.00 ms     12.0x   2.8 MB -> 1 KiB

Summed across the four counting stages one recall runs: 34.2 ms -> 5.0 ms.

Three things make this worth a dependency change rather than a micro-opt:

* `count()` returns an int without materialising the ids, so counting a large
  document allocates nothing. tiktoken has no count-only API — `encode_to_numpy`
  reaches the same 1 KiB but none of the speed, and is measured here too.
* ids are byte-identical to tiktoken's; the benchmark asserts that on adversarial
  inputs before it times anything.
* the vocabularies ship inside the wheel, so nothing is downloaded at runtime.
  That removes the tiktoken pre-download from the Docker build (both stages) and
  from scripts/dev/setup.sh — air-gapped deployments no longer need it baked in.

The dependency risk is maintenance, not correctness, so it is contained:
engine/token_encoding.py is the only module that imports quicktok, every call
site routes through get_token_encoding() / count_tokens(), and its one
dependency (numpy) was already in the tree. Replacing it means rewriting that
file and nothing else. This also removes the last direct tokenizer import that
had escaped the seam (`__import__("tiktoken")` in reflect/prompts.py).

Removes #3756's workaround. count_tokens_windowed existed to bound the memory of
counting a large retain body, encoding a megabyte at a time and accepting an
approximate answer because a fixed character cut can split a token. count()
allocates nothing at any size AND is exact, so the windowing, its helpers and its
six call sites are gone — those callers now get an exact count. The test file
keeps the property that made #3756 worth fixing (allocation does not track the
input), now asserted against count_tokens itself.

Default encoding moves to o200k_base, selectable with
HINDSIGHT_API_TOKENIZER_ENCODING (server-level: budgets are only comparable
between banks if they are all counted the same way). o200k_base is what current
OpenAI models tokenize with. On English and code it counts within a fraction of
a percent of cl100k_base, but on non-Latin scripts it is far closer to what a
model actually charges — a mixed-script line with emoji is 19 tokens under
cl100k_base and 13 under o200k_base. Since these counts back budgets that stand
in for a context window, the closer vocabulary is the more honest one. Set
cl100k_base to reproduce the previous counts exactly.

Call sites that only need a number now call count(); the ones that need ids
(query truncation, chunk truncation, reranker truncation, prompt fitting) still
encode, but only after a count shows the text does not fit. The chunk-budget loop
also stopped encoding each oversized chunk twice.
N
Nicolò Boschi committed
9fcb7ca7ac4a031cc47027aa1d5857575353cc58
Parent: ca2c983
Committed by GitHub <noreply@github.com> on 8/25/2026, 1:02:26 PM