A multilingual RAG pipeline breaks in a way that looks like a model problem and is a layout problem. The Elasticsearch standard analyzer applies no stemming and sets stopwords to none, Weaviate's inverted index defaults its stopword preset to en, and PostgreSQL falls back to default_text_search_config, so the sparse leg of a hybrid pipeline that was never told about German contributes close to nothing while dense retrieval quietly covers for it. The gaps run engine deep: Elasticsearch ships 36 predefined stopword language lists with no Polish, PostgreSQL 17 ships 28 snowball dictionaries with no Polish, Chinese, Japanese, Korean or Thai, and the stemmer list behind Qdrant's text index has 19 languages and also no Polish. Three layouts are on the table, a shared index with a chunk-level language field, one index per language behind a router, or a translation hop, and only the first two leave the cited span in the language somebody actually signed. Default to the shared index with per-language text indexes on it, because a language predicate correlates with the query rather than cutting across it. Public benchmarks will not settle it for you: MIRACL is an 18-language challenge with 16 publicly released corpora and German is not one of them. Slice every retrieval number by language before you touch the embedding model, and start this week by running the retriever's own tokenizer over the same clause in each of your languages to see which version hits the 512-token ceiling first.
A multilingual RAG pipeline has one property a monolingual one does not: the partition key of the corpus is a natural language, and the layers under the retriever already have an opinion about which. The Elasticsearch standard analyzer applies no stemming and ships stopwords at _none_. Weaviate's inverted index defaults its stopword preset to en. PostgreSQL's to_tsvector falls back to default_text_search_config when you omit the config argument. None of the three was told about your corpus: the first does no language processing at all, the second is English, the third is whatever the server was set to, and all three stay that way until somebody changes them.
So a language problem presents as a model problem. Retrieval returns k results at normal latency with the wrong documents, the team swaps the embedding model, and the sparse half of the hybrid had already stopped contributing on non-English text without logging it.
Article 70 of the Swiss Federal Constitution makes German, French and Italian the official languages of the Confederation, and Canada's Official Languages Act gives English and French equality of status in all federal institutions. When the corpus is multilingual by statute the mix will not shrink, so this post decides one question: how to lay out the retrieval index.
Three failure shapes that look identical from the retrieval log
All three return k results at normal latency with the wrong documents. From the log they are one bug. They are three.
The query language differs from the document language. A claims handler asks in German about a clause that exists only in French. This is what the LAReQA benchmark isolates: strong alignment, defined there as requiring semantically related cross-language pairs to be closer in representation space than unrelated same-language pairs. Without it, an unrelated German passage outranks the correct French one.
One index holds several languages at once. A study published in April 2026 on multilingual RAG reports that current systems suffer from a language bias during reranking, systematically favouring English and the query's native language, and suppress answer-critical documents in other languages.
A single document code-switches inside a page. A bilingual contract, or a German memo with a French annex. A document-level tag is right for part of the file and wrong for the rest, and the wrong half gets the wrong analyzer and token accounting.
Option A: one shared index, and the same-language pull
The default is one index with the language stored as a chunk-level payload field, right for two to five languages. Filter on that field when the query language is known, drop it when it is not.
A language predicate is the friendly case for filtered vector search: a German query is asking about German documents, so the filter correlates with the query rather than cutting across it, which is what separates a harmless filter from one that guts recall on an HNSW graph. The shared-versus-split question underneath is the tenant argument in the silo, pool and bridge tradeoff, with language substituted for tenant.
The tenant analogy stops at the encoder. LAReQA found that the baseline best on its strong-alignment task falls short of competing baselines on zero-shot variants targeting only weak alignment, so a model chosen on bitext-style scores has not been shown to rank a cross-language answer above a same-language distractor.
A shared index does not force one analyzer on the sparse leg. Qdrant attaches a text index per field:
PUT /collections/policies/index
{
"field_name": "body_de",
"field_schema": {
"type": "text",
"tokenizer": "multilingual",
"lowercase": true,
"min_token_len": 2,
"max_token_len": 30,
"stopwords": { "languages": ["german"] },
"stemmer": { "type": "snowball", "language": "german" }
}
}Swap german for polish in both the stopwords and the stemmer and the stemmer language does not exist. That gap is what pushes a shared index into a split one.
Option B: index per language plus a router, and what the router costs you
Splitting by language buys per-language analyzers with nothing to configure and evaluation slices for free, because the index boundary is already the slice boundary. The price is a language-detection step in front of every query, and it is the weakest component in the design.
Detection at ingest is easy, because you have a whole document. fastText publishes two language-identification models, lid.176.bin at 126MB and lid.176.ftz at 917kB, both recognising 176 languages under a Creative Commons Attribution-Share-Alike 3.0 licence. Elasticsearch ships lang_ident_model_1, called from an ingest pipeline:
PUT _ingest/pipeline/detect-language
{
"processors": [
{
"inference": {
"model_id": "lang_ident_model_1",
"inference_config": {
"classification": { "num_top_classes": 5 }
},
"field_map": {}
}
}
]
}num_top_classes at 5 is the part worth keeping: one label says nothing about confidence, while a five-class distribution shows when a page is mixed, because the runner-up then sits close to the leader.
Query time is a different problem behind the same API. A character-ngram classifier looking at a two-word query has very little signal, and the failure is silent: a wrong route sends the query to an index holding nothing relevant, and the pipeline returns k results at normal latency with no error. Fan-out plus a fused rank restores recall, and gives up the routing saving the split was supposed to buy.
Option C: translate at ingest or translate the query, and what it does to the citation
Translating everything into a pivot language is the cheapest way to reuse an English-tuned stack, and the one layout that damages something you cannot repair downstream.
Retrieval quality points the same way. A November 2025 evaluation of cross-lingual ranking tested four intervention types across three benchmark datasets and concluded that dense retrieval models trained for cross-language information retrieval consistently outperform lexical matching and derive little benefit from document translation. Machine translation has a documented place upstream of retrieval: LAReQA found that augmenting training data via machine translation improved significantly over using mBERT out of the box. Translating training pairs is not translating a corpus.
The citation cost is mechanical rather than measured, and it ends the argument in a regulated deployment. If you index translations, the span the generator quotes lives in a derivative nobody signed, and a regulator asking for the clause behind a decision cannot be handed one. Each chunk then carries two artefacts, the text it ranked on and the original span it must display, and the audit trail records which translation model version produced the derivative. That is citing sources correctly, one level harder.
The sparse half of hybrid search is English by default
The fusion machinery, normalisation, weighting and reciprocal rank fusion, is covered in combining dense and sparse retrieval. This section is about the input to the sparse leg, where language decides whether that leg contributes at all.
"invertedIndexConfig": {
"bm25": { "k1": 1.2, "b": 0.75 },
"stopwords": {
"preset": "none",
"additions": ["der", "die", "das", "und", "oder", "nicht"],
"removals": []
}
}PUT /policies-de
{
"settings": {
"analysis": {
"filter": {
"de_stop": { "type": "stop", "stopwords": "_german_" },
"de_stemmer": { "type": "stemmer", "language": "light_german" },
"de_decompound": {
"type": "hyphenation_decompounder",
"hyphenation_patterns_path": "analysis/de_hyph.xml",
"word_list_path": "analysis/de_word_list.txt",
"min_subword_size": 4,
"only_longest_match": true
}
},
"analyzer": {
"policy_de": {
"tokenizer": "standard",
"filter": [
"lowercase",
"de_decompound",
"de_stop",
"german_normalization",
"de_stemmer"
]
}
}
}
},
"mappings": {
"properties": {
"body": { "type": "text", "analyzer": "policy_de" },
"language": { "type": "keyword" }
}
}
}Nine rows, and Polish carries the argument: Elasticsearch needs a plugin, PostgreSQL has no dictionary, Qdrant has no stemmer. If Polish, Japanese, Korean or Chinese is in your corpus, that language's sparse leg is a platform decision, not a tuning task.
The defaults are English or nothing, and they are quiet about it
The Elasticsearch standard analyzer tokenizes on Unicode Standard Annex #29, takes max_token_length at 255 and stopwords at _none_, and applies no stemming. An index left on it stems nothing and removes no stopwords in any language, English included. The stop token filter, where the language lists live, defaults stopwords to _english_ and ships 36 predefined lists. Weaviate's invertedIndexConfig carries a stopwords object whose preset defaults to en, with en and none the only acceptable values, plus additions and removals arrays, and a bm25 object with k1 at 1.2 and b at 0.75. A German collection created with defaults strips English function words from German text and leaves every German one in place. There is no German preset, so turn it off and supply a list: Those six entries are a starting point, not a German stopword list, and Weaviate errors if an item appears in both additions and removals. PostgreSQL is quieter still: to_tsvector has the signature to_tsvector([ config regconfig, ] document text) returns tsvector, and omitting the config argument means the server uses default_text_search_config. That call is not language-neutral. It is whatever the server was set to.
Compounds, and the languages your engine does not ship
German makes the gap visible, because the language forms compounds and one token can hold the term a user searched for without matching it. Elasticsearch's built-in german analyzer is the chain lowercase, german_stop, german_keywords, german_normalization, german_stemmer, where german_stop is a stop filter carrying "stopwords": "_german_" and german_stemmer is a stemmer filter carrying "language": "light_german". It stems and normalises, and still does not split compounds. Two decompounder filters do. dictionary_decompounder takes word_list or word_list_path (one required), max_subword_size 15, min_subword_size 2, min_word_size 5 and only_longest_match false, and its documentation says it was built for Germanic languages. hyphenation_decompounder takes the same size parameters plus a required hyphenation_patterns_path pointing at an Apache FOP XML file, v1.2 compatible only. Elastic recommends the hyphenation variant in most cases, and the dictionary one for checking word-list quality first. Both paths are illustrative: you supply the FOP patterns and the word list, and that list is the quality of your German retrieval. Polish is where this stops being tuning. It is absent from the 36 stopword lists and has no built-in analyzer. Support comes from analysis-stempel, one of seven core analysis plugins, none installed by default, alongside analysis-icu, analysis-kuromoji, analysis-nori, analysis-phonetic, analysis-smartcn and analysis-ukrainian. Stempel provides the analyzer polish and the filters polish_stem and polish_stop, documented as not configurable, and installs with sudo bin/elasticsearch-plugin install analysis-stempel. Qdrant's text payload index takes tokenizer (word by default, plus whitespace, prefix and multilingual), lowercase, min_token_len, max_token_len, stopwords, stemmer, phrase_matching and ascii_folding. The multilingual value is documented, which is not the same as being present in the build you deployed. The ceiling is the stemmer: the library the docs point to exposes 19 languages, and Polish, Chinese, Japanese and Korean are absent. PostgreSQL 17 ships 28 snowball dictionaries in pg_catalog plus the non-stemming simple, and Polish, Chinese, Japanese, Korean and Thai have none. The language is chosen when the index is built, not inferred from the row, so one table needs one index object per language: a generated tsvector column from to_tsvector('german', body), another from to_tsvector('french', body), a GIN index on each.
| Language | Elasticsearch | PostgreSQL | Qdrant stemmer |
|---|---|---|---|
| German | german analyzer; compounds need dictionary_decompounder or hyphenation_decompounder plus a word list | german config | snowball German |
| Finnish | finnish analyzer | finnish config | snowball Finnish |
| French | french analyzer, _french_ stopwords | french config | snowball French |
| Italian | italian analyzer, _italian_ stopwords | italian config | snowball Italian |
| Polish | Needs analysis-stempel: analyzer polish, filters polish_stem, polish_stop | No built-in dictionary | Absent |
| Japanese | Needs analysis-kuromoji | No built-in dictionary | Absent; use the multilingual tokenizer |
| Korean | Needs analysis-nori | No built-in dictionary | Absent; use the multilingual tokenizer |
| Simplified Chinese | Needs analysis-smartcn | No built-in dictionary | Absent; use the multilingual tokenizer |
| English | english analyzer; standard is the real default | english config | snowball English |
Token budgets: the 512-token cap is not the same chunk in every language
intfloat/multilingual-e5-large is an XLMRobertaModel with max_position_embeddings 514 and vocab_size 250002, and its tokenizer config sets model_max_length to 512. The card states that long texts are truncated to at most 512 tokens, and that every input needs an English query: or passage: prefix even for non-English text. BAAI/bge-m3 is the same architecture on the same vocabulary, with 8,192 usable input tokens; specs are in the embedding model dimension table.
A chunking rule written in characters or words does not produce equal token counts across languages, because the subword vocabulary was not built to cover them equally. The same clause in German, French and Italian yields three different counts from one rule, and the longest is the one silently truncated. Swapping models does not fix it: bge-m3 raises the ceiling but shares the vocabulary. Do not take a multiplier off a blog post, this one included. Measure it with the retriever's own tokenizer:
from transformers import AutoTokenizer
tok = AutoTokenizer.from_pretrained("intfloat/multilingual-e5-large")
# same clause, three language versions, from your own corpus
samples = {
"de": open("clause_4_2.de.txt").read(),
"fr": open("clause_4_2.fr.txt").read(),
"it": open("clause_4_2.it.txt").read(),
}
for lang, text in samples.items():
ids = tok(f"passage: {text}", add_special_tokens=True)["input_ids"]
print(lang, len(text), len(ids), len(ids) > 512)Set chunk size from the worst language, not the average. Where a language has no stemmer, one model property helps: bge-m3 returns dense, multi-vector and sparse representations in one pass, through BGEM3FlagModel with model.encode(..., return_dense=True, return_sparse=True, return_colbert_vecs=True), so Polish gets a sparse leg that does not wait on a Snowball dictionary.
Evaluate per language, or you are not evaluating at all
An averaged retrieval number hides a language that has collapsed. Four languages at recall 0.82 and a fifth at 0.31 still average 0.72. Report recall and nDCG per language, against an evaluation set drawn from your own corpus.
Public benchmarks cannot stand in for that set. MIRACL is an 18-language challenge from the WSDM 2023 Cup with around 77,000 queries and over 700,000 relevance judgments, all assessed by native speakers hired by the project. Corpora are public for 16 of the languages, the other two are held back, and German is not among the 16. Those Wikipedia-derived corpora range from 131,924 passages for Swahili to 32,893,221 for English, roughly a 249-fold spread, so an unweighted average follows the biggest language.
MMTEB, published in February 2025, is broader: over 500 quality-controlled evaluation tasks across more than 250 languages, assembled by 85 authors. Its leaderboard result is a snapshot of that date, and the paper named a 560-million-parameter instruct checkpoint as the best-performing publicly available model at publication. The board has moved since, and how the multilingual leaderboard has moved covers where it stands now. Shortlist with these benchmarks, decide with your own set: Wikipedia prose is not policy prose.
Three slices belong in that set from the first week: monolingual retrieval in each language, cross-language retrieval where query and answer languages differ, and code-switched documents if you have any. Those slices are the three failure shapes at the top of this post, turned into measurements.
Choosing a layout, and the one-perimeter constraint
The on-prem constraint does not decorate this topic. It deletes an option. In a private perimeter, Option C is the one layout you cannot buy as a service: sending a French policy clause or a German claims note to a hosted translation endpoint is a cross-border transfer of the exact text under review, and that text is what a regulator or a policyholder will later ask you to produce. Running the translation model inside the perimeter removes the transfer problem and leaves the citation problem exactly as it was.
The per-language sparse gap is worse inside a perimeter, because the fix for a missing language is a plugin install, and on an air-gapped cluster installing analysis-stempel or analysis-kuromoji is a change-controlled event rather than a command someone runs on a Thursday. The plugin set is part of the cluster build artifact and part of upgrade testing.
Three things to do this week, in order. Run the tokenizer script above over the same clause in each of your languages and check whether your chunk size truncates the longest one. List what your engine ships for every language in the corpus, with \dF in psql or the equivalent check on your Elasticsearch nodes, and write down which languages have no stemmer. Then split your retrieval evaluation by language and read the worst row instead of the mean. If that row is a language your engine has no analyzer for, you have found the bug, and it was never the embedding model. The rest of the stack sits across our RAG systems work.
| Corpus shape | Query language predictable? | Layout | Why |
|---|---|---|---|
| Two to five languages, monolingual documents, every language has analyzer support | Yes, from the account locale | Shared index, language field, per-language text index | The predicate correlates with the query, and one collection keeps one eval set |
| Two to five languages, monolingual documents, one language has no stemmer | Yes | Shared index; that language runs dense-only, reported as its own slice | Hiding a sparse-blind language in an average is the failure this post is about |
| Many languages, very different corpus sizes, per-language teams own content | Yes | Index per language plus an ingest-time language stamp | Ownership and per-language tuning, not retrieval quality |
| Documents code-switch inside a page (a bilingual contract, a claims note) | No | Shared index, chunk-level language tag, never document-level | A document tag is wrong for half the page; it belongs where the analyzer is chosen |
| Users routinely ask in English about non-English sources | No | Shared index, strongly aligned model, cross-language case measured explicitly | A model that wins on weak-alignment tasks may lose the strong-alignment one |
| Regulated corpus, answers must cite the signed original, cross-border transfer restricted | Either | Shared index inside the perimeter, no translation hop on the retrieval path | A hosted API transfers the clause under review, and the cited span stops being the signed text |
FAQ
Quick answers to the questions this post tends to raise.



