סריקת קבלות עם מודל שפה: מה עובד, ואיך תופסים מספר מומצא

OCR מול מודל ראייה, פלט כפוי לסכימה, בדיקות אריתמטיות שמאתרות שדה שהומצא, בניית eval על קבלות אמיתיות, עלות לכל מסמך ומה נשלח החוצה.

Node.jsAI

קבלה מצולמת היא הקלט הכי גרוע שיש. היא מקומטת, מצולמת בזווית, חצי ממנה דהוי מהפקס התרמי, השם מודפס בעברית והסכומים באנגלית, ולפעמים היא בכלל צילום מסך של אפליקציית תשלום. בכל זאת, כל מערכת הנהלת חשבונות רוצה להפוך את זה לשורה מובנית עם ספק, תאריך, סכום ומע״מ.

מודל שפה עם ראייה עושה את זה טוב בהרבה מ-OCR קלאסי, ונכשל אחרת. OCR שגוי מייצר ג'יבריש שקל לזהות. מודל שגוי מייצר מספר שנראה סביר לגמרי. זה ההבדל שכל התכנון סביבו.

מה צריך לצאת מקבלה

לפני שבוחרים מודל, כדאי לכתוב את רשימת השדות, כי היא קובעת כמה קשה המשימה: שם הספק, מספר עוסק או ח.פ, תאריך המסמך, מספר המסמך, סוג המסמך (חשבונית מס, קבלה, חשבונית מס קבלה, חשבונית עסקה), סכום לפני מע״מ, סכום המע״מ, סה״כ לתשלום, מטבע, ואמצעי תשלום.

שלושה מהם קשים במיוחד. סוג המסמך, כי הכותרת לא תמיד כתובה ולפעמים היא כתובה שגוי על ידי מי שהנפיק. מספר העוסק, כי הוא מופיע בעשר צורות שונות ולפעמים מודפס ליד מספר טלפון באותו גודל. והפיצול בין הסכומים, כי חלק מהקבלות מציגות רק סה״כ ומשאירות את המע״מ לחישוב.

OCR קלאסי מול מודל ראייה

גישה מה מקבלים חולשה
Tesseract טקסט גולמי, בחינם, מקומי קורס על צילום עקום ועל עברית בגופן לא סטנדרטי
שירות OCR מנוהל טקסט עם קואורדינטות, אמין יחסית עדיין צריך לוגיקה שמחליטה איזה מספר הוא המע״מ
מודל ראייה שדות מובנים ישירות ממציא ערך כשהוא לא בטוח, אלא אם עוצרים אותו
שילוב טקסט מ-OCR כקלט נוסף למודל יותר תשתית, אבל נותן עוגן להצלבה

הדבר שמודל ראייה נותן ו-OCR לא הוא הבנת פריסה. הוא יודע שהמספר בפינה הימנית התחתונה, מתחת למילה "סה״כ", הוא הסכום הכולל, גם כשהטבלה לא מיושרת. הלוגיקה הזאת היא בדיוק מה שכתוב ידנית בכל פרויקט OCR ומעולם לא באמת עבד.

השילוב שווה שיקול כשהדיוק קריטי: מריצים OCR בנפרד ומשווים את המספרים שהמודל החזיר לטקסט שנקרא מהתמונה. מספר שלא מופיע בפלט ה-OCR באף צורה הוא מועמד מצוין לבדיקה אנושית.

פלט כפוי לסכימה

הטעות הראשונה של כל מי שמתחיל היא לבקש "החזר JSON" ואז לנסות לפרסר את התשובה. פלט מובנה פותר את זה ברמת ה-API: מגדירים סכימה, והתשובה מאומתת מולה.

import Anthropic from '@anthropic-ai/sdk';
import { z } from 'zod';
import { zodOutputFormat } from '@anthropic-ai/sdk/helpers/zod';
import { readFileSync } from 'node:fs';

const Receipt = z.object({
  supplier_name: z.string().nullable(),
  supplier_tax_id: z.string().nullable(),
  document_type: z.enum(['tax_invoice', 'receipt', 'tax_invoice_receipt', 'proforma', 'unknown']),
  document_number: z.string().nullable(),
  document_date: z.string().nullable(),
  currency: z.string().nullable(),
  subtotal: z.number().nullable(),
  vat_amount: z.number().nullable(),
  total: z.number().nullable(),
  notes: z.string().nullable(),
});

