چت با PDF با هوش مصنوعی؛ آموزش ساخت ChatPDF فارسی با API درواره

در این آموزش یاد می‌گیرید چگونه با Python، FastAPI، FAISS و API درواره یک سامانه ChatPDF فارسی بسازید. برنامه فایل PDF را دریافت می‌کند، بخش‌های مرتبط را پیدا می‌کند و پاسخ مستند همراه با شماره صفحه ارائه می‌دهد.

Share
چت با PDF با هوش مصنوعی؛ آموزش ساخت ChatPDF فارسی با API درواره

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

برای مثال، کاربر می‌تواند یک گزارش ۲۰۰ صفحه‌ای را بارگذاری کند و بپرسد:

  • مهم‌ترین نتیجه این گزارش چیست؟
  • نویسنده چه پیشنهادهایی ارائه کرده است؟
  • تعریف یک اصطلاح در کدام صفحه قرار دارد؟
  • تفاوت دو روش مطرح‌شده در سند چیست؟
  • تمام اعداد مربوط به فروش را استخراج کن.
  • فصل سوم را به زبان ساده توضیح بده.
  • پاسخ را همراه با شماره صفحه ارائه کن.

در این مقاله یک سامانه واقعی پرسش از PDF با هوش مصنوعی می‌سازیم که متن سند را استخراج، بخش‌بندی و نمایه‌سازی می‌کند و برای تولید پاسخ از مدل‌های موجود در API درواره کمک می‌گیرد.

چت با PDF چیست؟

چت با PDF یا AI PDF Chat قابلیتی است که به کاربر اجازه می‌دهد به‌جای خواندن خط‌به‌خط یک سند طولانی، سؤال خود را به زبان طبیعی مطرح کند.

سامانه ابتدا قسمت‌های مرتبط سند را پیدا می‌کند و سپس همان قسمت‌ها را همراه با سؤال کاربر به مدل زبانی می‌دهد. مدل باید پاسخ را بر اساس محتوای بازیابی‌شده تولید کند.

این فرایند معمولاً با معماری RAG پیاده‌سازی می‌شود.

RAG مخفف Retrieval-Augmented Generation به معنای «تولید تقویت‌شده با بازیابی» است. در این معماری، مدل قبل از پاسخ‌دادن به یک منبع اطلاعاتی خارجی مانند PDF، پایگاه دانش یا اسناد شرکت متصل می‌شود.

تفاوت خلاصه‌سازی PDF با چت PDF

خلاصه‌سازی و چت با PDF به یکدیگر مرتبط‌اند، اما یک قابلیت محسوب نمی‌شوند.

قابلیتنحوه کار
خلاصه‌سازی PDFکل سند یا بخشی از آن به یک خلاصه تبدیل می‌شود
چت با PDFکاربر چند سؤال متفاوت درباره سند می‌پرسد
جست‌وجوی PDFبخش‌های مرتبط با یک عبارت پیدا می‌شوند
استخراج اطلاعاتفیلدهایی مانند نام، تاریخ و مبلغ استخراج می‌شوند
مقایسه اسناداطلاعات دو یا چند سند کنار هم قرار می‌گیرند

در یک سامانه چت PDF، لازم نیست تمام سند برای هر سؤال دوباره به مدل ارسال شود. فقط قسمت‌هایی که احتمالاً پاسخ در آن‌ها قرار دارد بازیابی می‌شوند.

این روش برای فایل‌های طولانی سریع‌تر و اقتصادی‌تر است.

ChatPDF فارسی چه کاربردهایی دارد؟

پژوهش و دانشگاه

دانشجو یا پژوهشگر می‌تواند مقاله‌ها، کتاب‌ها و گزارش‌های علمی را بارگذاری کرده و درباره روش تحقیق، نتایج، فرضیه‌ها و منابع سؤال بپرسد.

شرکت‌ها و سازمان‌ها

سامانه می‌تواند روی آیین‌نامه‌ها، دستورالعمل‌ها، گزارش‌های داخلی، کاتالوگ‌ها و مستندات محصول کار کند.

فروش و پشتیبانی

کارشناسان می‌توانند کاتالوگ یا دفترچه راهنمای محصولات را به سامانه بدهند و پاسخ اولیه سؤالات مشتری را دریافت کنند.

آموزش

دانش‌آموز یا مدرس می‌تواند از کتاب و جزوه سؤال بپرسد، خلاصه فصل دریافت کند یا تمرین و پرسش آموزشی بسازد.

قراردادها و اسناد اداری

می‌توان بندها، تاریخ‌ها، طرف‌های سند و موضوعات مشخص را استخراج کرد. البته نتیجه مدل باید با متن اصلی سند تطبیق داده شود.

مستندات فنی

توسعه‌دهنده می‌تواند راهنماهای فنی، مستندات API یا دفترچه محصول را به یک دستیار قابل جست‌وجو تبدیل کند.

معماری سامانه پرسش از PDF

جریان اصلی برنامه به این صورت است:

آپلود PDF
    ↓
استخراج متن هر صفحه
    ↓
