טוקניזציה — עברית עולה פי 2.3Tokenization — Hebrew costs about 2.3x

הטוקניזר של Claude (כמו של רוב ה-LLMs) אומן רובו על אנגלית. עברית, בלי ניקוד, נשברת לעיתים קרובות לשני tokens או יותר על מילה. בדקנו את זה במסמכים אמיתיים שלנו — מאמרי בלוג של Hive באורך זהה — וקיבלנו יחס די יציב של 2.2-2.4 טוקנים בעברית מול אנגלית.

// דוגמה ממציאת ה-budget אצלנו
const SAMPLE_HE = 'אנחנו בונים סוכן שיודע לזהות כשהמשתמש רוצה לחזור לפרויקט קיים.';
const SAMPLE_EN = 'We are building an agent that recognizes when the user wants to return to an existing project.';

// tokens (approx, Claude tokenizer):
// HE: 41 tokens
// EN: 18 tokens
// ratio: 2.27x

שלושה effects מעשיים:

  • תקציב context קטן ב-2.3x אם הפלט בעברית. job של 60K tokens באנגלית הופך ל-138K בעברית.
  • output יקר יותר. ה-output tokens יקרים פי 5 מ-input ב-Opus. בעברית גם הם פי 2.3. החשבון הסופי גדל באגרסיביות.
  • output אטי יותר. מודלים פולטים tokens בקצב די יציב. פלט עברי = יותר tokens = יותר latency.

אצלנו זה השפיע על שני דברים: ה-cap של history הופחת ל-150K עבור session-ים בעברית, ו-system prompts כתובים באנגלית גם כשה-output בעברית — כי החיסכון על ה-prefix הקבוע משמעותי.

Claude's tokenizer (like most LLMs') was trained mostly on English. Hebrew, especially unvowelized, often breaks into two or more tokens per word. We measured this on real Hive content — blog posts of equal length in both languages — and the ratio comes in steady at 2.2-2.4 Hebrew tokens per English equivalent.

// from our budgeting code
const SAMPLE_HE = 'אנחנו בונים סוכן שיודע לזהות כשהמשתמש רוצה לחזור לפרויקט קיים.';
const SAMPLE_EN = 'We are building an agent that recognizes when the user wants to return to an existing project.';

// tokens (approx, Claude tokenizer):
// HE: 41 tokens
// EN: 18 tokens
// ratio: 2.27x

Three practical effects:

  • Context budget shrinks 2.3x if the output is Hebrew. A 60K-token English job becomes 138K in Hebrew.
  • Output is more expensive. Output tokens cost 5x input on Opus. In Hebrew they are also 2.3x. The bill compounds.
  • Output is slower. Models emit tokens at a relatively constant rate. Hebrew output = more tokens = more latency.

This shaped two of our defaults: history cap is reduced to 150K on Hebrew sessions, and system prompts are written in English even when the output is Hebrew — the savings on the cached fixed prefix are large.

RTL ב-HTML — לא רק dir="rtl" על bodyRTL in HTML — not just dir="rtl" on body

הרבה מפתחים מכניסים dir="rtl" ב-<html> או ב-<body> ומכריזים שעברית עובדת. ואז הם מקבלים תלונות שמספרי טלפון מופיעים הפוך, שקוד JavaScript נראה זוויות חדות, ושכתובות אימייל נכתבות מימין לשמאל.

הפיתרון הוא <bdi> ו-unicode-bidi. <bdi> אומר לדפדפן: "זה bidirectional isolate, אל תמשיך את הכיוון של הטקסט מסביב". מיועד בדיוק למספרים, קוד, מזהים, וכל דבר LTR שמופיע בתוך פסקה RTL.

<p dir="rtl">
  שלחנו 3 בקשות לכתובת 
  <bdi dir="ltr">api.hiveagent.co/v1/build</bdi> 
  והממוצע היה 
  <bdi>142ms</bdi>.
</p>

ל-block-level code עם הרבה שורות — תמיד dir="ltr" מפורש על ה-<pre>. אחרת, אם המשתמש בעמוד RTL, הקוד מתבלגן.

/* CSS שאצלנו */
.lab-article[dir="rtl"] pre { direction: ltr; text-align: left; }
.lab-article[dir="rtl"] code { unicode-bidi: isolate; }
.lab-article[dir="rtl"] p { unicode-bidi: plaintext; }
Noteunicode-bidi: plaintext על פסקה אומר: "זהה את כיוון הטקסט מהתוכן עצמו, לא מה-parent". זה מציל פסקאות שעוברות באמצע מאנגלית לעברית ובחזרה.

