← All writingMay 20, 2026 · 9 min read

Arabic search is three search problems in a trench coat

MSA, Iraqi dialect and Franco-Arabic do not tokenize alike, spell alike, or fail alike. What it takes to build retrieval that survives all three.

SearchArabic NLPQdrant

A customer wants a red dress. Depending on who she is and what keyboard she has open, she types one of these:

فستان أحمر          Modern Standard Arabic
فستان احمر          same words, hamza dropped (extremely common)
فـــستان احمر        with tatweel stretching, from a decorative keyboard
بدي فستان احمر       Iraqi dialect
fostan a7mar        Franco-Arabic: Latin letters, digits for missing sounds
One request. Five surface forms. A single BM25 index sees five unrelated queries.

Harrir serves women in Iraq, which means all five arrive in production, often inside the same conversation. Retrieval here treats Arabic as what it operationally is: several languages sharing a keyboard.

Normalization is not optional

Arabic BM25 without normalization is broken BM25, and it fails quietly. Tatweel stretching inserts a joining character that changes nothing semantically and everything about tokenization. Diacritics do the same. Alif, hamza and taa-marbuta all have variants that native writers use interchangeably.

def normalize_arabic(text: str) -> str:
    text = TATWEEL_RE.sub("", text)        # ـــ decorative stretching
    text = DIACRITICS_RE.sub("", text)     # fatha, damma, kasra, shadda, sukun
    text = re.sub("[أإآ]", "ا", text)      # alif variants
    text = text.replace("ة", "ه")           # taa marbuta
    text = text.replace("ى", "ي")           # alif maqsura
    return text
Applied to the Arabic sparse leg at ingest time and at query time. Both, or the index and the query disagree and recall silently drops.

Route the legs, fuse the ranks

Deciding which legs to run is a Unicode block character-ratio count. No model, no inference call, no measurable latency.

def language_hint(q: str) -> str:
    ar = sum(1 for ch in q if "\u0600" <= ch <= "\u06FF")
    en = sum(1 for ch in q if ch.isascii() and ch.isalpha())
    if ar and not en:
        return "ar"
    if en and not ar:
        return "en"
    return "mixed"
Cheap enough to run on every query, which is the only reason it runs on every query.

The hint gates the sparse legs only. The dense leg always runs unfiltered, which is a deliberate recall decision: a customer asking in Arabic should still reach an English-language policy document, and a language filter on the dense leg would make that impossible. Reciprocal Rank Fusion then merges whichever legs survived, and a multilingual cross-encoder does the final quality pass.

Franco-Arabic has no stem

This is where the approach runs out. 'a7mar' is Arabic written in ASCII, where 7 stands in for ح because the sound has no Latin equivalent. There is no stemmer for it. BM25 cannot help, because there is no morphology to reduce. Dense encoders mostly have not seen it in training either, since it barely exists in curated corpora.

So Franco-Arabic gets handled where it can be: in the fine-tune. The SFT dataset is stratified across English, Modern Standard Arabic, Iraqi dialect and Franco-Arabic, so the model learns to read all four and, more importantly, to reply in the register it was addressed in.

Getting register right matters more than it sounds. A stylist that answers an Iraqi teenager's Franco message in formal MSA has technically understood the query and still failed the conversation.

Soft signals beat re-ingestion

Admins review answers, and their verdicts need somewhere to go that does not require rebuilding an index. Every chunk carries a status, and the status changes retrieval behaviour without touching embeddings.

  • active: normal participation in fusion.
  • downranked: fused score multiplied by 0.5. Still reachable when nothing better exists, but it loses every close contest. An admin can apply this in one click, with no re-embedding.
  • blocked: filtered upstream in the Qdrant payload clause, so it never reaches fusion at all.

Versioning works the same way. Policy version is tracked per document type, and the retriever serves only the newest version, dropping stale-but-well-scoring chunks from older policies. Superseded chunks are soft-blocked rather than deleted, so an admin correction that references a chunk id still resolves after a re-ingest.

3
retrieval legs, routed per query
0.5
score multiplier on downranked chunks
12 → 4
fused candidates, then reranked

None of this is clever, and none of it is really about Arabic. It is the lesson every multilingual search system eventually learns: your tokenizer is an unexamined model of language, and it will fail silently in precisely the language you cannot read well enough to notice.

Read nextThe flywheel: turning live traffic into better models