پاک‌سازی و بخش‌بندی متن
    ↓
ساخت Embedding برای هر بخش
    ↓
ذخیره در پایگاه برداری
    ↓
دریافت سؤال کاربر
    ↓
ساخت Embedding سؤال
    ↓
پیداکردن بخش‌های مرتبط
    ↓
ارسال سؤال و متن مرتبط به API درواره
    ↓
تولید پاسخ همراه با شماره صفحه

مدل زبانی مستقیماً داخل فایل جست‌وجو نمی‌کند. برنامه باید ابتدا متن مرتبط را پیدا کرده و در Context درخواست قرار دهد.

اجزای اصلی یک سامانه ChatPDF

جزءوظیفه
PDF Parserاستخراج متن و شماره صفحه
OCRخواندن PDFهای اسکن‌شده
Text Splitterتقسیم متن به بخش‌های کوچک
Embedding Modelتبدیل متن به بردار
Vector Databaseذخیره و جست‌وجوی بردارها
Retrieverانتخاب بخش‌های مرتبط
Language Modelتولید پاسخ نهایی
API Backendمدیریت فایل و درخواست‌های کاربران
User Interfaceآپلود سند و نمایش گفتگو

ابزارهای متن‌باز برای ساخت چت با PDF

PyMuPDF

PyMuPDF کتابخانه‌ای سریع برای استخراج متن، تصویر و اطلاعات ساختاری از PDF است. در این آموزش از آن برای خواندن متن هر صفحه استفاده می‌کنیم.

این کتابخانه از page.get_text() برای استخراج متن استفاده می‌کند و برای صفحات تصویری نیز امکان اتصال به OCR را دارد. مستندات رسمی PyMuPDF

pypdf

کتابخانه pypdf گزینه سبک دیگری برای خواندن، ترکیب، جداسازی و پردازش فایل‌های PDF است. برای PDFهایی با متن دیجیتال و ساختار ساده مناسب است.

pdfplumber

اگر استخراج جدول یا کنترل دقیق‌تر روی موقعیت کلمات اهمیت داشته باشد، pdfplumber می‌تواند انتخاب مناسبی باشد.

Docling

Docling برای تبدیل اسناد پیچیده به خروجی ساختاریافته طراحی شده است و می‌تواند در پردازش جدول‌ها، عنوان‌ها و ساختار صفحه مفید باشد.

Marker

Marker یک ابزار متن‌باز برای تبدیل PDF به Markdown است. برای اسنادی که حفظ ساختار تیترها، فرمول‌ها و بخش‌بندی آن‌ها اهمیت دارد، قابل بررسی است.

Tesseract و OCRmyPDF

اگر PDF از تصاویر اسکن‌شده ساخته شده باشد، استخراج متن معمولی نتیجه مطلوبی نمی‌دهد. در این حالت می‌توان از Tesseract یا OCRmyPDF استفاده کرد.

PaddleOCR

PaddleOCR یک مجموعه متن‌باز OCR است که برای تشخیص متن و ساختار بعضی اسناد پیچیده استفاده می‌شود.

Sentence Transformers

Sentence Transformers متن را به بردارهای عددی تبدیل می‌کند. این بردارها امکان جست‌وجوی معنایی را فراهم می‌کنند؛ یعنی عبارت‌های مرتبط حتی بدون تطابق کامل کلمات پیدا می‌شوند. راهنمای رسمی جست‌وجوی معنایی Sentence Transformers

FAISS

FAISS کتابخانه‌ای متن‌باز برای جست‌وجوی سریع میان بردارهای متراکم است. این ابزار با C++ توسعه یافته و رابط Python نیز دارد. مخزن رسمی FAISS

Qdrant

Qdrant یک پایگاه داده برداری متن‌باز است که فیلتر متادیتا، ذخیره پایدار و جست‌وجوی برداری را ارائه می‌دهد. برای نسخه‌های چندکاربره و بزرگ‌تر می‌توان FAISS حافظه‌ای را با Qdrant جایگزین کرد.

pgvector

اگر برنامه از PostgreSQL استفاده می‌کند، افزونه pgvector امکان ذخیره و جست‌وجوی Embeddingها را داخل همان پایگاه داده فراهم می‌کند.

LangChain و LlamaIndex

LangChain و LlamaIndex ابزارهایی برای ساخت جریان‌های RAG، اتصال به پایگاه برداری و مدیریت اسناد ارائه می‌کنند. استفاده از آن‌ها اجباری نیست؛ همان‌طور که در نمونه این مقاله، اجزای اصلی را مستقیماً پیاده‌سازی می‌کنیم.

FastAPI

FastAPI برای ساخت APIهای Python استفاده می‌شود. در این پروژه دو مسیر برای آپلود PDF و پرسیدن سؤال می‌سازیم. مستندات رسمی FastAPI بخش‌های مستقلی برای دریافت فایل و ساخت API دارد. راهنمای FastAPI

نرم‌افزارهای متن‌باز آماده برای چت با اسناد

اگر نمی‌خواهید همه اجزا را از ابتدا بسازید، این پروژه‌ها نیز قابل بررسی‌اند:

ابزارکاربرد
Open WebUIرابط گفتگو با مدل‌ها و فایل‌ها
AnythingLLMساخت فضای کاری و گفتگو با اسناد
PrivateGPTپرسش از اسناد با مدل‌های محلی
DocsGPTدستیار مبتنی بر مستندات
Quivrساخت پایگاه دانش و دستیار RAG
RAGFlowپردازش اسناد و ساخت جریان RAG
Difyساخت اپلیکیشن و گردش کار هوش مصنوعی
Flowiseطراحی بصری جریان‌های مبتنی بر LangChain
Langflowساخت تصویری برنامه‌های RAG و Agent

در بسیاری از این ابزارها می‌توان یک سرویس سازگار با OpenAI را از طریق Base URL و API Key سفارشی متصل کرد. جزئیات اتصال به نسخه و تنظیمات هر پروژه بستگی دارد.

ساخت ChatPDF فارسی با Python و API درواره

در نمونه عملی از فناوری‌های زیر استفاده می‌کنیم:

  • FastAPI برای Backend
  • PyMuPDF برای استخراج متن
  • Sentence Transformers برای Embedding
  • مدل multilingual-e5-base برای جست‌وجوی چندزبانه
  • FAISS برای جست‌وجوی برداری
  • OpenAI Python SDK برای اتصال سازگار به درواره
  • مدل متنی انتخاب‌شده در درواره برای پاسخ نهایی

مدل multilingual-e5-base برای متن‌های چندزبانه طراحی شده و در کارت رسمی آن، استفاده از پیشوندهای query: و passage: برای جست‌وجوی نامتقارن توضیح داده شده است. کارت مدل multilingual-e5-base

ساخت پوشه پروژه

ساختار پروژه:

persian-chatpdf/
├── app.py
├── requirements.txt
├── .env
└── static/
    └── index.html

نصب کتابخانه‌ها

محتوای requirements.txt:

fastapi
uvicorn[standard]
python-multipart
pymupdf
sentence-transformers
faiss-cpu
openai
numpy
python-dotenv

سپس اجرا کنید:

python -m venv .venv

در Linux یا macOS:

source .venv/bin/activate

در Windows:

.venv\Scripts\activate

نصب وابستگی‌ها:

pip install -r requirements.txt

تنظیم متغیرهای محیطی

محتوای فایل .env:

DARVAREH_API_KEY=YOUR_API_KEY
DARVAREH_MODEL=YOUR_MODEL_ID
DARVAREH_BASE_URL=https://api.darvareh.ir/v1
EMBEDDING_MODEL=intfloat/multilingual-e5-base

شناسه مدل متنی را از فهرست فعلی مدل‌های درواره انتخاب کنید.

کلید API باید فقط در Backend قرار داشته باشد و نباید داخل JavaScript مرورگر نوشته شود.

کد کامل Backend

فایل app.py را ایجاد کنید:

import os
import re
import uuid
from typing import Any

import faiss
import fitz
import numpy as np
from dotenv import load_dotenv
from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.responses import FileResponse
from openai import OpenAI
from pydantic import BaseModel, Field
from sentence_transformers import SentenceTransformer


load_dotenv()

DARVAREH_API_KEY = os.environ["DARVAREH_API_KEY"]
DARVAREH_MODEL = os.environ["DARVAREH_MODEL"]
DARVAREH_BASE_URL = os.getenv(
    "DARVAREH_BASE_URL",
    "https://api.darvareh.ir/v1",
)
EMBEDDING_MODEL_ID = os.getenv(
    "EMBEDDING_MODEL",
    "intfloat/multilingual-e5-base",
)

MAX_FILE_SIZE = 20 * 1024 * 1024
CHUNK_SIZE = 900
CHUNK_OVERLAP = 150
DEFAULT_TOP_K = 5

app = FastAPI(
    title="Persian ChatPDF",
    version="1.0.0",
)

ai_client = OpenAI(
    api_key=DARVAREH_API_KEY,
    base_url=DARVAREH_BASE_URL,
)

embedding_model = SentenceTransformer(EMBEDDING_MODEL_ID)

# این حافظه فقط برای نسخه آموزشی است.
documents: dict[str, dict[str, Any]] = {}


class AskRequest(BaseModel):
    document_id: str
    question: str = Field(min_length=2, max_length=2000)
    top_k: int = Field(default=DEFAULT_TOP_K, ge=1, le=10)


class SourceItem(BaseModel):
    page: int
    score: float
    preview: str


class AskResponse(BaseModel):
    answer: str
    sources: list[SourceItem]


def normalize_text(text: str) -> str:
    """پاک‌سازی فاصله‌ها و نویسه‌های غیرضروری."""
    text = text.replace("\u200f", " ")
    text = text.replace("\u202a", " ")
    text = text.replace("\u202b", " ")
    text = re.sub(r"[ \t]+", " ", text)
    text = re.sub(r"\n{3,}", "\n\n", text)

    return text.strip()