Many developers slap dir="rtl" on the <html> or <body> tag and declare Hebrew handled. Then come the complaints: phone numbers display reversed, JavaScript code looks angular, email addresses run right-to-left.

The fix is <bdi> and unicode-bidi. <bdi> tells the browser "this is a bidirectional isolate, do not inherit direction from the surrounding text". It exists for exactly this case: numbers, code, identifiers, and any LTR fragment inside an RTL paragraph.

<p dir="rtl">
  שלחנו 3 בקשות לכתובת 
  <bdi dir="ltr">api.hiveagent.co/v1/build</bdi> 
  והממוצע היה 
  <bdi>142ms</bdi>.
</p>

For multi-line code blocks, always set dir="ltr" on the <pre>. Otherwise, when the user is on an RTL page, the code mirrors and becomes unreadable.

/* our CSS */
.lab-article[dir="rtl"] pre { direction: ltr; text-align: left; }
.lab-article[dir="rtl"] code { unicode-bidi: isolate; }
.lab-article[dir="rtl"] p { unicode-bidi: plaintext; }
Noteunicode-bidi: plaintext on a paragraph means "derive direction from the content itself, ignore the parent". It rescues paragraphs that switch mid-sentence between English and Hebrew.

כתיבת prompts לסוכן שמדבר עבריתWriting prompts for a Hebrew-speaking agent

כלל הברזל אצלנו: ה-system prompt באנגלית, ה-output בעברית. למה? שלוש סיבות:

  1. cache. ה-prefix הקבוע נכנס ל-prompt cache. כל הוזלה שם נכפלת ב-2.3 כשמדובר בעברית.
  2. אמינות הוראות. Claude נצמד טוב יותר להוראות באנגלית, פשוט כי יותר מהאימון שלו היה כך. הוראה כמו "never call the same tool twice" עובדת חזק יותר מ-"לעולם אל תקרא לאותו כלי פעמיים".
  3. schemas. tool descriptions באנגלית. השמות של ה-tools באנגלית. שמות השדות באנגלית. רק ה-content ל-user עובר לעברית.
const system = [
  { type: 'text', text: 'You are the Hive builder agent. All tool calls and reasoning are in English.', cache_control: { type: 'ephemeral' } },
  { type: 'text', text: 'When responding to the user, write in Hebrew. Use Hebrew typography (gershayim, geresh) where appropriate. Code identifiers stay in English.', cache_control: { type: 'ephemeral' } },
  { type: 'text', text: TOOL_DOC,  cache_control: { type: 'ephemeral' } },
];

שני pitfalls שראינו:

  • תרגום של schema מבלבל את המודל. השאר את build_and_deploy, לא בנה_ופרוס.
  • הוראות סותרות בעברית ובאנגלית בתוך אותו prompt. בחרו אחת. אנחנו בחרנו אנגלית, אחרים בחרו עברית — שתיהן עובדות, ערבוב לא.

Our iron rule: system prompt in English, output in Hebrew. Three reasons:

  1. Caching. The fixed prefix lands in the prompt cache. Every saved token there compounds at the 2.3x Hebrew ratio.
  2. Instruction reliability. Claude follows English instructions more tightly, simply because more of its training was English. "Never call the same tool twice" lands harder than its Hebrew equivalent.
  3. Schemas. Tool descriptions in English. Tool names in English. Field names in English. Only the user-facing content flips to Hebrew.
const system = [
  { type: 'text', text: 'You are the Hive builder agent. All tool calls and reasoning are in English.', cache_control: { type: 'ephemeral' } },
  { type: 'text', text: 'When responding to the user, write in Hebrew. Use Hebrew typography (gershayim, geresh) where appropriate. Code identifiers stay in English.', cache_control: { type: 'ephemeral' } },
  { type: 'text', text: TOOL_DOC,  cache_control: { type: 'ephemeral' } },
];

Two pitfalls we have seen:

  • Translating the schema confuses the model. Keep build_and_deploy, not bana_oufros.
  • Conflicting instructions in Hebrew and English in the same prompt. Pick one. We picked English; others pick Hebrew. Mixing fails.

evaluation לפלט בעבריתEvaluating Hebrew output

