💡 مثال کامل و قابل اجرا در گیت‌هاب موجود است:
spring-ai-document-reader-using-groupdocs-parser-java

روش قدیمی: پیوست‌هایی که هرگز به فهرست نمی‌رسند

یک Spring AI DocumentReader اولین مرحله از خط لوله ETL Spring AI است: این یک فایل را به اشیای Document تبدیل می‌کند که یک splitter و یک vector store مصرف می‌کنند، بنابراین هر چیزی که رها می‌شود برای همیشه از دست می‌رود. تصویری از دستیار را در ذهن داشته باشید که تیم پشتیبانی شما درخواست کرده بود. شما صندوق پست مشترک را استخراج می‌کنید، بدنه پیام‌های فایل‌های .eml را فهرست می‌کنید، تقسیم می‌کنید، تعبیه می‌کنید و ارسال می‌کنید. اولین سؤال واقعی می‌آید و پاسخ به ایمیلی اشاره می‌کند که تمام بدنه‌اش این است: «لطفاً سیاست بازپرداخت پیوست شده را ببینید». خود سیاست، یک پیوست PDF، هرگز خوانده نشده بود.

هیچ‌چیز در مسیر پایین‌دست نمی‌تواند این را اصلاح کند. TokenTextSplitter همان جمله‌ای که به آن داده شده را به قطعات تقسیم می‌کند و vector store آن را به‌عنوان نزدیک‌ترین تطابق به «پنجره بازپرداخت ما چه زمانی است؟» برمی‌گرداند. هر مؤلفه کار خود را انجام داد. شکاف در همان ابتدای خط لوله قرار دارد، در تصمیم‌گیری درباره این که چه چیزی به‌عنوان سند محسوب می‌شود. یک خروجی صندوق پست یک درخت است: پیام‌ها، پیوست‌ها و گاهی آرشیوها داخل پیوست‌ها. یک خواننده که فقط سطح بالایی را می‌بیند، کاغذ بسته‌بندی را فهرست می‌کند.

نسخه اولیه من دقیقاً همین کار را انجام می‌داد. آن را به‌روش واضح اصلاح کردم: بسته‌های انتشار را در یک کمک‌کننده unzip می‌کردم، پیوست‌ها را از پیام‌ها در کمک‌کننده‌ای دیگر استخراج می‌کردم، صفحات PDF را با کمک‌کننده‌ای سوم می‌خواندم و نام‌های متادیتا را در هر کدام اختراع می‌کردم. این کار کرد، تا این‌که فرمت چهارم آمد و کمک‌کننده‌ها درباره این که یک صفحه را چه نامی بگذارند، توافق نداشتند.

هزینه واقعی: در پروژه نمونه، ۲ از ۴ فایل ورودی، ایمیل و ZIP، متنی دارند که یک خواندن فقط بدنه هرگز فهرست نمی‌کند. در یک مجموعه پشتیبانی این نسبت تصمیم می‌گیرد که آیا دستیار مفید است یا نه.

روش بهتر: یک خواننده برای هر ورودی

GroupDocs.Parser برای Java بیش از 65 فرمت فایل را به‌صورت در‑فرآیند می‌خواند. این فرمت را از محتوای جریان تشخیص می‌دهد، متن را به‌ازای هر صفحه برمی‌گرداند و پیوست‌های یک ایمیل یا ورودی‌های یک ZIP را از طریق یک API کانتینر لیست می‌کند. این فراخوانی‌ها را در رابط DocumentReader Spring AI بپیچید و بقیه خط لوله نمی‌توانند تفاوت را تشخیص دهند، به‌جز این‌که اسناد اکنون شماره صفحه و ریشه‌سازگی (lineage) دارند.

خواننده‌های خود Spring AI همچنان گزینه‌های خوبی برای موارد خاص خود هستند: مستندات مرجع خط لوله ETL TikaDocumentReader بر پایه Apache Tika و PagePdfDocumentReader بر پایه Apache PDFBox را نشان می‌دهد. نکته اینجا باریک‌تر است. وقتی پیوست‌ها و اعضای آرشیو باید منبعی درجه یک باشند، خواننده‌ای که کانتینرها را می‌پیماید، شما را از زوایای کمک‌کننده نجات می‌دهد.

قبل از شروع، به موارد زیر نیاز دارید:

  • Java 17 یا بالاتر، پایه‌ای برای Spring AI 2.x
  • GroupDocs.Parser برای Java 26.10 (دریافت یک لایسنس موقت) و org.springframework.ai:spring-ai-commons 2.0.2
  • یک ایمیل استخراج‌شده با پیوست، یا چهار فایل نمونه از مخزن