def extract_pdf_pages(pdf_bytes: bytes) -> list[dict[str, Any]]:
    """استخراج متن PDF همراه با شماره صفحه."""
    try:
        document = fitz.open(
            stream=pdf_bytes,
            filetype="pdf",
        )
    except Exception as error:
        raise ValueError("فایل PDF قابل خواندن نیست.") from error

    pages = []

    for page_index, page in enumerate(document):
        text = normalize_text(page.get_text("text"))

        if text:
            pages.append(
                {
                    "page": page_index + 1,
                    "text": text,
                }
            )

    document.close()

    if not pages:
        raise ValueError(
            "متنی از فایل استخراج نشد. "
            "احتمالاً PDF اسکن‌شده است و به OCR نیاز دارد."
        )

    return pages


def split_pages_into_chunks(
    pages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
    """تقسیم متن صفحات با حفظ شماره صفحه."""
    chunks = []

    for page_item in pages:
        text = page_item["text"]
        page_number = page_item["page"]
        start = 0

        while start < len(text):
            end = min(start + CHUNK_SIZE, len(text))
            chunk_text = text[start:end].strip()

            if chunk_text:
                chunks.append(
                    {
                        "page": page_number,
                        "text": chunk_text,
                    }
                )

            if end >= len(text):
                break

            start = max(end - CHUNK_OVERLAP, start + 1)

    return chunks


def build_vector_index(
    chunks: list[dict[str, Any]],
) -> faiss.IndexFlatIP:
    """ساخت نمایه FAISS از بخش‌های سند."""
    passages = [
        f"passage: {chunk['text']}"
        for chunk in chunks
    ]

    embeddings = embedding_model.encode(
        passages,
        normalize_embeddings=True,
        convert_to_numpy=True,
        show_progress_bar=False,
    ).astype("float32")

    index = faiss.IndexFlatIP(embeddings.shape[1])
    index.add(embeddings)

    return index


def retrieve_chunks(
    document_data: dict[str, Any],
    question: str,
    top_k: int,
) -> list[dict[str, Any]]:
    """پیداکردن مرتبط‌ترین قسمت‌های PDF."""
    query_embedding = embedding_model.encode(
        [f"query: {question}"],
        normalize_embeddings=True,
        convert_to_numpy=True,
        show_progress_bar=False,
    ).astype("float32")

    available_count = len(document_data["chunks"])
    result_count = min(top_k, available_count)

    scores, indices = document_data["index"].search(
        query_embedding,
        result_count,
    )

    results = []

    for score, chunk_index in zip(scores[0], indices[0]):
        if chunk_index < 0:
            continue

        chunk = document_data["chunks"][int(chunk_index)]

        results.append(
            {
                "page": chunk["page"],
                "text": chunk["text"],
                "score": float(score),
            }
        )

    return results


def build_context(
    retrieved_chunks: list[dict[str, Any]],
) -> str:
    """ساخت Context همراه با شماره صفحات."""
    context_parts = []

    for item in retrieved_chunks:
        context_parts.append(
            f"[صفحه {item['page']}]\n{item['text']}"
        )

    return "\n\n---\n\n".join(context_parts)


def generate_answer(
    question: str,
    context: str,
) -> str:
    """تولید پاسخ مستند با مدل درواره."""
    completion = ai_client.chat.completions.create(
        model=DARVAREH_MODEL,
        messages=[
            {
                "role": "system",
                "content": (
                    "شما یک دستیار تحلیل اسناد فارسی هستید. "
                    "فقط بر اساس متن سند پاسخ بدهید. "
                    "اگر پاسخ در متن ارائه‌شده وجود ندارد، "
                    "شفاف بگویید که اطلاعات کافی پیدا نشد. "
                    "برای ادعاهای اصلی، شماره صفحه را به شکل "
                    "[صفحه ۳] ذکر کنید. "
                    "پاسخ را روان، دقیق و مختصر بنویسید."
                ),
            },
            {
                "role": "user",
                "content": (
                    f"متن بازیابی‌شده از سند:\n\n{context}\n\n"
                    f"سؤال کاربر:\n{question}"
                ),
            },
        ],
    )

    content = completion.choices[0].message.content

    if not content:
        raise ValueError("مدل پاسخ متنی معتبری برنگرداند.")

    return content.strip()


@app.get("/")
def home():
    return FileResponse("static/index.html")


@app.post("/api/documents")
async def upload_document(
    file: UploadFile = File(...),
):
    filename = file.filename or "document.pdf"

    if not filename.lower().endswith(".pdf"):
        raise HTTPException(
            status_code=400,
            detail="فقط فایل PDF قابل قبول است.",
        )

    pdf_bytes = await file.read()

    if not pdf_bytes:
        raise HTTPException(
            status_code=400,
            detail="فایل خالی است.",
        )

    if len(pdf_bytes) > MAX_FILE_SIZE:
        raise HTTPException(
            status_code=413,
            detail="حجم فایل بیشتر از حد مجاز است.",
        )

    if not pdf_bytes.startswith(b"%PDF"):
        raise HTTPException(
            status_code=400,
            detail="ساختار فایل PDF معتبر نیست.",
        )

    try:
        pages = extract_pdf_pages(pdf_bytes)
        chunks = split_pages_into_chunks(pages)
        index = build_vector_index(chunks)
    except ValueError as error:
        raise HTTPException(
            status_code=422,
            detail=str(error),
        ) from error

    document_id = str(uuid.uuid4())

    documents[document_id] = {
        "filename": filename,
        "pages_count": len(pages),
        "chunks": chunks,
        "index": index,
    }

    return {
        "document_id": document_id,
        "filename": filename,
        "pages_count": len(pages),
        "chunks_count": len(chunks),
    }


@app.post(
    "/api/ask",
    response_model=AskResponse,
)
def ask_document(request: AskRequest):
    document_data = documents.get(request.document_id)

    if not document_data:
        raise HTTPException(
            status_code=404,
            detail="سند موردنظر پیدا نشد.",
        )

    retrieved_chunks = retrieve_chunks(
        document_data=document_data,
        question=request.question,
        top_k=request.top_k,
    )

    if not retrieved_chunks:
        raise HTTPException(
            status_code=422,
            detail="بخش مرتبطی در سند پیدا نشد.",
        )

    context = build_context(retrieved_chunks)

    try:
        answer = generate_answer(
            question=request.question,
            context=context,
        )
    except Exception as error:
        raise HTTPException(
            status_code=502,
            detail=f"خطا در دریافت پاسخ مدل: {error}",
        ) from error

    sources = [
        SourceItem(
            page=item["page"],
            score=round(item["score"], 4),
            preview=item["text"][:180],
        )
        for item in retrieved_chunks
    ]

    return AskResponse(
        answer=answer,
        sources=sources,
    )

ساخت رابط کاربری ساده

فایل static/index.html:

<!doctype html>
<html lang="fa" dir="rtl">
<head>
    <meta charset="UTF-8">
    <meta
        name="viewport"
        content="width=device-width, initial-scale=1"
    >
    <title>چت با PDF فارسی</title>

    <style>
        body {
            max-width: 820px;
            margin: 40px auto;
            padding: 0 20px;
            font-family: sans-serif;
            line-height: 1.9;
            background: #f7f8fc;
            color: #172033;
        }

        .card {
            margin-bottom: 20px;
            padding: 24px;
            border-radius: 16px;
            background: white;
            box-shadow: 0 8px 30px rgba(20, 30, 60, 0.08);
        }

        input,
        textarea,
        button {
            box-sizing: border-box;
            width: 100%;
            margin-top: 12px;
            padding: 12px;
            font: inherit;
        }

        textarea {
            min-height: 120px;
            resize: vertical;
        }

        button {
            border: 0;
            border-radius: 10px;
            background: #6246ea;
            color: white;
            cursor: pointer;
        }

        button:disabled {
            cursor: wait;
            opacity: 0.6;
        }

        #answer {
            white-space: pre-wrap;
        }

        .source {
            margin-top: 10px;
            padding: 10px;
            border-right: 3px solid #6246ea;
            background: #f4f1ff;
        }
    </style>