כלי eval שעובדים לאנגלית לא בהכרח עובדים לעברית. שלוש דוגמאות מהיומיום שלנו:

  • BLEU/ROUGE מתבססים על n-gram match. בעברית, בגלל מורפולוגיה עשירה ("הולך", "הולכת", "הולכים") ה-match הרבה יותר רעוע על אותה משמעות. דרושה lemmatization לפני.
  • regex לבדיקת פורמט שובר על מקפים לטיניים מול עבריים, על גרשיים מול quotation marks, ועל ניקוד שלפעמים מופיע ולפעמים לא.
  • LLM-as-judge צריך judge שמבין עברית. שולחים את ה-eval ב-Sonnet, לא ב-Haiku, לפלט עברי.
// eval ספציפי לעברית: שאלון תוכן ולא BLEU
async function judgeHebrewAnswer(question, answer, ctx) {
  const judgement = await ctx.client.messages.create({
    model: 'claude-sonnet-4-6',
    system: 'You evaluate Hebrew answers. Reply only with JSON: {"correct":bool,"fluent":bool,"notes":string}.',
    messages: [{
      role: 'user',
      content: `שאלה: ${question}\nתשובה: ${answer}\n\nהאם התשובה עובדתית נכונה? האם היא כתובה בעברית טבעית?`,
    }],
  });
  return JSON.parse(judgement.content[0].text);
}
Noteשתי בדיקות זולות שמכסות הרבה רגרסיות בעברית: יחס "מילים שאינן לטיניות" ב-output (אמור להיות >90% בתשובה עברית), ויחס "שיש בה ניקוד" (אמור להיות 0% — Claude לפעמים מנקד ובולע tokens מיותר).

Evaluation tools tuned for English do not always work in Hebrew. Three examples from our day-to-day:

  • BLEU/ROUGE rely on n-gram match. Hebrew morphology (one verb has many surface forms) makes the match noisier on the same meaning. Lemmatize first.
  • Regex format checks break on Latin vs Hebrew dashes, on gershayim vs quotation marks, and on optional vowel marks.
  • LLM-as-judge needs a judge that reads Hebrew well. Use Sonnet, not Haiku, when judging Hebrew output.
// Hebrew-specific eval: rubric, not BLEU
async function judgeHebrewAnswer(question, answer, ctx) {
  const judgement = await ctx.client.messages.create({
    model: 'claude-sonnet-4-6',
    system: 'You evaluate Hebrew answers. Reply only with JSON: {"correct":bool,"fluent":bool,"notes":string}.',
    messages: [{
      role: 'user',
      content: `שאלה: ${question}\nתשובה: ${answer}\n\nהאם התשובה עובדתית נכונה? האם היא כתובה בעברית טבעית?`,
    }],
  });
  return JSON.parse(judgement.content[0].text);
}
NoteTwo cheap checks that catch many Hebrew regressions: the ratio of non-Latin characters in the output (should be >90% on a Hebrew answer), and the ratio of vowelization marks (should be 0% — Claude occasionally adds them and bloats tokens).

אחסון וחיפוש — Postgres עם ts_query עבריStorage and search — Postgres with Hebrew ts_query

full-text search על עברית ב-Postgres דורש קונפיגורציה מפורשת. ברירת המחדל english stemmer לא תעבוד; היא תזרוק stop-words עבריים ולא תזהה שורשים. אנחנו משתמשים ב-simple בתוספת מילון מותאם.

-- הגדרת FTS עברית
CREATE TEXT SEARCH CONFIGURATION hebrew (COPY = simple);

-- index hybrid: FTS + trigram + vector
CREATE TABLE memory_chunks (
  id          BIGSERIAL PRIMARY KEY,
  content     TEXT NOT NULL,
  tsv         TSVECTOR GENERATED ALWAYS AS (to_tsvector('hebrew', content)) STORED,
  embedding   VECTOR(1024)
);

CREATE INDEX ON memory_chunks USING GIN (tsv);
CREATE INDEX ON memory_chunks USING GIN (content gin_trgm_ops);  -- לטיפול בהטיות
CREATE INDEX ON memory_chunks USING ivfflat (embedding vector_cosine_ops);

השילוב של FTS + trigram + vector חשוב במיוחד בעברית בגלל המורפולוגיה. דוגמה אצלנו:

  • FTS תופס "בנינו" אם החיפוש הוא "בנינו" בדיוק.
  • trigram תופס "בנינו" אם החיפוש הוא "בנייה" (חופף 4 תווים).
  • vector תופס "הקמנו" כסמנטית קרוב.

ה-score המשוקלל שלנו הוא 50% FTS + 30% trigram + 20% vector. ניסינו 20/20/60 ויצאו תוצאות שטחיות יותר; הביסוס על FTS משאיר את החיפוש קונקרטי.