پروژه نمونه را کلون کنید و با یک فرمان اجرا کنید:

mvn compile exec:java

Spring AI DocumentReader برای ارجاع‌های RAG چه چیزی باید برگرداند؟

باید یک List<Document> باشد که هر ورودی آن به‌قدر کافی کوچک باشد تا بتوان آن را ارجاع داد و به‌قدر کافی برچسب‌گذاری شده باشد تا قابل ردیابی باشد. در عمل این به معنای یک Document برای هر صفحه با page_number، یک source که نام فایل را دارد و برای هر چیزی که داخل ایمیل یا آرشیو آمده است، یک parent_source که نام کانتینر را دارد، است. TokenTextSplitter این متادیتا را به هر قطعه کپی می‌کند، بنابراین ارجاع تا انتهای vector store زنده می‌ماند.

روش جدید: پیوست‌ها به‌عنوان اسناد درجه یک

در اینجا همان کار با یک کلاس واحد، GroupDocsParserDocumentReader، انجام می‌شود. سه گام آن را پوشش می‌دهد: باز کردن منبع، خواندن صفحات، سپس پیروی از کانتینرها و تحویل همه به splitter.

گام 1 – باز کردن هر منبع به‌عنوان یک Stream

خواننده یک Resource Spring می‌پذیرد، بنابراین یک فایل، یک ورودی کلاس‌پث و یک بارگذاری همگی مسیر یکسانی را طی می‌کنند. Parser بایت‌ها را می‌خواند، به این معنی که یک blob بدون پسوند همچنان تجزیه می‌شود.

// Format detection works on content, so the file name is only a label
try (InputStream stream = resource.getInputStream();
     Parser parser = new Parser(stream)) {
    List<Document> documents = new ArrayList<>(readBody(parser, source, null));
    if (includeAttachments) {
        documents.addAll(readAttachments(parser, source)); // the fix for "see attached"
    }
    return documents;
} catch (UnsupportedDocumentFormatException ex) {
    // Log the unreadable file and return an empty list; the batch keeps going
    System.out.println("skipped (unsupported format): " + source);
    return new ArrayList<>();
}

گام 2 – خواندن صفحه به صفحه

حالت صفحه به‌صورت پیش‌فرض فعال است. من آن را برای هر چیزی طولانی‌تر از یک یا دو صفحه فعال می‌گذارم، چون یک بازبین که پاسخ را بررسی می‌کند می‌خواهد مستقیماً به صفحه 31 بپردازد.

if (pagePerDocument && pageCount > 0 && parser.getFeatures().isTextPage()) {
    for (int page = 0; page < pageCount; page++) {
        String text = readText(parser.getText(page));
        Map<String, Object> metadata =
                metadata(source, parent, fileType, pageCount);
        metadata.put(METADATA_PAGE, page + 1); // people count pages from 1
        addIfNotBlank(documents, text, metadata); // blank pages never get embedded
    }
}

نکات کلیدی:

  • pagePerDocument: true به هر قطعه یک page_number می‌دهد؛ سازندهٔ سه‌پارامتری برای یادداشت‌های کوتاه این گزینه را خاموش می‌کند.
  • isTextPage(): فرمت‌های بدون صفحه را فیلتر می‌کند؛ برای آن‌ها خواننده یک بار getText() را صدا می‌زند و همچنان source، file_type و page_count را ثبت می‌کند.

گام 3 – پیروی از کانتینرها، سپس تقسیم

هر پیوست یا عضو آرشیو یک Parser مخصوص به خود دریافت می‌کند و دوباره از همان منطق صفحه عبور می‌کند، با مسیر خود داخل والد برچسب‌گذاری می‌شود.

Iterable<ContainerItem> items = parser.getContainer(); // null for plain files
if (items == null) {
    return documents;
}
for (ContainerItem item : items) {
    String childSource = source + "/" + item.getFilePath(); // "mail.eml/policy.pdf"
    try (Parser child = item.openParser()) {
        documents.addAll(readBody(child, childSource, source)); // parent_source = source
    } catch (UnsupportedDocumentFormatException ex) {
        System.out.println("skipped (unsupported format): " + childSource);
    }
}

مرحله splitter بخشی از Spring AI است و متادیتای خواننده را بر هر قطعه حفظ می‌کند:

TokenTextSplitter splitter = TokenTextSplitter.builder()
        .withChunkSize(300) // tokens per chunk in the sample run
        .build();
List<Document> chunks = splitter.apply(documents);

