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 (40 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 about twice the naive baseline (the first five words of the message) and ~92 % of a 2.4 GB LLM, a model 18,000× heavier — and on Danish, Finnish, Hungarian and Czech it matches or beats that LLM. 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.

score against size

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 default model:

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");   // -> "cover letter junior analyst position bank"

or with any file of this repository:

import { Titler } from "@aparte/titler";
const bytes = await fetch("https://huggingface.co/apartejs/aparte-titler/resolve/main/variants/titler-v1-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 titler-v1-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

model languages fp32 int8 int4 int3
titler-v1-latin (default, at the root) en, fr, es, de, pt, it, nl, pl, sv, da, fi, cs, ro, no, hu, hr, lt 613 KB 220 KB 150 KB 133 KB
variants/titler-v1-latin-mini the same 17, smaller vocabulary 466 KB 162 KB 109 KB 96 KB
variants/titler-v1-efigsp en, fr, es, de, pt, it 392 KB 134 KB 88 KB 77 KB
variants/titler-v1-<lang> (×17) one language 245 KB 76 KB 47 KB 40 KB

int3 is the recommended precision: 3 bits per weight with one fp16 scale per row, and the same score as fp32 on every benchmark. int2 was tested and dropped (−3 points). Every file carries its identity in its header (name, version, model, languages, precision), readable as titler.header in JS; manifest.json lists every file 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).

ONNX. The same 20 models are also in onnx/ as fp32 and int8 graphs (opset 17, dynamic length), with the tokenizers file of each vocabulary and a 60-line example that reproduces the runtime's decoding — for people who already run ONNX Runtime. 5 to 10 times heavier than the .bin; same titles as the fp32 reference on the benchmark (300/300), 287/300 for int8.

Results

Word-level F1 against the gold title — one human-quality reference per message, 300 messages per language, int3 files, the 6-word budget. "LLM" is gemma 4 e2b (2.4 GB, Q4_K_M), the teacher; "no model" is the first five words of the message.

language one model (40 KB) efigsp (77 KB) latin-mini (96 KB) latin (133 KB) LLM (2.4 GB) no model
English 0.626 0.615 0.615 0.610 0.672 0.315
French 0.625 0.610 0.607 0.624 0.681 0.328
Spanish 0.611 0.612 0.612 0.607 0.681 0.300
German 0.614 0.613 0.606 0.622 0.657 0.283
Portuguese 0.587 0.609 0.589 0.593 0.628 0.329
Italian 0.586 0.582 0.575 0.595 0.682 0.293
Dutch 0.630 0.629 0.630 0.710 0.266
Polish 0.609 0.600 0.602 0.618 0.384
Swedish 0.758 0.759 0.755 0.809 0.431
Danish 0.752 0.759 0.756 0.758 0.484
Finnish 0.789 0.793 0.786 0.773 0.674
Czech 0.766 0.763 0.769 0.775 0.510
Romanian 0.673 0.763 0.761 0.794 0.471
Norwegian 0.747 0.765 0.768 0.815 0.438
Hungarian 0.705 0.725 0.734 0.706 0.509
Croatian 0.602 0.745 0.754 0.811 0.492
Lithuanian 0.499 0.548 0.564 0.657 0.343

per language

Every model on every language, including languages it was not trained on (a one-language model on a neighbouring language keeps about half of the gain over "no model"):

matrix

How to read the numbers: the metric uses a single reference title per message, and the task admits several good titles — so a single reference under-counts every system in the same way, the LLM included; it lowers every number without biasing the comparison (two careful annotators agree at only 0.66–0.74 on it, an LLM with a good prompt reaches 0.62–0.82). On the eight real-chat languages the tiny model sits at ~92 % of the LLM and twice the no-model floor; on Danish, Finnish, Hungarian and Czech it matches or beats the LLM — sets made mostly of short FAQ-style questions, an easier task, but the same task for both. Scores are in scores.json; charts/ and the tables come out of one script in the repository.

How it was built

  1. Corpus. 12,000 real first messages per language: WildChat-4.8M first, completed when short by OASST2, Aya and — for the smaller languages — FAQ questions from MFAQ (capped at 100 questions per site to avoid the travel-site templates). Plus every message under 20 characters, which is its own title.
  2. Teacher. gemma 4 e2b (llama.cpp, thinking off) wrote an extractive title for each message: 3–5 words copied from it, subject first. 90–97 % of the titles were fully extractive; the rest was dropped.
  3. 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.
  4. 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.
  5. Decoding. The 6 best-scored words, in message order (the length of a title is not predictable from the message: a learned length head did no better than always 6).
  6. Quantization. Per-row symmetric integers with an fp16 scale; int3 measured lossless against fp32 on the benchmark.
  7. Evaluation. 300 messages per language titled blind by Claude (Fable), checked word-for-word against the message; the teacher as a second voice; the metric is word-level F1. The English set also has Opus, Sonnet and two larger Gemma models as voices. Everything is in apartejs/aparte-titler-gold.

Limits, honestly

  • Long messages with a preamble ("act as an expert in…", pasted dialogues) get titled on the preamble rather than on the request. Scores on messages over 400 characters are 0.4–0.5 against 0.85 on short ones.
  • 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.
  • int3 gives the same score as fp32, not the same titles: about a third of the messages get a different word among near-ties.
  • Small languages (Croatian, Lithuanian) have fewer than 2,000 training messages; use the 17-language model for them, not the one-language file. Lithuanian is the weakest language of all (0.563 with the 17-language model, 0.500 alone).
  • The Nordic, Czech, Romanian and Hungarian sets are mostly FAQ questions rather than chat; their high scores reflect an easier task.

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) on messages from WildChat-4.8M (ODC-BY), OASST2 (Apache 2.0), Aya (Apache 2.0) and MFAQ (CC0). 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},
  url = {https://apartejs.dev/models/titler/}
}
Downloads last month

-

Downloads are not tracked for this model. How to track
Inference Providers NEW
This model isn't deployed by any Inference Provider. 🙋 Ask for provider support

Datasets used to train apartejs/aparte-titler