aparte-titler
A title for a conversation, from its first message, in the browser — the weight of an icon, no API call.
aparte-titler picks 3 to 6 words of the user's first message and returns them, in order, as the conversation's title. This repository holds every model of the family: one file for 17 European languages (133 KB in int3), a 6-language file, and one file per language (41 KB), each in four precisions — plus ONNX graphs for every model. It runs in a few milliseconds on one CPU core (2–4 ms in Python, 6–7 ms in the pure-JS runtime, for a typical 300-character message), in the browser, in Node or in Python, with no framework and no dependency. On the benchmark it scores nearly twice the naive baseline (the first five words of the message), and on the six languages where the comparison can be made fairly, the 133 KB file reaches 94 % of its 2.4 GB teacher — a model 18,000× heavier. Because it only ever copies words from the message, it is immune to prompt injection by construction: an instruction hidden in a message can at worst produce a poor title, never be executed.
- Presentation: apartejs.dev/models/titler · try it in your browser: apartejs.dev/models/titler/#demo
- Code, runtime and npm packages: github.com/apartejs/aparte-titler-model

What changed in 1.2
- Every model saw about 33,000 examples per language, up from 1,000–25,000. Fourteen languages were topped up with English messages machine-translated by the teacher; 58 % of the training data is translation, and every translated row says so in the dataset.
- The 17-language files were no longer held back. In 1.1 they capped every language at 12,000 examples; in 1.2 they get the same ~33,000 as the one-language files, and gain +0.023 (
latin) and +0.016 (latin-mini). The 6-language file does not move: its languages were never short of data. - A new benchmark, gold-chat: real chat only, balanced by message length. Every number on this page is on it, for all three versions — the 1.0 and 1.1 figures published before were on an older set and are not comparable.
- Same architecture, same runtime, same file sizes. Nothing to change in your code but the file name.
Use it
In the browser or Node — the runtime is one dependency-free file (titler.js, 12 KB of source, 6 KB minified), published as @aparte/titler; @aparte/titler-latin bundles the runtime and the default model together, with no dependency of its own:
import { loadTitler } from "@aparte/titler-latin";
const titler = await loadTitler();
titler.title("Write me a cover letter for a junior data analyst position at a bank");
In a bundled browser app, serve the .bin yourself and pass it to loadTitler(url): a bundler does not rewrite the package-relative URL that the no-argument form uses.
or with any file of this repository:
import { Titler } from "@aparte/titler";
const bytes = await fetch("https://huggingface.co/apartejs/aparte-titler/resolve/main/v1.2/titler-v1.2-fr-int3.bin").then((r) => r.arrayBuffer());
new Titler(bytes).title("Peux-tu m'expliquer la photosynthèse chez les plantes ?");
In Python — the reference implementation, numpy only, in the repository:
python reader.py v1.2/titler-v1.2-latin-int3.bin "Peux-tu m'expliquer la photosynthèse chez les plantes ?"
No framework is needed anywhere: the file format is documented and the reader is 250 lines. Give the model only the user's first message, never a system prompt or the conversation history.
Files
One folder per version. v1.2/ is current; v1/ and v1.1/ stay for anyone who pinned them.
| model | languages | fp32 | int8 | int4 | int3 |
|---|---|---|---|---|---|
v1.2/titler-v1.2-latin (default) | en, fr, es, de, pt, it, nl, pl, sv, da, fi, cs, ro, no, hu, hr, lt | 614 KB | 220 KB | 150 KB | 133 KB |
v1.2/titler-v1.2-latin-mini | the same 17, smaller vocabulary | 466 KB | 163 KB | 109 KB | 96 KB |
v1.2/titler-v1.2-efigsp | en, fr, es, de, pt, it | 392 KB | 134 KB | 89 KB | 77 KB |
v1.2/titler-v1.2-<lang> (×17) | one language | 245 KB | 77 KB | 48 KB | 41 KB |
int3 is the recommended precision: 3 bits per weight with one fp16 scale per row. It costs 0.005 against fp32 on the 17-language file, and int4 costs nothing — see the limits below. Every file carries its identity in its header (name, version, model, languages, precision), readable as titler.header in JS; manifest.json lists every file of every version with its size, parameters and languages. Why 133 KB for 141k parameters? The 3-bit weights are 53 KB; the file adds one fp16 scale per matrix row (26 KB), the BPE merge table (12,030 pairs, 48 KB), the JSON header with the byte-to-id table and the file's identity (4 KB) and the fp16 bias and normalization vectors (2 KB).
Moved in 1.2. Until 1.1 the files sat flat — the 17-language file at the root, everything else in variants/. They are now in one folder per version: …/resolve/main/titler-v1.1-latin-int3.bin is now …/resolve/main/v1.1/titler-v1.1-latin-int3.bin. A link pinned to a commit (…/resolve/<commit>/…) still works.
ONNX. The same 20 models of every version are also in onnx/ as fp32 and int8 graphs (opset 17, dynamic length), one folder per version with its own tokenizers, and an example that reproduces the runtime's decoding — for people who already run ONNX Runtime. 5 to 10 times heavier than the .bin; the fp32 graph gives exactly the titles of the fp32 .bin.
Results
Word-level F1 against the reference title, on gold-chat, int3 files as published, hybrid decoding. Lines marked impossible, off-language, pasted transcriptions and unannotated lines are excluded; so are 61 lines whose text appeared in the training data. The tables below are rewritten by a script from scores.json, never typed by hand.
per language and scope
| language | one model (41 KB) | efigsp (77 KB) | latin-mini (96 KB) | latin (133 KB) | no model |
|---|---|---|---|---|---|
| English | 0.620 | 0.608 | 0.609 | 0.629 | 0.313 |
| French | 0.622 | 0.604 | 0.602 | 0.630 | 0.321 |
| Spanish | 0.640 | 0.614 | 0.640 | 0.635 | 0.318 |
| German | 0.620 | 0.605 | 0.602 | 0.620 | 0.277 |
| Portuguese | 0.591 | 0.596 | 0.619 | 0.598 | 0.325 |
| Italian | 0.556 | 0.542 | 0.557 | 0.549 | 0.307 |
| Dutch | 0.513 | — | 0.540 | 0.547 | 0.292 |
| Polish | 0.566 | — | 0.560 | 0.572 | 0.388 |
| Swedish | 0.697 | — | 0.682 | 0.661 | 0.303 |
| Danish | 0.579 | — | 0.563 | 0.574 | 0.268 |
| Finnish | 0.546 | — | 0.545 | 0.557 | 0.364 |
| Czech | 0.573 | — | 0.552 | 0.585 | 0.315 |
| Romanian | 0.610 | — | 0.600 | 0.618 | 0.323 |
| Norwegian | 0.629 | — | 0.624 | 0.617 | 0.289 |
| Hungarian | 0.570 | — | 0.559 | 0.578 | 0.346 |
| Croatian | 0.606 | — | 0.597 | 0.611 | 0.340 |
| Lithuanian | 0.549 | — | 0.541 | 0.568 | 0.354 |
Which file to use. The 17-language file scores at least as well as the one-language file for 11 of the 17 languages, for a third of their combined size, so it is the default. The one-language file is better for Swedish (+0.036) and Norwegian (+0.012), and within a hundredth for Danish, Italian, Spanish and German.
1.0, 1.1 and 1.2 on the same benchmark
All three versions read by the same runtime on the same set, so this compares weights only.
| scope | 1.0 | 1.1 | 1.2 | 1.2 − 1.1 |
|---|---|---|---|---|
| English | 0.6203 | 0.6186 | 0.6198 | +0.0012 |
| French | 0.6153 | 0.6280 | 0.6217 | -0.0063 |
| Spanish | 0.6242 | 0.6433 | 0.6397 | -0.0036 |
| German | 0.6043 | 0.6210 | 0.6200 | -0.0010 |
| Portuguese | 0.5767 | 0.5969 | 0.5908 | -0.0061 |
| Italian | 0.5591 | 0.5556 | 0.5565 | +0.0009 |
| Dutch | 0.5389 | 0.5212 | 0.5135 | -0.0077 |
| Polish | 0.5720 | 0.5664 | 0.5662 | -0.0002 |
| Swedish | 0.6393 | 0.6457 | 0.6965 | +0.0508 |
| Danish | 0.4860 | 0.4900 | 0.5787 | +0.0887 |
| Finnish | 0.4842 | 0.4831 | 0.5463 | +0.0632 |
| Czech | 0.5077 | 0.5104 | 0.5734 | +0.0630 |
| Romanian | 0.3819 | 0.5464 | 0.6104 | +0.0640 |
| Norwegian | 0.5027 | 0.5266 | 0.6293 | +0.1027 |
| Hungarian | 0.5105 | 0.5261 | 0.5698 | +0.0437 |
| Croatian | 0.3441 | 0.3913 | 0.6061 | +0.2148 |
| Lithuanian | 0.4700 | 0.4738 | 0.5490 | +0.0752 |
| efigsp | 0.6004 | 0.6007 | 0.5948 | -0.0059 |
| latin-mini | 0.5706 | 0.5720 | 0.5877 | +0.0157 |
| latin | 0.5750 | 0.5737 | 0.5970 | +0.0233 |
| mean | +0.0388 |
The gain is almost entirely in the languages that were short of data: Croatian, Norwegian, Danish, Lithuanian, Czech, Romanian, Finnish, Hungarian and Swedish. English, French, Spanish, German, Portuguese, Italian, Dutch and Polish already had 12,000 examples or more and move within a point, in both directions. Above roughly 12,000 examples a language gains nothing measurable from more.
Read this by message length, not as one number
A ten-word message has ten candidate words and the model keeps three to six; a two-hundred-word message has two hundred. Scores follow the length far more than the language. gold-chat is balanced across lengths — about sixty lines per band per language — so every band below is measured, which was not the case before: in the older set five languages had no message longer than twenty words.
by message length (words), one-language files
| scope | 0-10 | 10-20 | 20-40 | 40-80 | 80+ |
|---|---|---|---|---|---|
| English | 0.823 | 0.759 | 0.602 | 0.467 | 0.440 |
| French | 0.837 | 0.728 | 0.612 | 0.461 | 0.454 |
| Spanish | 0.822 | 0.756 | 0.571 | 0.551 | 0.460 |
| German | 0.788 | 0.751 | 0.577 | 0.481 | 0.489 |
| Portuguese | 0.769 | — | 0.518 | 0.463 | 0.474 |
| Italian | — | 0.709 | 0.572 | 0.428 | 0.446 |
| Dutch | 0.644 | 0.596 | 0.517 | 0.462 | 0.325 |
| Polish | 0.774 | 0.658 | 0.548 | 0.459 | — |
| Swedish | 0.763 | 0.789 | 0.628 | 0.564 | 0.747 |
| Danish | 0.729 | 0.748 | 0.582 | 0.462 | 0.365 |
| Finnish | 0.790 | 0.624 | 0.431 | 0.451 | — |
| Czech | 0.737 | 0.703 | 0.558 | 0.464 | 0.375 |
| Romanian | 0.817 | 0.748 | 0.584 | 0.523 | 0.346 |
| Norwegian | 0.785 | 0.729 | 0.586 | 0.530 | 0.512 |
| Hungarian | 0.795 | 0.657 | 0.507 | 0.477 | 0.399 |
| Croatian | 0.842 | 0.719 | 0.541 | 0.489 | 0.430 |
| Lithuanian | 0.758 | 0.657 | 0.465 | 0.531 | 0.333 |
An em dash means fewer than 50 lines in that band.
Three seeds, not one
Each scope is trained three times; the published file is the seed with the best score on the validation split, never on the benchmark. The spread is what a different random draw costs, so no difference smaller than it should be read as real.
| scope | seeds | mean | range |
|---|---|---|---|
| English | 3 | 0.6287 | 0.0055 |
| French | 3 | 0.6271 | 0.0018 |
| Spanish | 3 | 0.6328 | 0.0022 |
| German | 3 | 0.6270 | 0.0062 |
| Portuguese | 3 | 0.6098 | 0.0084 |
| Italian | 3 | 0.5744 | 0.0072 |
| Dutch | 3 | 0.5266 | 0.0239 |
| Polish | 3 | 0.5729 | 0.0056 |
| Swedish | 3 | 0.6949 | 0.0067 |
| Danish | 3 | 0.5841 | 0.0077 |
| Finnish | 3 | 0.5545 | 0.0086 |
| Czech | 3 | 0.5760 | 0.0069 |
| Romanian | 3 | 0.6080 | 0.0382 |
| Norwegian | 3 | 0.6298 | 0.0015 |
| Hungarian | 3 | 0.5805 | 0.0001 |
| Croatian | 3 | 0.6099 | 0.0118 |
| Lithuanian | 3 | 0.5630 | 0.0013 |
| efigsp | 3 | 0.6159 | 0.0036 |
| latin-mini | 3 | 0.6068 | 0.0037 |
| latin | 3 | 0.6081 | 0.0011 |
These are the fp32 weights before quantization, averaged over the three seeds, so they sit slightly above the published int3 files of the first table: quantization costs about 0.005, and the published seed is chosen on validation, not on this benchmark. Romanian (0.038) and Dutch (0.024) are the least stable; most scopes move by less than a hundredth.
Limits, honestly
- Part of the benchmark is translated. Where a language did not have 300 real chat messages, English ones were machine-translated into it: 88 % of the Croatian set, 78 % of the Danish, 77 % of the Norwegian, 73 % of the Finnish, 47 % of the Lithuanian. The training data of those languages is mostly translated too. A model trained and measured on translation can learn the translator's register rather than the language, and the largest gains of 1.2 are exactly there. Czech, measured on 93 % native text, gains strongly too, so the gains are not only an artefact — but they are harder to guarantee than the English or French ones.
- One reference per message. The task admits several good titles, and the score counts only one of them. The absolute numbers are therefore not comparable to other work; the relative ones, measured in the same protocol, are.
- A ceiling below 1.0. 11 % of the reference titles contain a word the model cannot produce: a version number, a path or a file name, which the tokenizer splits at its dots (
node.jsbecomesnodeandjs). - Long messages are the weak point. 0.78 on average on messages under ten words, 0.44 beyond eighty. The words of the right title are in the text: the model fails to find them, not to reach them.
- Messages are truncated at 1,200 characters, in the benchmark and in the runtime. Nothing is known about what happens beyond.
int3gives a slightly lower score thanfp32and different titles. On the 17-language file: −0.005 F1, and the same title on only 50 % of messages — the rest differ by one near-tied word.int8is identical tofp32on 99 % of messages;int4scores as well asfp32.- Chat-speak stop words ("pk", "ya", "si") are not always dropped: the training data is more formal than SMS-style chat.
- Typos are copied, because words are never rewritten.
How it was built
- Corpus. Real first messages per language from WildChat-4.8M, completed where short by OASST2, Aya, Tagengo and FAQ questions from MFAQ (capped at 100 per site). Fourteen languages were then brought to about 33,000 messages with English WildChat messages machine-translated by the teacher. Plus every message under 20 characters, which is its own title. 601,822 messages in all.
- Teacher. gemma 4 e2b (llama.cpp, thinking off) wrote an extractive title for each message: 3–5 words copied from it, subject first, on the translated text for translated messages.
- Labels. Each title word aligned back to the message (exact, then inflection, then one-typo), giving a keep/drop label per token and the word each token belongs to.
- Model. A 2-layer transformer encoder (width 32, 4 heads, GELU, learned positions) with factorized 8-dimensional embeddings over a byte-level BPE vocabulary — 2,048 merges for one language, 12,288 shared by 17. The decision is taken per word: the logits of a word's tokens are averaged before the loss. 58,727 parameters for one language, 140,647 for 17. Every language, in its own file or in a shared one, sees the same ~33,000 examples.
- Decoding. Hybrid: among the six best-scored words, keep those the model gives at least 0.5, never fewer than three, and keep every word of a message that has four or fewer.
title(message, 6)gives the fixed six-best decoding of 1.0. - Quantization. Per-row symmetric integers with an fp16 scale.
- Evaluation. gold-chat: about 300 chat messages per language titled blind by Claude (Fable), checked word-for-word against the message, decontaminated against the training data at the model's input length. Everything is in apartejs/aparte-titler-gold.
Latency
About 9 µs per character on one CPU core in Python (Ryzen AI Max) and 22 µs in the pure-JS runtime under Node 24: 2–4 ms and 6–7 ms for a typical 300-character message. No GPU, no network, under 1 MB of memory.
Sources and licenses
Code and weights: MIT © 2026 Paul Richez. The model was distilled from titles written by gemma 4 e2b (Apache 2.0), which also translated part of the training data; Google claims no rights in its outputs (Gemma Terms of Use §3.3). The messages come from WildChat-4.8M and WildChat-1M (ODC-BY), OASST2 (Apache 2.0), Aya (Apache 2.0), Tagengo (Apache 2.0) and MFAQ (CC0); the licence of each row is in the dataset. The benchmark annotations are ours (CC BY 4.0).
Citation
@software{aparte_titler_2026,
author = {Richez, Paul},
title = {aparte-titler: extractive conversation titles in 17 languages, in the browser},
year = {2026},
version = {1.2.0},
url = {https://apartejs.dev/models/titler/}
}