const client = new Anthropic();

const response = await client.messages.parse({
  model: 'claude-opus-5',
  max_tokens: 4096,
  system: SYSTEM_PROMPT,
  messages: [
    {
      role: 'user',
      content: [
        {
          type: 'image',
          source: {
            type: 'base64',
            media_type: 'image/jpeg',
            data: readFileSync('receipt.jpg').toString('base64'),
          },
        },
        { type: 'text', text: 'Extract the fields from this receipt.' },
      ],
    },
  ],
  output_config: { format: zodOutputFormat(Receipt) },
});

const receipt = response.parsed_output;

שתי החלטות בסכימה חשובות יותר מהפרומפט.

כל שדה מספרי הוא number ולא string. אם המודל מחזיר "1,240.00", הסכימה נכשלת ואתם יודעים על זה מיד, במקום לגלות אחרי parseFloat שהסכום הפך ל-1.

כל שדה הוא nullable. זה נשמע קטן, וזה ההבדל בין מערכת שמודה שהיא לא רואה את מספר העוסק לבין מערכת שמנחשת אותו. מודל שאין לו אפשרות להחזיר null ימלא משהו.

הפרומפט שעובד

הפרומפט קצר. כמה כללים מרוויחים את מקומם:

להעתיק מספרים כפי שהם מופיעים, בלי לחשב. אם בקבלה כתוב רק סה״כ, subtotal ו-vat_amount נשארים null. חישוב הוא עבודה של הקוד, לא של המודל, כי בקוד הוא נכון תמיד.

להחזיר null לכל שדה שלא מופיע בבירור, ולא לנחש. הוסיפו במפורש שעדיף שדה ריק על פני ניחוש.

תאריכים בפורמט YYYY-MM-DD. קבלה ישראלית כותבת 03/09/26, ובלי הנחיה מפורשת שהסדר הוא יום-חודש-שנה, מודל שאומן בעיקר על טקסט אמריקאי יתלבט.

document_type נבחר מתוך הרשימה בלבד. זו הסיבה שהוא enum בסכימה ולא טקסט חופשי.

לתאר בשדה notes כל דבר שנראה חריג: תמונה חתוכה, טקסט מטושטש, שני מסמכים באותו צילום. זה שדה שנקרא על ידי בן אדם, והוא מייצר יותר ערך מכל ניסיון לחלץ ממנו ציון ביטחון מספרי.

איך תופסים מספר מומצא

כאן נמצא ההבדל בין הדגמה למוצר. המודל לא מספק הסתברות אמיתית לכל שדה, ולבקש ממנו "דרג את הביטחון שלך מ-1 עד 10" מייצר מספר שנראה כמו ביטחון ומתנהג כמו רעש. במקום זה, בודקים את הפלט מול העולם.

הבדיקה הראשונה היא אריתמטית. אם שלושת הסכומים קיימים, חייב להתקיים subtotal + vat_amount = total, בסובלנות של אגורה לעיגול. שדה אחד שהומצא כמעט תמיד שובר את השוויון הזה.

השנייה היא שיעור המע״מ. vat_amount / subtotal צריך לצאת 18% (השיעור מאז 2025), או 0 בעסקה פטורה או בייצוא. תוצאה של 13% אומרת שאחד משני המספרים שגוי, בלי לדעת עדיין איזה.

השלישית היא ספרת ביקורת. מספר עוסק וח.פ ישראליים בנויים לפי אותו אלגוריתם של ספרת ביקורת, וזו בדיקה של עשר שורות שפוסלת חלק גדול מהטעויות בקריאת ספרה.

const validTaxId = (id) => {
  const digits = String(id).padStart(9, '0');
  if (!/^\d{9}$/.test(digits)) return false;
  const sum = [...digits].reduce((acc, char, i) => {
    const step = Number(char) * ((i % 2) + 1);
    return acc + (step > 9 ? step - 9 : step);
  }, 0);
  return sum % 10 === 0;
};

הרביעית היא שפיות של תאריך: לא בעתיד, ולא לפני שהעסק נפתח. קבלה מ-2019 שנסרקה היום היא כנראה שגיאת קריאה של הספרה האחרונה.

