chezo.uno / prelims-cli

Comparing embedding models for related-article recommendation

Across 420 Japanese and 33 English blog posts, two experiments measured the quality of related-article recommendations. Experiment 1 compares five embedding models; experiment 2 compares how much of the article body the model is given. The metric used at the start had a design fault — the model could move its own denominator — and the two natural readings of it ranked the models in opposite orders. Rebuilding it with a fixed set of judged items still did not settle the ranking, and what finally came out of judging the recommendations by hand was that this proxy metric could see only 9% of what relevance actually meant, and that 76% of both models' recommendations were sound, the gap being 2 slots out of 120. With quality unable to separate them, the decision moved to taste and cost, and landed on consolidating to a single multilingual model.

There is no ground truth for this, so agreement with the hand-written tags on each article stands in for it. This is not an off-the-shelf benchmark, so the metric is defined, its chance level established and its validity tested first; then the two experiments; then a human evaluation of the proxy metric itself. Finding and repairing the metric's fault happens along that same route.

Verdict Consolidate to one multilingual model (quality equal — decided on taste and cost) · raise the input to 8,000 characters

Corpus
ja 420 posts / en 33 posts
Referee
Hand tags (fixed-pool P@k, AUC) + 162 articles judged by hand
topk
3 / 5 / 10, checking the sign holds
Varied
5 models / input length / and the metric itself

Quality in other languages was not measured. These are results for Japanese and English.

Premise

The system and its constraints

chezo.uno is a personal blog built with Hugo, and the related articles at the foot of each post are written into the front matter by prelims-cli. It embeds each article and recommends the nearest neighbours by cosine similarity.

This runs on GitHub Actions, which is what sets the constraints.

  • No GPU. Inference is CPU-only, so the model has to run under ONNX Runtime
  • Limited memory. A standard runner has 16 GB on a public repository and 7 GB on a private one. Not just the model has to fit — so does peak memory while embedding
  • It runs on every new post. Run time is time before the post is live

Under these conditions only small embedding models are available. Today there are two, one per language (ruri-v3-30m for Japanese, granite-small-english for English), switched on a language setting.

This started as curiosity about Bekko Embedding, a small multilingual model (a25m model card). It would be interesting if 8M / 25M active parameters were enough for this job — and from that came two hopes: that it might replace both current models, and that even if it did not, it might extend coverage past Japanese and English.

So both Bekko sizes, plus granite-embedding-97m-multilingual-r2 as another small multilingual model, were put on the same footing as the two current ones.

1 · The metric

Defining the metric

There is no ground truth for "a good related article". Instead the hand-written tags on each article act as the referee: if two articles sharing a tag get recommended to each other, the topics are taken to match. The tags were not written for recommendation, so as a referee they are at least independent.

What follows defines every number in this report. None of it is an off-the-shelf benchmark — these are proxy metrics written for this investigation — so the arithmetic is recorded. The implementation is scripts/compare_embedding_variants.py. The first metric (tag agreement) turned out to have the design fault described below, so model comparisons use the two after it: fixed-pool precision@k and pairwise AUC.

Tag agreement (any-tag@k)

Let T(a) be the tag set of article a and Rk(a) its top k recommendations. Only pairs tagged on both ends are scored; if either side is untagged, the pair does not count.

P={(a,r)rRk(a),T(a),T(r)} any-tag@k=|{(a,r)P:T(a)T(r)}||P| avgshared=(a,r)P|T(a)T(r)||P|

P is the set of scored pairs, T(·) a tag set, Rk(a) the top k recommendations for article a.

T(a) is the union of tags, categories and keywords from the front matter, lowercased. Which keys count is set by --tag-keys. Every headline number here uses tags and categories only.

This definition is faulty. The model chooses P, the set of scored pairs — it decides which articles to recommend, so the denominator differs per model. Recommending an untagged article drops out of the denominator entirely and costs nothing, so two models are not being asked the same question. What that leads to is shown in "the ranking flips with the reading". The two below are the repaired version, and every model ranking in this report is decided with them.

Fixed-pool precision@k (judged pool)

Restrict the candidates to tagged articles first, then take the top k. This is the condensed-list treatment used in information retrieval for incomplete relevance judgments. Sources are restricted to tagged articles too, so the denominator is fixed at "sources × k" and every model is made to pick the same number from the same pool. With S the set of tagged articles:

precpool@k=aS|{rRkS(a):T(a)T(r)}||S|×k

RkS(a) is the top k with candidates restricted to S. The denominator does not depend on the model.

The theoretical ceiling — the highest hit count any ranking could reach — is reported alongside. An article with fewer than k same-label partners cannot fill all k slots, so for Japanese at k=3 the ceiling is 203 of 285 slots, and for English 53 of 69.

Pairwise AUC

Over all pairs of tagged articles, the probability that a same-label pair ranks as more similar than a different-label pair. 0.5 is chance. It uses the whole similarity ordering rather than a top-k cutoff, so it resolves differences that a change in hit count cannot show, and it does not depend on topk.

AUC=P(sim(p+)>sim(p))+12P(sim(p+)=sim(p))

p+ is drawn uniformly from same-label pairs, p from different-label ones. Japanese has 212 same-label against 4,253 different; English 53 against 200.

The implementation does not enumerate the probability; it computes the Mann-Whitney U statistic. Rank every pair by similarity (ties take the average rank) and take the rank sum over the same-label pairs:

AUC=pP+rank(p)n+(n++1)2n+×n

rank is the 1-based position in ascending similarity over all pairs (ties averaged), and n+ / n are the counts of same-label and different-label pairs. Averaging tied ranks is what corresponds to the 1/2 term above.

In this form the rank sum is maximal — 1.0 — when every same-label pair sits above every other, and 0.0 when they all sit below. No pairwise comparison loop is needed, so even the 4,465 Japanese pairs come out instantly.

Both metrics report a paired bootstrap 95% interval on the difference. Precision resamples source articles; AUC resamples articles rather than pairs, because pairs sharing an endpoint are correlated and resampling them directly makes the interval too narrow. 1,000 resamples, fixed seed.

Hub concentration

Count how many articles recommend article x (its in-degree), then look at the maximum and at the share of all recommendation slots taken by the top 5 articles. N is the article count.

indeg(x)=|{axRk(a)}| maxindeg=maxxindeg(x) top5share=xtop5indeg(x)N×k

N is the article count, k the number of recommendations. top5 is the five articles with the highest in-degree.

Lower is better. It catches an article that attaches itself everywhere regardless of topic. It sometimes moves before tag agreement does, which is why it is kept as a separate view.

Probe separation

Embed three sentences translated into three languages — two on one topic, one unrelated — and take the difference in cosine similarity. Implemented in scripts/check_onnx_model.py.

margin=cos(e0,e1)max(cos(e0,e2),cos(e1,e2))

e0 and e1 are the same-topic sentences, e2 the unrelated one.

That this metric does not measure ranking quality is shown under "Is the metric valid?".

Membership changes and order-only changes

When comparing two settings, count separately whether the set of top k changed or only the order did. A reordering shows up in the front matter diff, but the reader sees the same articles.

membership=|{aset(RkA(a))set(RkB(a))}| orderonly=|{aRkA(a)RkB(a)same set}|