</head>

<body>
    <h1>چت با PDF فارسی</h1>

    <section class="card">
        <h2>۱. بارگذاری فایل</h2>

        <input
            id="pdf-file"
            type="file"
            accept="application/pdf"
        >

        <button id="upload-button">
            بارگذاری و پردازش PDF
        </button>

        <p id="upload-status"></p>
    </section>

    <section class="card">
        <h2>۲. پرسش از سند</h2>

        <textarea
            id="question"
            placeholder="سؤال خود را درباره فایل بنویسید..."
        ></textarea>

        <button id="ask-button" disabled>
            ارسال سؤال
        </button>
    </section>

    <section class="card">
        <h2>پاسخ</h2>
        <div id="answer">هنوز سؤالی ارسال نشده است.</div>
        <div id="sources"></div>
    </section>

    <script>
        let documentId = null;

        const fileInput = document.getElementById("pdf-file");
        const uploadButton =
            document.getElementById("upload-button");
        const uploadStatus =
            document.getElementById("upload-status");
        const questionInput =
            document.getElementById("question");
        const askButton =
            document.getElementById("ask-button");
        const answerBox =
            document.getElementById("answer");
        const sourcesBox =
            document.getElementById("sources");

        uploadButton.addEventListener("click", async () => {
            const file = fileInput.files[0];

            if (!file) {
                uploadStatus.textContent =
                    "ابتدا یک فایل PDF انتخاب کنید.";
                return;
            }

            const formData = new FormData();
            formData.append("file", file);

            uploadButton.disabled = true;
            askButton.disabled = true;
            uploadStatus.textContent =
                "در حال استخراج و نمایه‌سازی متن...";

            try {
                const response = await fetch(
                    "/api/documents",
                    {
                        method: "POST",
                        body: formData,
                    }
                );

                const data = await response.json();

                if (!response.ok) {
                    throw new Error(
                        data.detail || "پردازش فایل ناموفق بود."
                    );
                }

                documentId = data.document_id;
                askButton.disabled = false;

                uploadStatus.textContent =
                    `${data.pages_count} صفحه و ` +
                    `${data.chunks_count} بخش پردازش شد.`;
            } catch (error) {
                uploadStatus.textContent = error.message;
            } finally {
                uploadButton.disabled = false;
            }
        });

        askButton.addEventListener("click", async () => {
            const question = questionInput.value.trim();

            if (!documentId || !question) {
                return;
            }

            askButton.disabled = true;
            answerBox.textContent = "در حال دریافت پاسخ...";
            sourcesBox.innerHTML = "";

            try {
                const response = await fetch("/api/ask", {
                    method: "POST",
                    headers: {
                        "Content-Type": "application/json",
                    },
                    body: JSON.stringify({
                        document_id: documentId,
                        question: question,
                        top_k: 5,
                    }),
                });

                const data = await response.json();

                if (!response.ok) {
                    throw new Error(
                        data.detail || "دریافت پاسخ ناموفق بود."
                    );
                }

                answerBox.textContent = data.answer;

                data.sources.forEach((source) => {
                    const item = document.createElement("div");
                    item.className = "source";
                    item.textContent =
                        `صفحه ${source.page}: ` +
                        source.preview;

                    sourcesBox.appendChild(item);
                });
            } catch (error) {
                answerBox.textContent = error.message;
            } finally {
                askButton.disabled = false;
            }
        });
    </script>