در ۴ فایل نمونه، خواننده ۱۲ Document برمی‌گرداند و splitter ۲۰ قطعه می‌سازد. دموی مثال آن‌ها را در output/spring-ai-documents.jsonl می‌نویسد به‌جای vectorStore.add(chunks)، بنابراین می‌توانید قبل از ساخت فهرست واقعی، source، parent_source، page_number و chunk_index splitter را در هر خط ببینید.

این فایل ارزش پنج دقیقه از وقت شما را دارد. برای parent_source جستجو کنید و دقیقاً ببینید کدام قطعات از داخل ایمیل و ZIP آمده‌اند؛ برای page_number جستجو کنید و ببینید مرز هر صفحه کجا بوده است. وقتی اولین بار آن را روی یک خروجی واقعی صندوق پست اجرا کردم، این دو جستجو بیش از یک روز تنظیم پرامپت، به سؤال‌های کیفیت بازیابی پاسخ دادند.

مقایسهٔ کنار هم: قبل vs. بعد

در اینجا آنچه بین نسخهٔ کمک‌کننده‑به‑فرمت و خوانندهٔ تک‌کلاسه تغییر کرد، نشان داده شده است:

قبل (خواندن فقط بدنه به‌همراه کمک‌کننده‌های هر فرمت) بعد (GroupDocs.Parser DocumentReader)
پیوست‌های ایمیل از دست رفته یا توسط یک کمک‌کننده جدا استخراج می‌شد به‌عنوان Document با parent_source خوانده می‌شود
اعضای ZIP ابتدا به‌صورت فایل‌های فیزیکی استخراج می‌شدند درجا خوانده می‌شوند، source = sample.zip/sample.pdf
ارجاع‌ها فقط نام فایل نام فایل به‌همراه page_number
کد برای نگهداری یک کمک‌کننده برای هر فرمت یک کلاس DocumentReader
فایل غیرقابل خواندن بستگی به کمک‌کننده دارد ثبت می‌شود، رد می‌شود، دسته ادامه می‌یابد

خط لوله پس از خواننده به‌کل تغییر نکرد؛ فقط آنچه به آن جریان می‌یابد تغییر کرد.

مثال واقعی: فهرست پشتیبانی شبانه

یک تیم پشتیبانی هر شب صندوق پست مشترک خود را استخراج می‌کند و یک ZIP از راهنمایی‌های به‌روز شده با هر انتشار محصول دریافت می‌کند. یک کار زمان‌بندی‌شده در سرویس Spring Boot آن‌ها فهرست فایل‌های جدید را می‌گیرد، با GroupDocsParserDocumentReader می‌خواند، تقسیم می‌کند و به vector store پشت دستیار می‌نویسد. وقتی یک عامل درباره بازپرداخت‌ها سؤال می‌کند، پاسخ اکنون به manuals-2026-10.zip/refunds.pdf، صفحه 4، ارجاع می‌دهد و یک فیلتر بر parent_source می‌تواند جستجو را به آخرین بسته انتشار محدود کند.

کد سه گام دقیقاً به این کار یک‑به‑یک می‌نقشند. گام 1 هر چیزی که خروجی شبانه تولید می‌کند می‌پذیرد، از جمله فایل‌هایی که پسوندشان در مسیر گم شده است. گام 2 صفحات راهنما را جدا نگه می‌دارد، بنابراین عامل می‌تواند قبل از پاسخ به مشتری صفحه 4 را باز کند. گام 3 ZIP و صندوق پست را به‌عنوان والدهای قابل مشاهده به‌جای blobهای نامشخص می‌سازد و فراخوانی splitter همان‌طور که در هر خط لولهٔ دیگر Spring AI استفاده می‌شود، بدون تغییر می‌ماند.

یادداشت‌های داخلی کوتاه نیازی به تقسیم صفحه ندارند، بنابراین تیم آن‌ها را در حالت تمام‑فایل می‌خواند و پیوست‌ها را حفظ می‌کند:

// One Document per note; attachments still become their own Documents
DocumentReader reader = new GroupDocsParserDocumentReader(resource, false, true);
List<Document> documents = reader.read();

دیگر چه کاری می‌توانید با GroupDocs.Parser انجام دهید؟

حالا که پیوست‌ها به Spring AI جریان می‌یابند، چند قابلیت مرتبط تنها با یک فراخوانی در دسترس هستند:

نتیجه‌گیری

راه‌حل «دیدن پیوست» در مرحلهٔ خواننده قرار داشت. با GroupDocs.Parser پشت DocumentReader Spring AI، پیوست‌ها، اعضای آرشیو و صفحات جداگانه منبعی می‌شوند که دستیار می‌تواند به آن‌ها ارجاع دهد و جایگزینی آن تنها یک سازنده است.

آماده‌اید آن را روی مجموعهٔ خود امتحان کنید؟

منابع تکمیلی