A and B are the two settings being compared.

1 · The metric

The chance level

The agreement rates below land between 0.3 and 0.5. Whether that is high or low is not determined by the number alone. To give it a scale, here is the probability that two randomly chosen articles from the same corpus share a tag — the floor you would get by recommending nothing at all.

Probability that two random articles share at least one tag, and how far the observed values sit above it. Observed values are the spread across all models at topk=5
CorpusRefereeTaggedAll pairs ChanceObserved k=5Ratio
jahand tags only 954,465 0.0470.384〜0.428~8–9×
with keywords41887,153 0.0470.460〜0.536~10–11×
enhand tags only 23253 0.2090.356〜0.465~1.7–2.2×
with keywords33528 0.2250.564〜0.570~2.5×

In Japanese the observed values sit 8–9× above chance, so the metric is picking up signal. In English, chance reaches 21%. Only 23 articles are tagged and the vocabulary concentrates on python / oss / machine learning, so even a random pick often hits.

This is part of why English resolves differences so poorly: a high chance level compresses the range left for models to differ in. It is not only about article count. The int8 build of granite-97m-multilingual reaches 0.294 at topk=10, just 1.4× chance.

Swapping the referee does not move the Japanese chance level at all (0.047 either way). Adding keywords is not stretching the scale. And yet the winner changes — with keywords, bekko-a8m is ahead by +7.6pt; with hand tags it loses by 2.3pt. In ratio terms too: with keywords ruri is 9.8× against bekko's 11.5×, with hand tags ruri 9.0× against bekko's 8.5×, and the order reverses. This is not a question of scale — the ordering itself changes. The next section takes it up.

0.40 in Japanese and 0.40 in English do not mean the same thing. With chance at 0.047 against 0.209, a factor of four, absolute agreement rates cannot be compared across languages. Everything compared across languages in this report is limited to the ordering and sign of the differences between models.

Pairwise AUC differs here. By construction, chance is 0.500 in every language, so a comparison like ja 0.83 against en 0.77 does at least share a scale (which is not the same as sharing a meaning). Fixed-pool precision@k, like agreement, depends on how easy the labels are to hit, so it cannot be compared.

1 · The metric

Is the metric valid?

Since this is a proxy, what it can and cannot measure has to be settled first. Four things were checked.

The winner changes with where the tags come from

Besides the hand-written tags and categories, the front matter carries keywords extracted automatically by TF-IDF (on 405 of 420 articles, 2,350 distinct terms, the most common being こと, a semantically empty formal noun). Including those in the referee means measuring "topical agreement" as agreement with the very method the embeddings are meant to replace. Swap the referee and the winner does change.

Per-language models (ruri-v3 / granite) bekko-a8m

Japanese — referee: with TF-IDF keywords (418 articles, 2,090 pairs)

ruri-v3 + prefix
0.460
bekko-a8m
0.536

Japanese — referee: hand tags only (95 articles, 271 pairs)

ruri-v3 + prefix
0.428
bekko-a8m
0.405

English — referee: with keywords (33 articles, 165 pairs)

granite
0.570
bekko-a8m
0.564

English — referee: hand tags only (23 articles, 103 pairs)

granite
0.447
bekko-a8m
0.449
Share of recommendations sharing at least one tag (topk=5, scale 0–1). In Japanese, swapping the referee turns bekko's +7.6pt into −2.3pt. In English neither referee separates them.

In Japanese, bekko's +7.6pt becomes −2.3pt. The chance level does not move at all (0.047 either way), so this is not the scale stretching — the ordering itself changes. Every number in this report is measured with hand-written tags / categories only.

In English the referee makes no difference (−0.6pt and +0.2pt). The contamination shows up only in Japanese because keywords coverage differs sharply — 405/420 in Japanese against 29/33 in English — and because the hand tags cover only 95 Japanese articles. Swapping the referee inflates the scored set from 271 pairs to 2,090, and every one of those added pairs is TF-IDF derived.

Measured again with the repaired metric, the shape of the contamination is visible. Under the keywords referee, fixed-pool precision@5 puts a8m ahead by a significant +7.6pt [+5.6, +9.6], while under the same keywords referee the AUC gap is only +1.1pt [−0.4, +2.5]. a8m's advantage is concentrated in the top few results rather than spread through the ranking, which fits the explanation that articles sharing TF-IDF terms at the surface rise to the top.

Absolute values cannot be compared across languages

Chance is 0.047 in Japanese against 0.209 in English, a factor of four. 0.40 in Japanese and 0.40 in English do not carry the same meaning. Everything compared across languages here is limited to the ordering and sign of the differences between models.

Probe separation does not measure ranking quality

This metric takes the similarity gap between a same-topic pair and an unrelated sentence, over three-language probes of identical content. bekko-a25m separates less than a8m (0.173 against 0.379 in Japanese) yet recommends better. What it captures is how spread out the embedding space is, not which model ranks better.

Same topic − unrelated (higher means better separated)
ModelJapaneseEnglishFrench
bekko-a8m0.3790.5880.509
ruri-v3 + prefix0.1680.1540.072
granite (cls)0.0550.1790.139

The two current models return 0.72–0.85 even for unrelated sentences, compressing every value into a narrow band. ruri's 0.072 in French and granite-english's 0.055 in Japanese show separation all but vanishing outside their range. This metric is only good for asking "is it broken?" and "can a threshold be set?". It does work as evidence that a per-language model cannot be reused for other languages.

Only one article — cannot form a pair Two or more — can be scored
Japanese — breakdown of the 69 labels vertical axis is the number of distinct labels
0 15 30 45 1 39 2 11 3 9 4–5 4 6–9 5 10+ 1 Articles carrying that label
English — breakdown of the 26 labels vertical axis is the number of distinct labels
0 8 16 24 1 19 2 3 3 1 4–5 2 6–9 1 10+ Articles carrying that label

39 of the 69 Japanese labels and 19 of the 26 English ones appear on a single article. A label used once cannot form a pair by definition, so more than half the vocabulary contributes nothing to the metric. What is left forms only 212 same-label pairs in Japanese and 53 in English.

Set that against the total and the ceiling appears. Of the 4,465 pairs formed by the 95 tagged Japanese articles, only 212 (4.7%) share a label — that is the entire supply of "correct answers". In the fixed pool at topk=3, no ranking can exceed 203 hits out of 285 slots, and the current model takes 103 (51% of the ceiling). That is not a bad number, but with a supply this size it is hard to make a few points of hit-count difference mean anything.

English had machine_learning and machine-learning as separate labels. Tags were normalised partway through this investigation, and every number in this report was re-measured afterwards.

Resolution — how small a difference can this metric see?

The resolution of tag agreement depends on |P|, the number of recommendation pairs tagged on both ends — and |P| is small. Japanese produces 1,260 recommendations (420 articles × topk=3), yet only 174 of them, 14%, can be scored. The reason is not a low agreement rate. Agreement itself is 49%, ten times chance, so the signal is clearly there. What binds is tag coverage: 95 of 420 articles carry hand tags, and since both ends of a pair need tags, that 23% enters squared.

So does a two-point difference between models mean anything? Measured with paired bootstrap 95% intervals, the three metrics differ sharply in resolution.