</body>
</html>

اجرای برنامه

دستور زیر را اجرا کنید:

uvicorn app:app --reload

سپس آدرس زیر را باز کنید:

http://127.0.0.1:8000

یک فایل PDF متنی بارگذاری کرده و سؤالی درباره محتوای آن بپرسید.

مستندات خودکار API نیز در آدرس زیر در دسترس است:

http://127.0.0.1:8000/docs

API برنامه چگونه کار می‌کند؟

مسیر بارگذاری سند

POST /api/documents

ورودی:

multipart/form-data
file: document.pdf

خروجی نمونه:

{
  "document_id": "fa45af2f-8f32-4a60-9cb1-9ec87eae3307",
  "filename": "report.pdf",
  "pages_count": 42,
  "chunks_count": 118
}

مسیر پرسش از سند

POST /api/ask

ورودی:

{
  "document_id": "fa45af2f-8f32-4a60-9cb1-9ec87eae3307",
  "question": "مهم‌ترین نتیجه گزارش چیست؟",
  "top_k": 5
}

خروجی:

{
  "answer": "بر اساس گزارش، مهم‌ترین نتیجه ... [صفحه ۱۸]",
  "sources": [
    {
      "page": 18,
      "score": 0.8421,
      "preview": "نتایج بررسی نشان می‌دهد که..."
    }
  ]
}

بخش‌بندی یا Chunking چرا مهم است؟

اگر هر صفحه یا کل سند به‌صورت یک متن بزرگ ذخیره شود، جست‌وجوی معنایی دقت کافی نخواهد داشت.

Chunk باید به‌اندازه‌ای کوچک باشد که یک موضوع نسبتاً مشخص را پوشش دهد و به‌اندازه‌ای بزرگ باشد که مفهوم جمله‌ها از بین نرود.

در نمونه مقاله از تنظیمات زیر استفاده کردیم:

CHUNK_SIZE = 900
CHUNK_OVERLAP = 150

هم‌پوشانی باعث می‌شود جمله‌ای که در مرز دو Chunk قرار گرفته است به‌طور کامل از دست نرود.

اعداد مناسب به نوع سند بستگی دارند:

نوع سنداندازه اولیه پیشنهادی
مقاله و متن عمومی۷۰۰ تا ۱۲۰۰ نویسه
قراردادبند یا زیرعنوان
کتاب آموزشیپاراگراف یا بخش موضوعی
مستندات فنیعنوان و محتوای زیر آن
جدول و گزارش مالیسطرها و عنوان جدول
پرسش‌های متداولهر سؤال و پاسخ

تقسیم متن صرفاً بر اساس تعداد کاراکتر ساده است، اما برای نسخه حرفه‌ای بهتر است ساختار عنوان، پاراگراف، جدول و فصل نیز در نظر گرفته شود.

PDF اسکن‌شده را چگونه پردازش کنیم؟

بعضی فایل‌ها فقط شامل تصویر صفحات هستند. در این حالت get_text() متن قابل استفاده‌ای برنمی‌گرداند.

جریان مناسب برای PDF اسکن‌شده:

تشخیص نبود متن
    ↓
تبدیل صفحه به تصویر
    ↓
اجرای OCR
    ↓
پاک‌سازی متن
    ↓
ثبت شماره صفحه
    ↓
ساخت Embedding

PyMuPDF امکان استفاده از OCR روی صفحه را نیز ارائه می‌کند:

text_page = page.get_textpage_ocr()
text = page.get_text(textpage=text_page)

برای اجرای این قابلیت باید موتور OCR موردنیاز روی سرور نصب شده باشد. کیفیت OCR فارسی نیز باید با اسناد واقعی آزمایش شود.