החמישית היא כפילות. אותו ספק, אותו מספר מסמך, אותו סכום, כבר קיים במערכת. זה תופס גם העלאה כפולה וגם קריאה שגויה שיצרה התנגשות.

כל בדיקה שנופלת מורידה את המסמך לתור אישור ידני עם סימון על השדה החשוד. זה מדד ביטחון אמיתי, והוא מחושב מהנתונים ולא נשאל מהמודל.

eval לפני שינוי פרומפט

ברגע שיש מערכת בפרודקשן, כל שינוי בפרומפט הוא סיכון, ו"נראה לי שזה השתפר" הוא לא מדידה. צריך סט קבוע של קבלות אמיתיות עם התשובות הנכונות שנרשמו ידנית. מאתיים מסמכים מספיקים כדי להתחיל, אם הם מגוונים: תרמי דהוי, צילום בזווית, PDF נקי מספק גדול, צילום מסך, וקבלה במטבע זר.

המדד הוא דיוק ברמת שדה ולא ברמת מסמך. "‏87% מהמסמכים נכונים" מסתיר את מה שחשוב: אולי total נכון ב-99% ו-supplier_tax_id נכון ב-60% בלבד, וזה שינוי אחר לגמרי בעבודה שלכם. תמדדו גם כמה מסמכים ירדו לאישור ידני, כי פרומפט שמייצר יותר null נראה מדויק יותר ועושה פחות עבודה.

הריצו את הסט בפועל לפני כל שינוי בפרומפט או במודל, ושמרו את התוצאות. ה-API של batch עולה חצי ומתאים בדיוק לזה, כי אף אחד לא ממתין לתשובה.

כמה זה עולה וכמה זה לוקח

תמונה נצרכת כטוקנים לפי הגודל שלה, אז שליחת צילום של 12 מגה-פיקסל היא בזבוז: מודל מקטין תמונה גדולה מדי בעצמו, וכל מה שקיבלתם הוא זמן העלאה ארוך יותר. הקטינו בצד שלכם, שמרו על טקסט קריא, ובדקו את העלות בפועל עם ספירת טוקנים במקום להעריך.

const count = await client.messages.countTokens({
  model: 'claude-opus-5',
  messages,
});

מעבר לזה, שלושה מנופים. תמונה בגודל סביר במקום המקור; system prompt קבוע עם prompt caching, כי הוא זהה בכל בקשה; ו-batch למה שלא צריך תשובה מיידית.

בחירת המודל היא החלטה למדוד ולא לנחש. מודל קטן יותר וזול יותר עשוי להספיק לקבלות נקיות ולהיכשל דווקא על התרמי הדהוי, שהוא בדיוק המקרה שבו טעות עולה יקר. הדרך היחידה לדעת היא להריץ את שני המודלים על אותו סט ולהשוות דיוק ברמת שדה מול העלות לאלף מסמכים.

מה יוצא מהמערכת החוצה

קבלה מכילה שם ספק, סכומים, לפעמים שם לקוח ומספר כרטיס חלקי. שליחתה ל-API חיצוני היא העברת מידע לצד שלישי, וזה צריך להופיע במדיניות הפרטיות ובהסכם עיבוד המידע, לא רק בתיעוד הפנימי.

שלוש שאלות שכדאי לענות עליהן לפני ההשקה ולא אחריה: מה נשמר אצל הספק וכמה זמן, האם התמונה המקורית נשמרת אצלכם לצורך ביקורת ולכמה זמן, ומה קורה כשלקוח מבקש למחוק את חשבונו. אם התשובה לאחרונה לא כוללת גם את הקבצים באחסון האובייקטים, היא לא תשובה.

הטיוטה נשארת טיוטה

הפלט של המודל הוא הצעה. הוא נכנס לטופס מלא מראש, עם התמונה פתוחה לצד השדות, ובן אדם מאשר או מתקן. שדה שנפל בבדיקה מסומן. שדה שחזר null פשוט ריק, וזה עדיף על מספר שגוי שאף אחד לא יבדוק.

התיקונים שהמשתמשים עושים הם הנכס שנצבר כאן. כל תיקון הוא דוגמה מתויגת בחינם, וקבלה שתוקנה ידנית שווה יותר לסט ה-eval שלכם מכל דוגמה שהמצאתם.