95% intervals on the difference, current model against bekko-a25m (8,000 characters, hand tags). Interval width — that is, resolution — depends on the metric
MetricResampling unit ja interval widthen interval width
Tag agreement (old — model-dependent denominator)source article ±5pt±6pt
Fixed-pool precision@3source article ±3.7pt±5.1pt
Pairwise AUCarticle ±3.9pt±8.5pt

Fixed-pool precision tightens as k grows, reaching ±1.3pt at k=10 in Japanese. Where the old metric could only say "any difference is within ±7pt", the repaired one produces cells that can judge differences of 1–2pt. Cells that actually cleared zero appear in the experiment sections.

Even a blunt metric could answer some questions. The question asked was "should this be replaced", and the old metric returned an upper bound: any difference is within ±7pt. For deciding whether to turn 37 MB into 199 MB, knowing the bound was enough to start with. But talking about a ranking needed the denominator fault fixed and the resolution raised. Fixed-pool precision and AUC do both.

The other way to raise resolution is to tag more articles. With all 420 tagged, the same-label pair count would grow considerably and the intervals would tighten further. AUC is wider than k=10 precision in English because resampling articles takes the full force of a 23-article sample's variance.

2 · Experiment 1: choosing a model

The models

All ONNX. Each ran from a single added config entry, with no code changes
ModelRoleDimSize poolingprefix
ruri-v3-30mJapanese, current256 37 MBmean"トピック: "
granite-small-english-r2English, current384 52 MBcls
bekko-v1-a8mmultilingual, candidate384 130 MBmean
bekko-v1-a25mmultilingual, candidate384 199 MBmean
granite-97m-multilingual-r2multilingual, candidate384 98 MBcls

Get the pooling wrong and nothing raises — the recommendations just get worse. ruri and granite-english ship their own sentence vectors inside the ONNX, so the right pooling was settled by measurement, matching ours against theirs. bekko and granite-multilingual ship none, so each was confirmed from its 1_Pooling/config.json.

2 · Experiment 1: choosing a model

Results

Models are judged with fixed-pool precision@k and pairwise AUC (how it looked under the old, model-dependent-denominator metric is kept later in this section for the record). Precision is checked for a consistent sign at topk 3 / 5 / 10. AUC does not depend on topk.

Experiment 1, 2,000-character input, hand tags. Scores in the fixed pool, candidates restricted to tagged articles (ja 95 / en 23). Small figures are hit counts (slots are ja 285/475/950, en 69/115/230). Colour marks the best in each column; the pale cell is one whose 95% interval against the current model clears zero on the wrong side
CorpusModel AUCP@3P@5P@10
jaruri-v3 + prefix (current) 0.8180.361
103
0.316
150
0.245
233
bekko-a8m 0.8210.368
105
0.326
155
0.249
237
bekko-a25m 0.8350.365
104
0.320
152
0.258
245
granite-97m-multi(fp32) 0.8130.351
100
0.314
149
0.256
243
same, int8 0.8090.368
105
0.309
147
0.243
231
engranite-small-english (current) 0.7590.449
31
0.426
49
0.335
77
bekko-a8m 0.7980.449
31
0.426
49
0.357
82
bekko-a25m 0.7910.478
33
0.443
51
0.339
78
granite-97m-multi(fp32) 0.7470.464
32
0.409
47
0.326
75
same, int8 0.6800.420
29
0.348
40
0.278
64
Per-language model (current) Multilingual candidate

Japanese — pairwise AUC (bars show the excess over chance, 0.500)

ruri-v3 + prefix
0.818
bekko-a8m
0.821
bekko-a25m
0.835
granite-97m fp32
0.813
granite-97m int8
0.809

English — pairwise AUC

granite-small
0.759
bekko-a8m
0.798
bekko-a25m
0.791
granite-97m fp32
0.747
granite-97m int8
0.680
Probability of ranking a same-label pair above a different-label one. 0.500 is chance. In both languages the two bekko models land at or above the current one, but no 95% interval clears zero.

At 2,000 characters the only cell clearing zero is granite-97m int8 in English, and in the wrong direction (P@5 −7.8pt [−13.9, −1.7], P@10 −5.7pt [−10.4, −1.3]). That is quantization damage, not the model's ability (see the appendix). The two bekko models overlap the current one in every cell of every language — though the point estimates lean one way: AUC gives a25m +1.7pt in Japanese and a8m +3.8pt / a25m +3.2pt in English, positive for bekko in all four comparisons.

"Equal, possibly slightly better, but this sample cannot say so" — that is the fixed-pool reading of experiment 1. Re-measurement under production conditions (8,000 characters), and the cells that do reach significance, come after "the ranking flips with the reading".

How it looked under the old metric (for the record)

What follows is the reading from before the denominator fault was found. The per-language model is the dashed baseline, with the three multilingual candidates over it. The numbers themselves are correct, but the ranking between models cannot be read from them.

Per-language model (baseline) bekko-a8m bekko-a25m granite-97m-multilingual(fp32)
Japanese, 420 posts · tag agreement higher is better
0.26 0.32 0.38 0.44 0.50 3 5 10 topk
English, 33 posts · tag agreement higher is better
0.26 0.32 0.38 0.44 0.50 3 5 10 topk
Japanese · max in-degree lower is better
0 20 40 3 5 10 topk
English · max in-degree lower is better
0 10 20 30 3 5 10 topk

Measured with hand-written tags / categories only.

The same data returns opposite rankings depending on how it is read. On Japanese agreement, the current ruri is best at topk=3 and 5; on recall, bekko-a25m is best at all three. Neither is the correct one — they differ in how the denominator is taken.

The denominator of agreement is "pairs that could be scored", and the model can move it. A model more inclined to recommend tagged articles gets a larger denominator and a lower rate. And indeed a25m always has more scored pairs than ruri (194 against 174, 307 against 271, 564 against 513). The denominator of recall is "correct answers available", which the corpus alone fixes and no model can move.

In English both readings point the same way. On agreement and on recall, the bekko models lead at topk=3 and 5, and the current granite-small-english is never best in any column. But there are only 58–199 scored pairs.

The int8 build of granite-97m-multilingual pushes Japanese in-degree up to 47 (the current ruri is 35). Quantization is covered in the appendix.

Experiment 1, 2,000-character input. Agreement is the reading whose denominator the model can move; recall is the reading with it fixed. In-degree is the maximum in-degree (lower is better). Colour marks the best value in each column
CorpusModel agreement k=3k=5k=10 recall k=3k=5k=10 in-degree k=3k=5k=10
ja 420 postsruri-v3 + prefix (current) 0.4890.4280.33341.938.341.7132035
bekko-a8m 0.4430.4050.33541.938.944.1152550
bekko-a25m 0.4540.4070.34043.341.346.8162745
granite-97m-multi(fp32) 0.4480.3840.32237.935.640.2182539
same, int8 0.4880.4050.31940.435.336.8233347
en 33 postsgranite-small-english (current) 0.4830.4470.36254.759.764.2101526
bekko-a8m 0.5080.4490.39656.657.169.891220
bekko-a25m 0.5250.4650.36058.559.764.281423
granite-97m-multi(fp32) 0.5000.4410.34756.658.465.181123
same, int8 0.4190.3560.29449.148.154.791221