Full-text search on Hebrew in Postgres needs explicit configuration. The default english stemmer fails — it drops Hebrew stop-words it does not understand and misses roots. We use simple plus a custom dictionary.

-- Hebrew FTS configuration
CREATE TEXT SEARCH CONFIGURATION hebrew (COPY = simple);

-- hybrid index: FTS + trigram + vector
CREATE TABLE memory_chunks (
  id          BIGSERIAL PRIMARY KEY,
  content     TEXT NOT NULL,
  tsv         TSVECTOR GENERATED ALWAYS AS (to_tsvector('hebrew', content)) STORED,
  embedding   VECTOR(1024)
);

CREATE INDEX ON memory_chunks USING GIN (tsv);
CREATE INDEX ON memory_chunks USING GIN (content gin_trgm_ops);  -- handles morphology
CREATE INDEX ON memory_chunks USING ivfflat (embedding vector_cosine_ops);

The FTS + trigram + vector triple matters especially in Hebrew because of its rich morphology. Example from our system:

  • FTS catches "בנינו" only on exact match.
  • Trigram catches "בנינו" when the query is "בנייה" (4 overlapping characters).
  • Vector catches "הקמנו" as semantically near.

Our blended score is 50% FTS + 30% trigram + 20% vector. We tried 20/20/60 and the results felt fuzzy; anchoring on FTS keeps search concrete.

מלכודות שלמדנו מהן בדרך הקשהPitfalls we learned the hard way

כמה דברים שעלו לנו זמן וכסף לפני שתפסנו אותם:

  • UI שמראה זמן 14:32 כ-32:14. ה-bidi algorithm מהפך את הסדר. הפיתרון: <bdi>14:32</bdi> או &#x202D; (LRO) סביב הזמן.
  • אימוג'ים ובמקרה גם פיסוק שעוברים לצד הלא-נכון. סימני שאלה ופסיקים בסוף משפט עברי בלי <span dir="auto"> נצמדים לאות הלא-נכונה.
  • גרירה ושחרור (drag and drop) של טקסט. טקסט עברי שמועתק מדפדפן ל-IDE לפעמים מאבד RTL ומתחיל להיראות כמו "olleh". שמרו את ה-context ב-clipboard עם marker מפורש.
  • ספירת אורך טקסט. 'שלום'.length ב-JavaScript מחזיר 4. עד כאן בסדר. אבל 'שָׁלוֹם'.length (עם ניקוד) מחזיר 7. אם ה-cap שלך הוא 4 תווים, אתה בבעיה. תמיד [...str].length או Intl.Segmenter.
  • הזרמה (streaming) של תווים בודדים. תווים עבריים הם 2 בייטים ב-UTF-8. אם תחתוך באמצע, תקבל תו פגום. ה-buffer חייב לעבוד ב-codepoints, לא ב-bytes.
Warningאם האפליקציה שלך תומכת רק באנגלית והוספת עברית בסוף, תמיד תהיה מאחור. אם בנית bidi-clean מההתחלה — להוסיף ערבית, פרסית או כל RTL אחרת זה כמעט חינם.

עברית כשפה ראשונה היא החלטה ארכיטקטונית, לא feature. כשהיא בליבה, ה-stack כולו מתפתח בכיוון נכון. כשהיא בולט-און, כל פיתוח בתחומה הופך מלחמה.

Things that cost us time and money before we caught them:

  • UI that shows 14:32 as 32:14. The bidi algorithm swaps the order. Fix with <bdi>14:32</bdi> or wrap in a Left-to-Right Override (&#x202D;).
  • Emoji and even punctuation drifting to the wrong side. A question mark at the end of a Hebrew sentence, without <span dir="auto">, attaches to the wrong character.
  • Drag and drop of Hebrew text. Hebrew text copied from a browser to an IDE sometimes loses RTL and looks like "olleh". Preserve direction in the clipboard with an explicit marker.
  • String length. 'שלום'.length in JavaScript returns 4. Fine. But 'שָׁלוֹם'.length (with vowel marks) returns 7. If your cap is 4 chars, you have a bug. Always [...str].length or Intl.Segmenter.
  • Streaming individual characters. Hebrew codepoints are 2 bytes in UTF-8. Slice mid-byte and you get a replacement character. Buffers must work in codepoints, not bytes.
WarningIf your app supports English only and Hebrew is added later, you will always be behind. Build bidi-clean from the start and adding Arabic, Persian, or any RTL language later becomes nearly free.

Hebrew as a first-class language is an architectural decision, not a feature. When it lives in the core, the whole stack evolves in the right direction. When it is bolted on, every Hebrew-related ticket turns into a fight.