برای فرم‌ها، فاکتورها و اسناد دارای جدول، OCR ساده ممکن است ترتیب ستون‌ها را به‌درستی حفظ نکند. در این شرایط ابزارهایی مانند Docling، PaddleOCR یا مدل‌های تحلیل سند قابل بررسی‌اند.

چگونه شماره صفحه را در پاسخ حفظ کنیم؟

هنگام استخراج متن باید شماره صفحه در متادیتای هر Chunk ذخیره شود:

{
    "page": 12,
    "text": "متن استخراج‌شده از صفحه دوازدهم"
}

پس از بازیابی، متن به شکل زیر به مدل داده می‌شود:

[صفحه ۱۲]
متن مرتبط...

[صفحه ۲۷]
متن مرتبط...

پرامپت نیز باید از مدل بخواهد شماره صفحه را در پاسخ ذکر کند. این کار بررسی جواب را برای کاربر ساده‌تر می‌کند.

شماره صفحه‌ای که کتاب روی صفحه چاپ کرده است ممکن است با شماره فیزیکی PDF متفاوت باشد. اگر این تفاوت اهمیت دارد، باید شماره چاپی صفحه نیز از سند استخراج و در متادیتا ذخیره شود.

چگونه دقت پاسخ را افزایش دهیم؟

تعداد نتایج بازیابی را تنظیم کنید

اگر top_k بسیار کم باشد، ممکن است بخش مهمی از پاسخ حذف شود. اگر بسیار زیاد باشد، متن نامرتبط وارد Context خواهد شد.

مقدار ۴ تا ۸ برای شروع مناسب است، اما باید با داده واقعی ارزیابی شود.

از Reranker استفاده کنید

ابتدا جست‌وجوی برداری ۱۵ یا ۲۰ نتیجه پیدا می‌کند. سپس یک مدل Reranker این نتایج را دوباره مرتب کرده و بهترین ۵ بخش را انتخاب می‌کند.

Sentence Transformers از Cross-Encoder برای امتیازدهی دقیق‌تر به جفت سؤال و متن پشتیبانی می‌کند. Cross-Encoder معمولاً دقیق‌تر اما کندتر از محاسبه مستقیم Embedding است. مستندات Cross-Encoder

جست‌وجوی ترکیبی بسازید

جست‌وجوی برداری برای تشابه معنایی مناسب است، اما شماره‌ها، کد محصولات و عبارت‌های دقیق ممکن است با جست‌وجوی کلیدواژه‌ای بهتر پیدا شوند.

در Hybrid Search نتایج دو روش ترکیب می‌شوند:

  • جست‌وجوی برداری
  • جست‌وجوی کلیدواژه‌ای مانند BM25

ساختار سند را حفظ کنید

نام فصل، عنوان بخش، شماره صفحه و نوع محتوا را همراه هر Chunk ذخیره کنید.

پاسخ را محدود به سند کنید

در System Prompt صریحاً بنویسید:

اگر پاسخ در متن بازیابی‌شده وجود ندارد،
بگو اطلاعات کافی در سند پیدا نشد.

منابع را به کاربر نشان دهید

علاوه بر پاسخ مدل، متن کوتاهی از Chunkهای بازیابی‌شده را نمایش دهید تا کاربر بتواند نتیجه را بررسی کند.

انتخاب مدل مناسب از درواره

برای چت با PDF، مدل باید توانایی‌های زیر را داشته باشد:

  • درک مناسب زبان فارسی
  • پیروی دقیق از Context
  • پاسخ‌گویی بدون افزودن اطلاعات غیرضروری
  • توانایی ترکیب چند قسمت سند
  • تولید ارجاع به شماره صفحه
  • پشتیبانی از Context کافی
  • سرعت متناسب با تجربه تعاملی
  • هزینه قابل مدیریت

همیشه قوی‌ترین مدل برای تمام سؤال‌ها ضروری نیست. سؤال‌های ساده و استخراجی ممکن است با یک مدل سریع‌تر و اقتصادی‌تر پاسخ داده شوند؛ درحالی‌که مقایسه چند فصل یا تحلیل پیچیده به مدل توانمندتری نیاز دارد.

شناسه مدل را در متغیر محیطی نگه دارید:

DARVAREH_MODEL=YOUR_MODEL_ID

به این ترتیب تغییر مدل به بازنویسی برنامه نیاز ندارد.

محاسبه هزینه چت با PDF

در این معماری دو نوع پردازش انجام می‌شود:

  1. ساخت Embedding برای بخش‌های PDF
  2. تولید پاسخ با مدل زبانی

در نمونه مقاله، Embedding به‌صورت محلی ساخته می‌شود؛ بنابراین هزینه API در مرحله پرسش و تولید پاسخ ایجاد خواهد شد.

هزینه هر سؤال تقریباً به این عوامل بستگی دارد:

  • طول سؤال
  • تعداد Chunkهای بازیابی‌شده
  • اندازه هر Chunk
  • طول تاریخچه گفتگو
  • طول پاسخ
  • قیمت مدل انتخابی