3 · Experiment 2: input length

The effect of input length

All five models so far were compared under one fixed condition — embedding only the first 2,000 characters of the body. Experiment 2 varies what experiment 1 held fixed.

2,000 is the default for max_content_chars, put there to keep memory down. It was not chosen for quality. The first question is how much it throws away.

Body length as handed to the embedder, and how much a 2,000-character cut discards
CorpusMedianMax Over 2,000Body discarded
ja 420 posts7768,890 93 (22%)25% of the total
en 33 posts1,85415,581 15 (45%)55% of the total

The counting needs care. prelims strips HTML tags, code fences, math and URLs before embedding, so counting the raw Markdown badly overstates what the model sees. This corpus holds many posts migrated from Medium, heavy with HTML and URLs: on the raw files the Japanese median looked like 1,205 characters with 154 posts over 2,000. The table above is post-filter, and it matches the production log exactly (93 Japanese and 15 English posts re-embedded when the limit went 2,000 → 8,000).

English was embedding less than half of each body. With a median of 1,854 characters, longer posts were being compared on their introductions alone. The distribution makes it plainer.

Up to 2,000 characters — raising the limit changes nothing Over 2,000 characters — was being truncated
Japanese, 420 posts share of articles · 78% are under 2,000 characters
0% 10% 20% 30% 40% 0.5k 1k 2k 3k 4k 6k 8k 8k+ 2,000 Body length (characters)
English, 33 posts share of articles · 55% are under 2,000 characters
0% 10% 20% 30% 40% 0.5k 1k 2k 3k 4k 6k 8k 8k+ 2,000 Body length (characters)

78% of Japanese posts fit inside 2,000 characters. The largest bucket is under 500 characters, holding 150 of the 420. Raising the limit to 8,000 changes the input for only 93 posts. English has only 55% under 2,000, and 12% of posts run past 8,000.

That difference is the whole explanation for why the same change helped English and did nothing for Japanese. Only 1 Japanese post of 420 extends past 8,000 characters, so for Japanese, 8,000 is effectively "the whole article".

Input limit against tag agreement (hand tags only, %). The small figures are hits / scored pairs; colour marks the higher hit count
CorpusInput limit k=3k=5k=10
ja 420 posts2,00048.9
85/174
42.8
116/271
33.3
171/513
8,00049.1
86/175
42.3
116/274
34.3
177/516
en 33 posts2,00048.3
29/60
44.7
46/103
36.2
68/188
8,00051.7
30/58
50.0
48/96
36.8
70/190

This is the one result in the whole investigation that points the same way under either reading. Hits go +1 / ±0 / +6 in Japanese and +1 / +2 / +2 in English. Five of six cells positive, the sixth flat, and not one negative.

The reason this holds up where the model comparison did not is that both arms are the same model. Whatever tendency a model has toward recommending tagged articles is identical on both sides, so the denominator bias cancels. The metric breaks across models, not across settings.

Read as agreement, English looks like the bigger gain (+3.4 / +5.3 / +0.6 against Japanese +0.2 / −0.5 / +1.0) — but that is the English denominator shrinking (60→58, 103→96). By hit count both languages gain about equally.

The repaired metric points the same way. Fixed-pool hits and AUC, 2,000 → 8,000 (same model both sides; differences in pt, [ ] is the 95% interval)
Metricja 2,000ja 8,000Diff en 2,000en 8,000Diff
P@3 hits103105 +0.7 [−1.1, +2.5] 3131+0.0 [−5.8, +5.8]
P@5 hits150156 +1.3 [−0.2, +2.7] 4951+1.7 [−2.6, +6.1]
P@10 hits233240 +0.7 [+0.0, +1.6] 7780+1.3 [+0.0, +2.6]
AUC0.8180.826 +0.8 [−0.4, +2.6] 0.7590.767+0.8 [−2.3, +5.9]

All eight cells have a non-negative point estimate and not one comes out negative. No single cell reaches significance (the two at k=10 touch zero at the edge of the interval), but the same direction holding across metrics, across languages and across k is the strongest argument for this change. Unlike the model comparison, both arms are the same model, so the old metric's denominator bias largely cancelled too — this is the one experiment whose conclusion did not change when the metric was repaired.

8,000 it is. Both languages gain correct pairs and neither gets worse. Post-filter the cost is 1.33× the tokens and 2.5× the attention in Japanese; in English the absolute volume is small.

Past that point, unmeasured

While the measurement still ran on raw text it went up to 20,000 characters (effectively unlimited), and neither language moved beyond 8,000. That was not re-measured after filtering. But the longest post-filter article is 8,890 characters in Japanese and 15,581 in English, so what survives past 8,000 in Japanese is the tail of a handful of articles.

The two readings rank them in opposite orders

Take the discrepancy experiment 1 exposed and pin it down on the dedicated model against bekko-a25m. The same recommendations, scored two ways, with the difference (bekko − dedicated model) overlaid. The zero line is the dedicated model.

agreement-rate difference (denominator set by the model) recall difference (denominator fixed)
Japanese · bekko-a25m − ruri-v3+prefix 8,000-character input, pt, up favours bekko
+4 +2 0 −2 −4 3 5 10 topk
English · bekko-a25m − granite-small-english 8,000-character input, pt, up favours bekko
+4 +2 0 −2 −4 3 5 10 topk
bekko-a25m − dedicated model (pt, positive favours bekko). 8,000-character input
CorpusReading k=3k=5k=10
ja, 420 articlesAgreement −3.8−2.4+0.1
Recall +2.5+2.3+5.1
en, 33 articlesAgreement +3.3−0.5−0.8
Recall +5.7+2.6−1.9

In Japanese the sign flips completely. Agreement rate puts ruri ahead at two of the three k values; recall puts a25m ahead at all three. Same recommendations, same tags — the only difference is the denominator.

a25m finds more correct pairs (86→91, 116→123, 177→198). It also carries more scorable pairs (175→201, 274→308, 516→576). One property — a tendency to recommend articles that carry tags — shows up as a penalty in one formula and a reward in the other.

This comes from the referee's design. Of the 420 Japanese articles, 325 have no tags. Those articles are not "wrong answers", they are unjudgeable. Recommending one drops it out of the agreement-rate denominator, which helps; under recall it counts as something missed, which hurts. The metric's denominator moves with the recommendations — this is exactly the incomplete-relevance-judgment problem from information retrieval.

A reading with a fixed denominator (recall, or a hit rate against "articles × k") is immune to this. Within one corpus at one k these are just divisions by a constant, so they rank identically — meaning there are only two options, a metric whose denominator moves and one whose denominator does not. Mixing them, as an F-measure does, does not help: it inherits the bias of the half it took.

That is the diagnosis. The repair is two metric definitions — fixed-pool precision@k, which restricts candidates to tagged articles, and pairwise AUC, which drops the top-k cutoff entirely. The same embeddings, re-measured in a form that keeps the denominator out of the model's hands.

Re-measuring with the denominator fixed

