درس ۱۱ — RAG عاملمحور (Agentic RAG) با LangGraph: وقتی یک بار بازیابی کافی نیست¶
ماژول:
parsrag.agent· کد شروع:starter/agent.py· تصحیح:python -m grader run 11(۱۴ بررسی) · پیشنیاز: درس ۱۰ — ارزیابی
پایپلاین درس ۰۹ یک خط مستقیم است: پرسش → بازیابی → تولید. اگر بازیابی خراب شود، همهچیز خراب میشود و هیچکس متوجه نمیشود. درس ۱۰ نشان داد کجا خراب میشود: روی پرسشهای بازنویسیشده (محاورهای، با هممعنیها) پایپلاین فقط به ۲۲٪ پرسشهای پاسخدار جواب میدهد.
ایدهٔ عامل (Agent) ساده است: بهجای یک خط مستقیم، یک گراف با تصمیمگیری بسازیم. سیستم قبل از جستوجو پرسش را میفهمد، بعد از جستوجو نتیجه را قضاوت میکند، بعد از تولید پاسخ خودش را بررسی میکند، و اگر راضی نبود، پرسش را بازنویسی میکند و دوباره تلاش میکند — ولی تعداد تلاشها محدود است و آخرش، اگر چیزی پیدا نشد، صادقانه «نمیدانم» میگوید.
این درس هم میسازد و هم میسنجد: عامل چقدر بهتر است، چقدر گرانتر است، و کِی ارزشش را ندارد.
flowchart TB
Q["پرسش کاربر"] --> AN["تحلیل (analyze)<br/>محاورهای → معیار<br/>شکستن پرسش چندبخشی"]
AN -- "مبهم" --> CL["پرسش روشنکننده<br/>«دقیقتر بگویید…»"]
AN -- "خارج از موضوع" --> NO["«نمیدانم»"]
AN -- "اسناد" --> RT["بازیابی ترکیبی<br/>(درس ۰۷)"]
RT --> RR["بازرتبهبندی<br/>(درس ۰۸)"]
RR --> GR{"قضاوت متنها<br/>(grade): مرتبط؟"}
GR -- "هیچکدام، تلاش باقی است" --> RW["بازنویسی پرسش<br/>(rewrite)"]
RW --> RT
GR -- "هیچکدام، تلاش تمام" --> NO
GR -- "بله" --> GE["تولید مستند با ارجاع<br/>(درس ۰۹)"]
GE --> CH{"خودآزمایی وفاداری<br/>(check)"}
CH -- "وفادار" --> OK["پاسخ + منابع"]
CH -- "ضعیف، تلاش باقی است" --> RW
CH -- "ضعیف، بازبین انسانی روشن" --> HU["بازبین انسانی<br/>interrupt()"]
CH -- "ضعیف، تلاش تمام" --> NO
گراف، حالت، گره، مسیریاب¶
یک عامل LangGraph سه جزء دارد:
| جزء | یعنی | در parsrag.agent |
|---|---|---|
| حالت (State) | یک دیکشنری که از گرهی به گره دیگر میرود | AgentState: پرسش، پرسش تحلیلشده، زیرپرسشها، نتایج، پیشنویس، امتیاز وفاداری، trace |
| گره (Node) | تابعی که حالت را میگیرد و فقط کلیدهای تغییرکرده را برمیگرداند | analyze، retrieve، rerank، grade، generate، check، rewrite، … |
| مسیریاب (Router) | تابعی که با نگاه به حالت، اسم گره بعدی را میگوید | route_after_analyze، route_after_grade، route_after_check |
from parsrag import PersianRAG
from parsrag.agent import AgenticRAG
agent = AgenticRAG(PersianRAG.course_default(), max_retries=2)
result = agent.run("هزینهٔ پیک تو تهران چقدره؟")
result.query # 'هزینهٔ پیک در تهران چقدر است؟' ← بازنویسی به فارسی معیار
result.answer.text # 'هزینهٔ پیک در تهران 200 هزار تومان است.'
result.trace # ['analyze', 'retrieve', 'rerank', 'grade', 'generate', 'check', 'finish']
result.llm_calls # 4
مسیریابها کوچکاند، ولی همهٔ منطق کنترل همینجاست. مثلاً بعد از خودآزمایی:
def route_after_check(self, state):
score = state.get("faithfulness")
if score is not None and score >= self.faithfulness_threshold:
return "finish"
if self.can_retry(state): # attempt <= max_retries
return "rewrite"
if self.review and not state["draft"]["abstained"]:
return "human_review"
return "refuse" # «نمیدانم»ِ صادقانه
can_retry حلقه را محدود میکند. بدون آن، یک پرسش بیپاسخ میتواند تا ابد (و تا ته کیف پولتان) بازنویسی شود.
یک گراف، دو اجراکننده¶
همهٔ گرهها و یالها یکبار تعریف شدهاند (agent.nodes و agent.edges) و دو جا اجرا میشوند:
agent.run(question)— یک حلقهٔ ساده در پایتون، بدون هیچ وابستگی اضافه. تستهای این درس روی نصب پایه هم اجرا میشوند.build_graph(agent)— همان گرهها را در یکStateGraphلنگگراف میگذارد (pip install -e ".[agent]"):
from langgraph.graph import StateGraph, START, END
graph = StateGraph(AgentState)
for name, fn in agent.nodes.items():
graph.add_node(name, fn)
graph.add_edge(START, "analyze")
graph.add_conditional_edges("grade", agent.route_after_grade,
{"generate": "generate", "rewrite": "rewrite", "refuse": "refuse"})
...
app = graph.compile(checkpointer=checkpointer)
چرا لنگگراف، اگر حلقهٔ پایتونی هم کار میکند؟ سه چیز که نوشتنش با دست سخت است: نقطهٔ بازیابی (Checkpoint) بعد از هر گره، توقف برای انسان (interrupt) و ادامه از همانجا، و نمایش گراف. یک تست بررسی میکند که هر دو اجراکننده دقیقاً یک پاسخ و یک trace میدهند.
این همان گرافی است که خود لنگگراف رسم میکند (python examples/agentic_rag.py --mermaid docs/images/agent_graph.mmd):
graph TD;
__start__([<p>__start__</p>]):::first
analyze(analyze)
retrieve(retrieve)
rerank(rerank)
grade(grade)
generate(generate)
check(check)
rewrite(rewrite)
finish(finish)
clarify(clarify)
refuse(refuse)
human_review(human_review)
__end__([<p>__end__</p>]):::last
__start__ --> analyze;
analyze -.-> clarify;
analyze -.-> refuse;
analyze -.-> retrieve;
check -.-> finish;
check -.-> human_review;
check -.-> refuse;
check -.-> rewrite;
generate --> check;
grade -.-> generate;
grade -.-> refuse;
grade -.-> rewrite;
rerank --> grade;
retrieve --> rerank;
rewrite --> retrieve;
clarify --> __end__;
finish --> __end__;
human_review --> __end__;
refuse --> __end__;
classDef default fill:#f2f0ff,line-height:1.2
classDef first fill-opacity:0
classDef last fill:#bfb6fc
یالهای نقطهچین شرطیاند (یک مسیریاب تصمیم میگیرد).
یک تفاوت با طرح اولیه: در گراف ما بازرتبهبندی قبل از قضاوت است، نه بعد از آن. دلیلش هزینه است: قضاوت با LLM گرانترین قدم است، پس اول با بازرتبهساز ارزان ۲۰ نامزد را به ۶ میرسانیم و فقط همان ۶ را به LLM نشان میدهیم — در یک فراخوانی، نه ششتا.
هر قدمِ LLM: پرامپت کوچک، قالب ثابت، تجزیهگر بخشنده¶
هر گرهای که LLM صدا میزند، یک پرامپت فارسی دارد که جواب را در یک قالب ثابت میخواهد، تا یک تجزیهگر (Parser) — نه یک LLM دیگر — آن را بخواند. پرامپت تحلیل:
موضوع اسناد: حساب کاربری و امنیت ورود، باشگاه مشتریان کهربا، کدهای تخفیف و جشنوارهها، ...
پرسش کاربر: هزینهٔ پیک تو تهران چقدره و مرجوعی تا کی ممکنه؟
۱. اگر پرسش محاورهای یا شکسته است، آن را به فارسی معیار بازنویسی کن؛ معنا را عوض نکن.
۲. اگر پرسش چند بخش مستقل دارد، هر بخش را یک زیرپرسش جدا بنویس.
۳. مسیر را انتخاب کن: «اسناد»، «ابهام» یا «خارج».
دقیقاً با این قالب جواب بده:
مسیر: <اسناد | ابهام | خارج>
بازنویسی: <پرسش به فارسی معیار>
زیرپرسش: <زیرپرسش اول>
زیرپرسش: <زیرپرسش دوم، اگر هست>
پرسش روشنکننده: <فقط اگر مسیر «ابهام» است>
تجزیهگرها بخشنده و محتاطاند: ارقام فارسی ([۲] خیر)، گیومه («ابهام») و برچسب انگلیسی را میپذیرند؛ اگر جواب خوانا نبود، پیشفرضِ امن را برمیگردانند. در قضاوت، متنی که حکم ندارد نامرتبط حساب میشود — مدلی که قالب را رعایت نکند، بهجای جواب ساختگی، «نمیدانم» میگیرد.
| قدم | فراخوانی LLM | قالب جواب |
|---|---|---|
| تحلیل | ۱ | مسیر: … / بازنویسی: … / زیرپرسش: … |
| قضاوت | ۱ برای همهٔ متنها (۰ اگر امتیاز BM25 زیر آستانه است) | [1] بله، [2] خیر، … |
| تولید | ۱ برای هر زیرپرسش | پاسخ با [n] (درس ۰۹) |
| خودآزمایی | ۱ (۰ اگر پیشنویس «نمیدانم» است) | امتیاز: ۰ تا ۱۰ (داور درس ۱۰) |
| بازنویسی | ۱ در هر تلاش دوباره | یک سطر |
«LLM» آفلاین: قاعدههای شفاف بهجای مدل¶
تستها و CI نباید به هیچ API پولی وصل شوند. OfflineAgentLLM همان پرامپتهای بالا را میگیرد، تشخیص میدهد کدام قدم است (از پیام system) و با یک قاعدهٔ ساده، در همان قالب جواب میدهد:
| قدم | قاعدهٔ آفلاین |
|---|---|
| تحلیل | colloquial_to_standard (میشه → میشود، کتابخونه → کتابخانه، میرسه → میرسد، سفارشمو → سفارشم را)، split_questions، «ابهام» اگر جز «این/اون» واژهٔ محتوایی نماند |
| قضاوت | پوشش واژگانی LexicalReranker (درس ۰۸) ≥ ۰٫۴ |
| بازنویسی | فقط واژههای محتوایی؛ در تلاش بعد، شکستن در نیمفاصله |
| تولید | FakeLLM استخراجیِ درس ۰۹ |
| خودآزمایی | وفاداری ابتکاری درس ۱۰ |
چون از همان پرامپتها و تجزیهگرها رد میشود، منطق گراف، شمارش فراخوانیها و حلقه واقعاً تست میشوند. ولی یادتان باشد چه چیزی را نمیتواند: فهمیدن. «کرایهٔ پیک داخل پایتخت چنده؟» با قاعدهها «کرایهٔ پیک داخل پایتخت چند است؟» میشود — و هنوز در اسناد چیزی دربارهٔ «کرایه» و «پایتخت» نیست؛ آنجا نوشته «هزینهٔ پیک در تهران». این شکاف هممعنایی است و فقط یک مدل واقعی پُرش میکند.
آستانهٔ ۰٫۴ قضاوت را فقط روی qa.jsonl انتخاب کردیم (از ۰٫۳ تا ۰٫۷، بیشترین F1 پاسخ بدون افت F1 «نمیدانم»)؛ بازنویسیها فقط گزارش میشوند.
اندازهگیری: عامل در برابر پایپلاین درس ۰۹¶
هر دو سیستم دقیقاً همان نمایه، بازیاب، بازرتبهساز و آستانهٔ «نمیدانم» را دارند؛ تفاوت فقط در گراف است. معیارها همان گزارش درس ۱۰ است:
پرسشهای اصلی (۶۶ پرسش):
| معیار | درس ۰۹ | عامل |
|---|---|---|
| بازیابی R@1 | 0.881 | 0.881 |
| بازیابی MRR@10 | 0.941 | 0.941 |
| F1 پاسخ | 0.276 | 0.301 |
| دقت ارجاع | 0.842 | 0.877 |
| «نمیدانم» F1 | 0.714 | 0.714 |
| نرخ پاسخ | 0.966 | 0.966 |
| فراخوانی LLM در هر پرسش (میانگین / بیشینه) | 0.89 / 1 | 3.89 / 4 |
| پرسشهایی که بازنویسی شدند | ۰٪ | ۱۱٪ |
| تأخیر هر پرسش (میانه، آفلاین) | ≈ 6 ms | ≈ 9 ms |
پرسشهای بازنویسیشده (۵۱ پرسش):
| معیار | درس ۰۹ | عامل | عامل بدون قاعدههای محاوره |
|---|---|---|---|
| بازیابی R@1 | 0.370 | 0.478 | 0.370 |
| بازیابی MRR@10 | 0.487 | 0.557 | 0.469 |
| F1 پاسخ | 0.016 | 0.021 | 0.007 |
| دقت ارجاع | 0.800 | 0.562 | 0.700 |
| «نمیدانم» F1 | 0.178 | 0.205 | 0.178 |
| نرخ پاسخ | 0.217 | 0.348 | 0.217 |
| فراخوانی LLM در هر پرسش (میانگین / بیشینه) | 0.22 / 1 | 3.49 / 6 | 3.27 / 6 |
| پرسشهایی که بازنویسی شدند | ۰٪ | ۷۱٪ | ۷۵٪ |
| تأخیر هر پرسش (میانه، آفلاین) | ≈ 5 ms | ≈ 15 ms | ≈ 15 ms |
و بوتاسترپ جفتی (درس ۱۰) روی F1 پاسخ، «عامل − درس ۰۹»:
| مجموعه | ΔF1 [۹۵٪] | حکم |
|---|---|---|
| اصلی | +0.025 [0.000, +0.063] | نشان داده نشد (بازه به صفر میرسد) |
| بازنویسی | +0.005 [-0.010, +0.019] | نشان داده نشد |
چطور بخوانیم:
- هزینه قطعی است، سود نه. عامل روی پرسشهای اصلی ۴٫۴ برابر فراخوانی LLM دارد (۳٫۸۹ در برابر ۰٫۸۹) و بهبود F1 پاسخ با این تعداد پرسش اثبات نمیشود. با یک مدل واقعی، تأخیر تقریباً با تعداد فراخوانیها بالا میرود (هر فراخوانی یک رفتوبرگشت به مدل است)؛ عددهای میلیثانیهای بالا فقط برای
FakeLLMدرستاند — تأخیر مدل خودتان را با--llmبسنجید. - کل بهبود بازیابی روی بازنویسیها از قاعدههای محاوره است، نه از حلقهٔ بازنویسی: ستون آخر (همان عامل، بی قاعده) دقیقاً R@1 درس ۰۹ را دارد. حلقهٔ آفلاین (فقط واژههای کلیدی) تقریباً هیچ پرسشی را نجات نمیدهد — ۷۱٪ پرسشها بازنویسی شدند و نرخ پاسخ فقط از ۰٫۲۲ به ۰٫۳۵ رسید.
- دقت ارجاع روی بازنویسیها پایین آمد (۰٫۸۰ → ۰٫۵۶): عامل به پرسشهای بیشتری جواب میدهد، ولی پرسشهایی که تازه جواب گرفتهاند سختترند و ارجاعشان بیشتر اشتباه است. «بیشتر جواب دادن» همیشه «بهتر جواب دادن» نیست.
- خودآزمایی آفلاین هیچوقت شکست نمیخورد:
FakeLLMاستخراجی جمله را کپی میکند، پس وفاداری همیشه ۱ است. ارزش این گره با مدلی معلوم میشود که واقعاً توهم میزند (تستها این را با یک مدل «توهمزن» ساختگی شبیهسازی میکنند).
یک اعتراف دربارهٔ نشت داده. قاعدههای محاوره را با دانش عمومی فارسی گفتاری نوشتیم، ولی چند قاعده (مثل «بیاره»، «درمیاد»، «ون → ان» در «میمونه») را بعد از دیدن خروجی بازنویسی روی
qa_paraphrase.jsonlاضافه کردیم — دقیقاً همان کاری که درس ۱۰ گفت نکنید. یک بار اندازه گرفتیم: R@1 بازنویسیها بدون قاعده 0.370، با قاعدههای دور اول 0.435 و با همهٔ قاعدهها 0.478. پس از ۰٫۱۰۸ بهبود، حدود ۰٫۰۴۳ را باید خوشبینانه دانست. روی پرسشهای خودتان، قاعدهها را روی یک مجموعهٔ توسعه بنویسید و روی مجموعهٔ دیگری بسنجید.
کِی عامل ارزشش را ندارد؟¶
| موقعیت | پیشنهاد |
|---|---|
پرسشها با واژههای خود اسناد پرسیده میشوند (مثل qa.jsonl) |
پایپلاین ساده؛ عامل ۴ برابر هزینه دارد و بهبودش اثبات نشد |
| بودجه یا تأخیر محدود است (پاسخ زیر یک ثانیه، هزاران پرسش در روز) | پایپلاین ساده + آستانهٔ «نمیدانم»؛ بازنویسی را فقط برای پرسشهایی که زیر آستانهاند روشن کنید |
| پرسشها محاورهایاند یا واژههایشان با اسناد فرق دارد | عامل با مدل واقعی برای تحلیل و بازنویسی — قاعدهها فقط بخش کوچکی را درست میکنند |
| پرسشهای چندبخشی یا مبهم زیاد است | عامل: این را پایپلاین خطی اصلاً نمیتواند |
| هزینهٔ پاسخ غلط بالاست (حقوقی، پزشکی) | عامل با خودآزمایی + بازبین انسانی |
قانون عملی: اول پایپلاین ساده را بسنجید، ببینید کجا شکست میخورد (درس ۱۰)، و فقط برای همان شکست یک گره اضافه کنید.
انسان در حلقه (Human-in-the-loop)¶
برای پاسخهای کماطمینان (خودآزمایی ضعیف و تلاشها تمام) میتوانیم بهجای «نمیدانم»، از یک انسان بپرسیم. لنگگراف این را با interrupt() میکند: اجرای گراف متوقف میشود، حالت در checkpointer ذخیره میشود و بعداً — حتی در یک درخواست HTTP دیگر — از همان نقطه ادامه مییابد:
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command
from parsrag.agent import AgenticRAG, build_graph, run_graph
agent = AgenticRAG(rag, llm, human_review=True)
graph = build_graph(agent, checkpointer=InMemorySaver())
config = {"configurable": {"thread_id": "ticket-42"}}
paused = run_graph(agent, graph, "…", config)
paused["__interrupt__"][0].value
# {'question': '…', 'draft': '…', 'faithfulness': 0.3, 'sources': [...]}
graph.invoke(Command(resume={"action": "approve"}), config) # تأیید
graph.invoke(Command(resume={"action": "edit", "answer": "… [1]"}), config) # اصلاح
graph.invoke(Command(resume={"action": "reject"}), config) # «نمیدانم»
پاسخ اصلاحشده هم دوباره از finalize درس ۰۹ رد میشود، پس ارجاعهایش اعتبارسنجی میشوند. بدون لنگگراف، میتوانید یک تابع بازبین بدهید: AgenticRAG(rag, human_review=lambda payload: {...}).
checkpointer ماندگار: PostgreSQL (اختیاری)¶
InMemorySaver با بسته شدن برنامه پاک میشود — برای تست کافی است. در سرویس واقعی، حالت را در پایگاه داده نگه دارید (همان PostgreSQL درس ۰۶):
from langgraph.checkpoint.postgres import PostgresSaver
with PostgresSaver.from_conn_string("postgresql://user:pass@localhost:5432/parsrag") as saver:
saver.setup() # یک بار: ساخت جدولها
graph = build_graph(agent, checkpointer=saver)
graph.invoke({"question": "…"}, {"configurable": {"thread_id": "ticket-42"}})
این بخش در CI اجرا نمیشود (پایگاه داده لازم دارد)؛ تستها فقط InMemorySaver را به کار میبرند.
با یک مدل واقعی، روی کامپیوتر خودتان¶
pip install -e ".[openai,agent]"
python examples/agentic_rag.py --llm qwen2.5:7b --base-url http://localhost:11434/v1
این، همهٔ قدمها (تحلیل، قضاوت، تولید، خودآزمایی، بازنویسی) را به مدل شما میسپارد. سه چیز را نگاه کنید: آیا مدل شکاف «کرایه ↔ هزینه» و «پایتخت ↔ تهران» را با بازنویسی پُر میکند؟ چند درصد جوابها در قالب خواستهشده نیستند (تجزیهگر پیشفرض امن برمیگرداند)؟ و تأخیر واقعی هر پرسش با ۴ تا ۶ فراخوانی چقدر است؟ هیچ عددی از مدل واقعی در این درس نیامده — آن را خودتان بسازید.
تمرین¶
فایل lessons/11-agentic-rag/starter/agent.py را کامل کنید. گرههای اصلی، پرامپتها و مدل آفلاین آمادهاند؛ شما کنترل عامل را مینویسید:
| تابع | راهنمایی |
|---|---|
split_questions |
بعد از «؟» ببُرید؛ در «؛» و «و» فقط اگر هر دو طرف واژهٔ پرسشی دارند |
parse_analysis |
_field و _ROUTES؛ پیشفرضهای امن |
parse_grades |
_GRADE؛ بدون حکم = نامرتبط |
AgenticRAG.grade |
زیر آستانهٔ BM25 بدون فراخوانی؛ وگرنه یک فراخوانی برای همهٔ متنها |
| سه مسیریاب | جدول و کد بالا؛ can_retry |
AgenticRAG.run |
حلقه روی nodes و edges؛ trace اضافه میشود، بقیه جایگزین |
build_graph |
StateGraph، add_node، add_conditional_edges، compile(checkpointer=…) |
آخرین بررسی، عامل شما را روی هر دو مجموعهٔ پرسش اجرا میکند و انتظار دارد عددهای جدول بالا را بازتولید کنید. 🎉
برای کنجکاوها¶
max_retriesرا ۰، ۱ و ۴ کنید. روی بازنویسیها نرخ پاسخ و تعداد فراخوانیها چطور عوض میشود؟ نقطهٔ «بازده نزولی» کجاست؟- بازنویسی آفلاین را با بازخورد شبهربط (Pseudo-relevance feedback) عوض کنید: واژههای پرتکرار عنوان بهترین قطعه را به پرسش اضافه کنید. روی
qa.jsonlتنظیم کنید و فقط بعد روی بازنویسیها گزارش دهید. - یک «مسیریاب ارزان» بسازید: عامل فقط وقتی اجرا شود که امتیاز BM25 زیر آستانه است، وگرنه پایپلاین ساده. هزینه و کیفیت را با هر دو ستون بالا مقایسه کنید.
مطالعهٔ بیشتر¶
- Yao et al.، ReAct: Synergizing Reasoning and Acting in Language Models (2022) — ریشهٔ ایدهٔ «فکر کن، عمل کن، مشاهده کن».
- Asai et al.، Self-RAG: Learning to Retrieve, Generate, and Critique through Self-Reflection (2023) — قضاوت و خودآزمایی بهعنوان بخشی از تولید.
- Yan et al.، Corrective Retrieval Augmented Generation (CRAG، 2024) — ارزیاب بازیابی و تلاش دوباره.
- Jeong et al.، Adaptive-RAG (2024) — مسیریابی بر اساس سختی پرسش؛ همان ایدهٔ «عامل فقط وقتی لازم است».
- مستندات LangGraph: مفاهیم
StateGraph، persistence و human-in-the-loop.
چالشها (اختیاری)¶
تمرینهایی باز، بدون پاسخ مرجع و بدون بررسی Grader، برای وقتی که میخواهید جلوتر بروید:
- قاعدههای بدون نشت. قاعدههای محاورهای تازه را فقط از روی پرسشهایی بنویسید که خودتان ساختهاید (نه
qa_paraphrase.jsonl) و بعد روی بازنویسیها بسنجید. چقدر از بهبود درس باقی میماند؟ - عامل با مدل واقعی. عامل را با
--llmاجرا کنید و تعداد فراخوانی، تأخیر و F1 پاسخ را با پایپلاین درس ۰۹ مقایسه کنید. خودبررسی وفاداری حالا چند بار فعال میشود؟
درس بعد¶
درس ۱۲ — API تولیدی: همین سیستم را پشت FastAPI میگذاریم، با استریم، کش، محدودیت نرخ و Docker.