برای کاهش هزینه:

  • تمام PDF را در هر سؤال ارسال نکنید.
  • فقط قسمت‌های مرتبط را وارد Context کنید.
  • تعداد پیام‌های تاریخچه را محدود کنید.
  • برای سؤال‌های تکراری کش تعریف کنید.
  • از مدل متناسب با پیچیدگی سؤال استفاده کنید.
  • پاسخ را در اندازه موردنیاز نگه دارید.
  • Embedding سند را فقط هنگام تغییر فایل دوباره بسازید.

برای جزئیات بیشتر، مقاله محاسبه هزینه API هوش مصنوعی را مطالعه کنید.

ذخیره اطلاعات در نسخه عملی

در کد آموزشی، اطلاعات سند داخل حافظه Python ذخیره می‌شود. با توقف برنامه تمام اطلاعات از بین می‌روند و این روش برای محیط عملی مناسب نیست.

معماری پیشنهادی نسخه واقعی:

دادهمحل ذخیره پیشنهادی
فایل اصلی PDFفضای ذخیره‌سازی شیء
اطلاعات سندPostgreSQL
ChunkهاPostgreSQL یا پایگاه سند
EmbeddingهاQdrant، pgvector یا پایگاه برداری
وضعیت پردازشپایگاه داده
وظایف طولانیRedis و صف پردازش
گزارش مصرفجدول Usage
نتیجه سؤال‌های پرتکرارCache

برای هر سند باید موارد زیر ثبت شوند:

document_id
user_id
filename
status
pages_count
chunks_count
created_at
embedding_model
chunking_version

ثبت مدل Embedding و نسخه Chunking اهمیت دارد؛ زیرا تغییر هرکدام ممکن است به نمایه‌سازی مجدد سند نیاز داشته باشد.

پردازش PDF در پس‌زمینه

پردازش یک فایل بزرگ ممکن است چند دقیقه طول بکشد. در نسخه عملی بهتر است API آپلود، فایل را دریافت کرده و یک Job ایجاد کند.

وضعیت‌های پیشنهادی:

uploaded
extracting
chunking
embedding
ready
failed

کاربر پس از آپلود یک document_id دریافت می‌کند و رابط کاربری وضعیت پردازش را بررسی می‌کند.

ابزارهای مناسب صف:

  • Celery
  • Dramatiq
  • RQ
  • Arq
  • Redis Queue
  • سرویس‌های مدیریت‌شده صف

پشتیبانی از چند PDF

برای گفت‌وگو با چند فایل باید document_id یا collection_id در متادیتای بردارها ذخیره شود.

درخواست کاربر می‌تواند شامل چند سند باشد:

{
  "document_ids": [
    "document-1",
    "document-2"
  ],
  "question": "تفاوت نتیجه این دو گزارش چیست؟"
}

Retriever فقط میان Chunkهای همین اسناد جست‌وجو می‌کند. نام فایل و شماره صفحه نیز باید همراه هر نتیجه به مدل ارسال شوند.

نمونه Context:

[فایل: report-1404.pdf | صفحه ۱۸]
...

[فایل: report-1405.pdf | صفحه ۲۱]
...

افزودن تاریخچه گفتگو

برای سؤال‌های دنباله‌دار مانند «این مورد را بیشتر توضیح بده»، برنامه باید زمینه سؤال قبلی را درک کند.

دو روش وجود دارد:

ارسال چند پیام اخیر

چند سؤال و پاسخ اخیر به مدل فرستاده می‌شوند. این روش ساده است، اما Context و هزینه را افزایش می‌دهد.

بازنویسی سؤال

مدل ابتدا سؤال جدید را با توجه به تاریخچه به یک سؤال مستقل تبدیل می‌کند.

مثال:

سؤال قبلی:
درآمد شرکت در سال ۱۴۰۴ چقدر بوده است؟

سؤال جدید:
در مقایسه با سال قبل چطور؟

سؤال مستقل:
درآمد شرکت در سال ۱۴۰۴ نسبت به سال ۱۴۰۳ چه تغییری کرده است؟

سپس جست‌وجوی برداری با سؤال مستقل انجام می‌شود.

خروجی ساختاریافته برای کاربردهای نرم‌افزاری

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

{
  "answer": "پاسخ مستند",
  "found": true,
  "citations": [
    {
      "page": 18,
      "claim": "نتیجه اصلی گزارش"
    }
  ],
  "confidence": "medium"
}

سپس پاسخ با Pydantic اعتبارسنجی می‌شود.

نباید فقط به مقدار confidence تولیدشده توسط مدل اتکا کرد. شاخص‌های بازیابی، وجود منبع و ارزیابی واقعی نیز باید بررسی شوند.

نمونه پرامپت مناسب برای ChatPDF فارسی

شما دستیار پرسش و پاسخ از اسناد فارسی هستید.

قواعد:
- فقط از متن بازیابی‌شده استفاده کن.
- اطلاعات خارج از سند اضافه نکن.
- اگر پاسخ وجود ندارد، شفاف

Read more