Current model against bekko-a25m, 8,000-character input, hand-written tags, fixed pool. Differences in pt (positive favours a25m), [ ] is the 95% interval. Coloured cells exclude zero
CorpusMetric Currenta25mDiff [95% CI]
jaP@3 0.368
105
0.368
105
+0.0 [−3.5, +3.9]
P@5 0.328
156
0.318
151
−1.1 [−3.6, +1.3]
P@10 0.253
240
0.266
253
+1.4 [+0.1, +2.6]
AUC 0.8260.844 +1.8 [−2.3, +5.6]
enP@3 0.449
31
0.507
35
+5.8 [+1.4, +11.6]
P@5 0.443
51
0.461
53
+1.7 [−0.9, +5.2]
P@10 0.348
80
0.326
75
−2.2 [−6.1, +1.7]
AUC 0.7670.793 +2.6 [−5.8, +11.3]

The rank reversal is gone. Two of the eight cells exclude zero — English P@3 (+5.8pt; 3 is the topk actually in production) and Japanese P@10 (+1.4pt) — and both favour a25m. Not one cell favours the current model significantly. AUC also comes out positive for a25m in both languages (not significantly).

Two cautions against over-reading it. The sign of the point estimate wobbles with k (Japanese P@5 and English P@10 are negative). And across eight cells at 95%, chance alone can produce a cell that excludes zero. "a25m is equal or slightly ahead; there is no evidence the current model is ahead" — that is as far as the repaired metric goes, but it does go that far. Compared with the old metric's "either answer, depending how you read it", that is a large step.

Hub spread is consistently worse for a25m in Japanese (maximum times recommended 12→16 / 20→26 / 35→40). That quantity is unaffected by the denominator, so it can be read as-is. In English it moves either way depending on topk.

What the difference is made of — which axis do the tags point along?

To find out what a few points of difference actually refers to, I dumped every recommendation under the same conditions (8,000 characters, topk=3) and went through the pairs where both ends carry tags, one at a time. Most of the tag-score difference was decided not by which model was right, but by which axis the tags happened to point along.

"Things to check when moving homebrew breaks iRuby"categories: ruby

ruri → "Organising my own take on setting up a Python environment"categories: python

a25m → "We held kawasaki.rb #008"categories: ruby

"Using Markdown documentation on the new PyPI"categories: python

ruri → "Adding recommendations to Hugo articles with prelims"tags: NLP, Python, Recommendation

a25m → "Drop mecab-python3 for natto-py"tags: NLP

The first scores for a25m, the second for ruri. But both readings hold up. The iRuby article is as much about Ruby as it is about environment setup, yet the only label on it is ruby, so only the side that picked up that theme scores. The PyPI article is the same: both candidates are Python-adjacent, but the labels split across python and NLP, so a difference appears.

The labels on these six articles are split between categories and tags. The metric merges the two and lower-cases them, so the match still fires, but which field a given word ends up in is a matter of the writer's habit. The second match also crosses categories: python and tags: Python.

What the referee looks at is not "are these about the same thing" but "is the same word written in both tag lists". When an article has several axes, the tags record only one of them. As long as hand-written tags are the ground truth, this distortion is unavoidable.

Under the old metric, finding more correct answers could even score lower. On "I spoke at the Machine Learning Systems Engineering kickoff symposium", ruri hit 1 of 1 scorable recommendation while a25m hit 2 of 3. a25m has more hits, but ruri wins on ratio. The fixed pool cannot produce that inversion — both models divide by 3.

As far as the eye can tell, neither model produces broken recommendations. The difference is a habit in what they pick up across a few dozen articles — entity detection for a25m, topic purity for the current model — and aggregating that into a verdict puts it at a scale indistinguishable from chance. Whether that reading is correct is tested systematically in the next section.

4 · Human evaluation

Checking the proxy metric's answers

Repairing the metric did not settle the ranking. What is actually still open is a question one step earlier — how much of a reader's sense of "related" does this proxy metric capture at all? That cannot be answered from inside the metric, so I judged the recommendations themselves by hand and compared.

The subjects are the 389 articles whose recommendations changed. I wrote a dedicated judging tool that puts the two recommendation lists side by side and lets you tick the ones you consider genuinely related. Three things mattered in its design.

  • Blind by default. Which column is which model is shuffled per article and hidden, and tags and tag-hit marks are hidden too. The tags are the very metric being used to pick a model, so judging with them visible would mean validating the metric with the metric itself
  • Each recommendation is judged once. Where both models recommend the same article, ticking one side applies to the other. No pair can carry contradictory labels
  • Strata are not pooled. The 162 judged articles centre on a seed-fixed random 40 and the total-swap group (zero shared recommendations — the 64 articles where the models disagree most). All 19 changed English articles were judged. The total-swap group was selected to maximise the difference, so it cannot be used for an overall estimate. The unbiased estimate is the random 40 alone

162 articles / 827 pairs judged (143 Japanese, 19 English), 458 recommendations marked related, 19 articles marked "none of these are related". One judge, the author.

Result 1 — the model difference still cannot be detected

precision@3 under human judgment (share of recommendations judged related). The random 40 is the unbiased estimate; the other strata are reference values
StratumnCurrenta25m Diff95% CI
Random 40
unbiased
40 0.758
91/120
0.775
93/120
+1.7[−9.2, +12.5]
Total swap
maximum-difference group
57 0.439
75/171
0.497
85/171
+5.8[−4.7, +16.4]
Other65 0.615
120/195
0.621
121/195
+0.5[−5.6, +6.7]
All judged
mixed, reference
162 0.588
286/486
0.615
299/486
+2.7[−2.5, +7.8]

a25m is non-negative in all four strata and the current model leads in none of them. Per article it is 46 wins for a25m, 36 for the current model, 80 ties (sign test p = 0.32). The direction is consistent, yet every stratum's interval spans zero. The +1.7pt from the unbiased random 40 is, in counts, 2 more hits out of 120 slots.

And judging established that more judging would not settle it. The standard error on the random sample is 5.5pt. Holding the effect size constant, the interval as the sample grows looks like this.

n=100 → [−5.2, +8.5] / n=200 → [−3.2, +6.5] / n=389 (every changed article) → [−1.8, +5.1]. Making +1.7pt significant would take roughly 1,700 articles, and only 389 articles changed at all. On this corpus, judging everything by hand still would not rank them.

In English, the one "significant" cell did not replicate. The repaired metric put a25m ahead with an interval clear of zero in exactly one place in this report — English fixed-pool P@3, +5.8pt [+1.4, +11.6]. Judging all 19 changed English articles by hand gave 40/57 against 40/57, a difference of +0.0pt [−14.0, +14.0] — an exact tie. Per article, a25m 7 wins, current 6, 6 ties.

English has only 23 tagged articles. The most straightforward reading is that a cell reaching significance at that scale reflects a difference in the narrow slice the metric can see, not a difference a reader would notice. Human judgment cancelled the only "significant" result in the study.

Result 2 — the proxy metric saw only 9% of relatedness

This is the real point. Take the 827 judged pairs and evaluate tag agreement as a predictor of the human labels, and what the metric was measuring comes out as a number.

Tag agreement against human judgment (827 judged pairs)
Human: relatedHuman: unrelated
Tag hit 4013
Tag miss 418
missed
356

Precision 0.755, recall 0.087. If the tags agree, a human almost always calls the pair related — that direction is trustworthy. But of the 458 recommendations a human called related, the tags found 40.

The reason is simple: 86% of judged pairs (714/827) have no tag on at least one end and were invisible to the metric from the start. And in that invisible territory, the human marked 392 pairs related.

Split by language, the same metric turns out to break in two different ways.

Tag agreement as a predictor of human judgment, by language
CorpusPairsRecallPrecision One end untagged
ja745 0.0610.86291%
en82 0.2940.62546%

Japanese is "right when it fires, almost blind otherwise" (precision 0.86, recall 0.06). English is "sees a fair amount, but firing does not mean right" (recall 0.29, precision 0.63). The tagged share differs — 23% (ja) against 70% (en) — and where tags are dense, tag agreement turns loose enough to pick up 9 pairs a human called unrelated. Tilt it either way and the proxy still does not stand in for a human.

The recommendations are far better than the metric suggested. On the random 40, about 76% of both models' recommendations were judged sound. The same recommendations score about 0.37 under fixed-pool precision@3. The low number was not bad recommendations, it was thin ground truth.

This asymmetry decides how the experiments should be read.

  • "A recommendation whose tags agree is sound" — this direction is broadly safe (precision 0.755)
  • "A recommendation whose tags disagree is unsound" — this direction is flatly wrong (recall 0.087)
  • And the model comparison rode on the second inference. Almost all of the difference between the two models happens in untagged territory, and the metric was not looking there at all

So the numbers in experiments 1 and 2 were not "broken": they measured a narrow slice of relatedness, accurately, as that slice. The slice was simply too narrow to speak about which model is better.

Differences the metric cannot see — some examples

What happens outside the tags shows up in the individual cases I read while judging. a25m's winning pattern is detecting the same entity, which is the kind of match that essentially never appears in a tag.

A new Real Group album write-up → a25m lines up two reviews of older albums by the same artist. The current model returned a live announcement for a different artist.

A review of Ira Ishida's "Utsukushii Kodomo" → a25m lines up "Akihabara@DEEP" and "Ai ga Inai Heya" by the same author. The current model matched the author once.

The Kawasaki Ruby Kaigi 01 report → a25m puts the sister event, Kanagawa Ruby Kaigi, at the top. The current model returned the monthly meetup reports.

Artist names, author names and event names are not tags, so none of this scores under any metric. The failure runs the other way too — on the MeCab article a25m mixed in a Thinkpad migration post, while the current model kept it to NLP articles. The current model's winning pattern is topic purity.

Neither habit is broken. Which one you prefer is a design choice, not something measurement decides — which is also how it felt after reading 162 articles.

Result 3 — so should the choice be made per article?

If the habits differ, the next question follows naturally: maybe "this article wants this model" is a real thing. If it is, a mechanism that picks the better model per article would beat both.

But a win-loss table cannot answer that. Even two evenly matched models will trade wins and losses when they recommend different articles, so "there are wins and losses per article" is not evidence of structure. The question is whether those wins are predictable. Three tests.

Testing "the better model is determined by the article". All three point towards a25m; none is significant
TestQuestionResultp
Split-half reliability Does the winner at rank 1 agree with the winner at ranks 2–3? r = +0.1470.082
Stratified (by year) 2005–12 / 2013–19 / 2020– +1.4 / +3.6 / +11.1pt0.301
Oracle Does taking the per-article winner gain more than noise would? +9.5pt
noise alone +7.9
0.076

The oracle row is the clearest. Taking the per-article winner raises 0.603 to 0.698 (+9.5pt). But "pick the winner" is an operation that, on two perfectly equal models, still earns +7.9pt [+5.7, +10.0] just by skimming noise. The observed +9.5pt merely sits at the edge of that distribution — any structure there is about the same size as the noise.

The trend by year — a25m doing better on newer technical articles — lines up neatly in the point estimates, but 2020 onwards is n=15 with an interval of [−6.7, +26.7]. The correlation with year itself is r = +0.086, p = 0.301, so the neatness of the ordering is explained by how thin the samples are.

All three point the same way and all three fall short of significance. This is as far as it goes — the difference in habits is reliably observable, but "this article wants this model" could not be shown to be predictable. This data gives no grounds for building a per-article switch.

These are two different claims. The first is visible by eye in every diff; the second is a claim about predictability, and cannot be made without a test. This time the test did not pass.

In the end it comes down to which one you prefer. Neither measurement nor inspection produces a ranking, and switching per article is not supported. What is left is a preference about which habit you want on your own blog.

5 · Discussion

The two multilingual candidates, compared

Consolidating onto one model means choosing between the two multilingual candidates. And what mostly matters is what happens when an article is written in a language other than Japanese or English — exactly what was not measured. Beyond the two languages measured here, that means leaning on published coverage information.

Both were trained on a wide set of languages. bekko was trained on data covering more than 100 languages so that it can search across languages, retrieving English documents from a Japanese query without a translation step. granite's base encoder is pre-trained on more than 200 languages. The difference is not the training range but how far the per-language numbers are published.

The 14 languages bekko evaluates individually

Not the limit of the training range but the range with published measured scores. Measured on English NanoBEIR plus community-published translations into 13 languages.

English · Spanish · Italian · Portuguese · German · Vietnamese · Japanese · French · Swedish · Norwegian · Korean, and the bottom three, Arabic · Serbian · Thai.

The 52 languages granite-97m-multilingual lists as enhanced-support

The base encoder is pre-trained on more than 200 languages, so a general-purpose embedding is available for any of them. On top of that, quality is claimed for the 52 languages given explicit retrieval-pair and cross-lingual training data.

Quoted as written on the model card.

sq  Albanian     ar  Arabic       az  Azerbaijani  bn  Bengali
bg  Bulgarian    ca  Catalan      zh  Chinese      hr  Croatian
cs  Czech        da  Danish       nl  Dutch        en  English
et  Estonian     fi  Finnish      fr  French       ka  Georgian
de  German       el  Greek        he  Hebrew       hi  Hindi
hu  Hungarian    is  Icelandic    id  Indonesian   it  Italian
ja  Japanese     kk  Kazakh       km  Khmer        ko  Korean
lv  Latvian      lt  Lithuanian   ms  Malay        mr  Marathi
no  Norwegian    fa  Persian      pl  Polish       pt  Portuguese
ro  Romanian     ru  Russian      sr  Serbian      sk  Slovak
sl  Slovenian    es  Spanish      sw  Swahili      sv  Swedish
tl  Tagalog      te  Telugu       th  Thai         tr  Turkish
uk  Ukrainian    ur  Urdu         uz  Uzbek        vi  Vietnamese

It is also trained on Python · Go · Java · JavaScript · PHP · Ruby · SQL · C · C++ code, and claimed to support cross-lingual code search.

The declarations are at different granularities, so breadth of coverage cannot rank them. granite's 52 is a list of languages given explicit retrieval-pair and cross-lingual data, not per-language scores. bekko's 100+ is the set of languages included in training, also not per-language scores. Neither publishes anything in the shape of "this language will be fine".

There is exactly one real difference: whether per-language measurements are published. bekko has numbers for 14 languages. granite publishes no per-language figures. As material for choosing what to consolidate onto, those 14 languages are the only thing resembling evidence.

Position on public benchmarks

The two models are compared directly in bekko's announcement post. Not per language, but it is the only third-party measurement available, so here it is.

Source: excerpted from the Bekko Embedding announcement. Coloured is ahead
Modelactive params MMTEB (ALL)MMTEB (Ret)MNanoBEIR NanoRTEBNanoCoIRNanoLongEmbed
bekko-a25m24.9M 58.357.554.9 59.478.670.6
granite-97m-multilingual28.3M 51.960.350.5 56.778.065.9
bekko-a8m7.67M 56.756.252.6 55.074.768.2

bekko-a25m leads on five of the six entries; granite leads only on MMTEB's retrieval task (60.3 against 57.5). a25m also has fewer active parameters (24.9M against 28.3M).

Related-article recommendation deals in symmetric similarity between articles, so MMTEB (ALL) — the average over all 131 tasks — is closer to it than the query → document retrieval task. There the gap is 58.3 against 51.9, 6.4pt. But this is not a per-language number, so it does not answer "which is better in a specific language that has no dedicated model".

Thai, Serbian and Arabic are at the bottom. Having adopted it, recommendations for articles written in those languages need to be discounted accordingly.

bekko-a8m does not beat bge-m3 consistently — bge-m3 is ahead in Vietnamese, Serbian, Thai, Korean and Swedish. a8m's selling point is efficiency, holding its own at 1/41 the active parameters, not absolute quality. a25m beats bge-m3 in nearly every language.

On the granite side, IBM itself writes that "pruned models show more pronounced degradation on low-resource languages". The fallback is used mainly for low-resource languages, so that lands squarely on the use case. Losing to a25m by 4–5pt even in Japanese — a high-resource language — looks consistent with that note.

5 · Discussion

Conclusion

Consolidate onto one multilingual model, and change the input length too. The verdict flipped twice during the investigation. Before the metric was repaired: "no quality improvement large enough to justify replacing". After the repair: "quality is equal or marginally better, but the difference does not cover the added cost". Then, after human evaluation and measuring the cost for real: "quality is equal. The ranking cannot be settled in principle, and switching per article is not supported. What is left is preference and cost — and the cost turned out smaller than expected" — that is the final form.

ja   → bekko-embedding-v1-a25m    199 MB  changed (was ruri-v3 + "トピック: ")
en   → bekko-embedding-v1-a25m    199 MB  changed (was granite-small-english)
rest → runs on the same model

max_content_chars   2,000 → 8,000         changed
  • Quality dropped out of the decision. In the fixed pool, Japanese P@3 is tied, a25m is +1.4pt at P@10, AUC +1.8pt (n.s.). Under human judgment the random 40 gives +1.7pt [−9.2, +12.5], and per article 46 wins to 36 with 80 ties (p = 0.32). All 19 changed English articles were judged and came out an exact tie (40/57 against 40/57). a25m is non-negative in every stratum and the current model leads in none, yet not one result reaches significance. And judging all 389 would still not reach it (see "Human evaluation"). What cannot be measured cannot ground the decision
  • The deciding factor is a preference between habits. a25m picks up the same entity — same artist, same author, sister event — while the current model keeps topic purity. The case for switching per article was rejected by the tests (Result 3), so it comes down to choosing one habit. For a personal blog, I judged that entity connections — which appear in neither the tags nor the metric — matter more to a reader
  • The cost was measured and it was small. On the real GitHub Actions run, 3m29s on the first run (re-embedding all 452 articles, model download included). That breaks down as 3m8s embedding and 17s download, so 199 MB matters less than the number suggests — with 25M active parameters in a MoE, an article takes about 0.4s. Later runs only touch new articles, so the steady- state cost is unchanged. I also confirmed that running the same weights twice produces byte-identical recommendations
  • A side benefit. Switching by detected language disappears and the configuration gets simpler. The same model runs for languages other than Japanese and English — the multilingual coverage that motivated this in the first place, obtained without giving up quality
  • max_content_chars from 2,000 to 8,000. This is the most solid result in the investigation. All eight fixed-pool and AUC cells have a non-negative point estimate; none is negative. Both arms are the same model, so the old metric's flaw does not touch it. It goes in on "a small gain, and no loss if it misses"
  • The corpus length distribution should have been checked before touching the input length. The input actually changed for 93 of 420 Japanese articles and 15 of 33 English ones. For the rest, raising the cap changes not one character. What a setting can reach is decided by the corpus, not the model
  • Quality outside Japanese and English is adopted still unverified. What was measured is Japanese and English, both languages that have a dedicated model. How a25m behaves elsewhere is unknown — but this is still better than before. For a language with no dedicated model the only option used to be reusing the English model, and on the probes the separation margin outside its range all but vanished: 0.139 for French, 0.055 for Japanese. An unverified multilingual model is the better bet against a dedicated model known to be out of range
  • The rival, granite-97m-multilingual, was dropped. Only fp32 is usable (the int8 build collapses in English, AUC 0.747 → 0.680), and even at fp32 its English AUC is below a25m's, with a25m ahead on five of six public-benchmark entries

It is worth recording that three of the four experiments leading to this conclusion misfired. Experiment 1 (model comparison) had its ranking inverted by a flaw in the metric and did not reach significance after the repair either. Result 3 (per-article switching) was rejected. Human evaluation could not rank them either. What decided it was not measurement but measured cost and preference.

Measuring was still worth it. Because an upper bound — "whatever the difference is, it is within this range" — could be drawn, it became coherent to drop quality from the decision and choose on preference. Measurement could not pick a winner, but it showed that no winner had to be picked.

On proxy metrics

The first metric could answer a coarse question. Asked "should this be replaced", it returned "no improvement large enough to justify replacing" — enough, if all you want is an upper bound. It broke one step finer down, at the moment "which model is better" was to be decided on a few points of difference: the design that lets the model choose what gets scored surfaced, and the ranking inverted depending on how you read it.

The repair took two operations. Take the denominator away from the model (fix candidates to tagged articles), and drop the cutoff and use the whole ranking (pairwise AUC). Both are standard responses to incomplete relevance judgments in information retrieval, not new inventions. After the repair the intervals shrank from ±5pt to the 1–4pt range and, for the first time, a cell came out clear of zero. Fixing a metric's design did as much for resolution as adding data would have.

And the flaw that remained was invisible from inside the metric. The repaired metric had a fair denominator but still saw only 9% of relatedness (see "Human evaluation"). Precision is a relatively high 0.755, so "a tag hit means it is sound" holds — yet the difference between the models happens almost entirely in untagged territory, outside what was being measured. A correctly designed metric was correctly measuring a narrow slice — that is the final diagnosis.

And not one of the errors found in this investigation was found by staring at the metric.

  • The referee included TF-IDF-derived terms — found by swapping the referee
  • Measurement ran on different text than production — found by checking the numbers against the production run log
  • The ratio was being dragged by the size of the denominator — found by reading individual articles one at a time
  • That denominator effect was manufacturing the apparent conclusion about input length — found by decomposing the rate into hits and denominator
  • A model-movable denominator was a flaw in the design itself — confirmed when the same data under two readings inverted the ranking
  • Even after the repair the metric saw only a fraction of relatedness — only revealed by judging the recommendations themselves by hand

Every one came from outside the metric, or from setting metrics against each other. A proxy metric cannot be validated on its own. A proxy breaks not only on "what counts as correct" but on "who decides the denominator", and once fixed its limit is set by "how much of the field it sees". Even after writing a note on the third one saying "this has a side effect", it took a while to notice that my own conclusion was built out of that side effect. A note is not absolution — the error sat in the conclusion until the design was fixed.

If you are building a proxy metric, take a sample of human judgments first, small is fine, and evaluate the proxy as a predictor of it — cheap insurance. Doing that last is why I carried the misreading "low metric = bad recommendations" for so long. In fact 76% of the recommendations were sound.

The biggest finding here did not come from the metric either. The reason input length matters in English and not in Japanese lies in the corpus length distribution — 327 of 420 articles do not reach 2,000 characters. The metric only confirmed it; the explanation was on the data side.

5 · Discussion

Where this conclusion does not reach

What this investigation measured is two languages, Japanese and English, both high-resource, both with a dedicated model available. A fallback is needed precisely for languages that meet neither condition. That asymmetry is why the conclusion calls a25m the "first choice" and stops short of declaring it verified.

To go further, the inference would have to be assembled like this.

  1. Public benchmarks give a relative ordering across several languages (third-party measurement)
  2. This corpus confirms, at two points only, that the ordering also holds for related-article recommendation
  3. Extrapolate: for an unmeasured language, if its score in (1) is comparable to Japanese, (2) should be comparable too

(3) is the leap. The distance being crossed is not just "a different language" — it crosses the resource-volume axis. Low-resource languages are where models lean on cross-lingual transfer, and where every model degrades most. IBM's note about granite, "pruned models show more pronounced degradation on low-resource languages", is about exactly this territory.

Where extrapolation might hold, and where it does not. On the public benchmark Japanese sits mid-pack among the 14 languages (about 54.5, against a high of 61 and a low of 47.5), so bekko is not simply good at Japanese in particular. Extrapolating to languages at a similar level — Italian, Portuguese, German, Vietnamese, French — is reasonably plausible. Thai (47.5), Serbian (49) and Arabic (49.5), 5–7pt below Japanese, are the opposite: two measurements support nothing about them.

There is only one way to find out. Take a few dozen articles in the target language and run the same procedure. Nothing in this toolchain is language-specific, so the same metrics apply as soon as a corpus exists. With parallel translations you could also see whether the recommendation set holds across languages. chezo.uno has only Japanese and English, so that is not possible today.

As for other limits: with hand-written tags as the referee, the writer's tagging habits go straight into the metric. Depending on topk there are only 58–536 scorable pairs. And this is two languages on one blog — every difference reported here is a difference within that scope.

Human evaluation has limits of the same kind. There is one judge, the author, and no second judge to measure agreement against. Blinding (model names, tags and tag-hit marks all hidden, left and right shuffled per article) cut off bias towards a model, but the standard for "related" still rests on the author's sense of it. 162 of the 389 changed articles were judged: a sample in Japanese, the complete set in English.

Appendix

The effect of quantisation

ruri and granite were measured on int8 model_quantized.onnx, bekko on fp32 model.onnx (bekko's author does not recommend the quantised build). To check whether that favoured bekko, all three models were run at both int8 and fp32.

Quantisation seen through the repaired metric. The hit difference is fixed-pool fp32 − int8 (positive means quantisation was losing hits). The two production models are measured at the production 8,000 characters; the candidate granite-97m at 2,000, as in experiment 1
ModelCorpus AUC int8 → fp32 Hit diff k=3k=5k=10 Max recommended k=10
ruri-v3-30m
production
ja 0.826 → 0.832 +2−2±0 35 → 35
granite-small-english
production
en 0.767 → 0.763 ±0−1+2 25 → 23
granite-97m-multilingual
candidate
en 0.680 → 0.747 +3+7+11 21 → 23
ja 0.809 → 0.813 −5+2+12 47 → 39

Quantisation does not break the two production models. The AUC differences are ruri +0.6pt [−0.2, +1.5] and granite −0.4pt [−3.2, +3.2], both spanning zero, and the fixed-pool hit difference stays within ±2. int8 is fine as it is — no reason emerged to take ruri to 120 MB or granite to fp32.

The only thing that broke was the candidate, granite-97m-multilingual, and the damage lands differently per language. In English the AUC collapses 6.7pt, 0.747 → 0.680, and in the fixed pool it produced the only cell in any model that falls significantly short of the current model (experiment 1). In Japanese the AUC barely moves, yet hub concentration is clearly worse at int8 (maximum times recommended 47 against 39). The same quantisation shows up as ranking quality in English and as hub concentration in Japanese.

This model is a pruned build of the 311m (22 layers → 12), and the natural reading is that with the redundancy already cut there is nothing left to spend on quantisation. Quantisation tolerance is a model-specific property; measuring it on one model does not generalise to another. In this very investigation three models came through untouched and one broke.

So granite-97m-multilingual is compared using its fp32 numbers. Listing its int8 figures as "this model's performance" would mistake quantisation damage for model quality.

As a by-product, it also became visible that the tag referee is remarkably insensitive to recommendations changing. Between ruri int8 and fp32 the recommendations change for 319 of 420 Japanese articles, yet fixed-pool hits move by 2 and AUC by 0.6pt. A largely different set of recommendations can be at the same level as far as tags can measure.

Appendix

Reproducing this

All of it lives in prelims-cli's scripts/.

  • check_onnx_model.py — the ONNX inputs and outputs, whether the required inputs are satisfied, the output rank, pooling pinned down against the model's own sentence vectors, and the probe separation margin
  • compare_embedding_variants.py — runs the corpus under two settings and reports the diff and the metrics. Alongside tag agreement and hub concentration, it reports fixed-pool precision@k and pairwise AUC with paired-bootstrap 95% intervals (--bootstrap, default 1,000 resamples, fixed seed). --vary config compares any two models, and --cache-dir reuses embeddings so a topk sweep costs one run
  • rerun_all.sh — re-runs every comparison in this report (models × 2 languages × 3 topk levels, input length, quantisation, the referee swap, per-article diffs) in one command and leaves the output in a file. Every number in this report comes from that output
  • judgments/ — the human evaluation labels (162 articles, 827 pairs) and the scoring script. The stratified precision@3, the bootstrap intervals and the confusion matrix against the tag metric all reproduce from it
bash scripts/rerun_all.sh          # re-run every comparison (minutes, with a warm cache)

# to run a single comparison:
python scripts/compare_embedding_variants.py ../chezo.uno/content/post \
    --language ja --permalink-base /post --topk 5 --vary config \
    --tag-keys tags categories --cache-dir ~/.cache/prelims-compare \
    --a "language=ja,prefix=トピック: " \
    --b "model_name=hotchpotch/bekko-embedding-v1-a25m,\
model_file=onnx/model.onnx,pooling=mean"

The embedding cache is keyed on article body text alone, so fixing front-matter tags leaves the cache warm. That property is why re-measuring after fixing the referee (the tags) takes minutes.