RAG در دات‌نت: پاسخ به سؤال‌ها از روی اسناد خودتان با مدل زبانی

پاسخ کوتاه: RAG یا Retrieval-Augmented Generation یعنی چند بخش مرتبط از اسناد خودتان را برای یک سؤال پیدا کنید و همراه این دستور در پرامپت بگذارید که فقط از روی همین‌ها جواب بده و به آن‌ها ارجاع بده. لازم نیست مدل روی داده‌ی شما آموزش دیده باشد؛ آن را هنگام درخواست می‌خواند. یک سیستم RAG سه مرحله دارد: ایندکس (تکه‌تکه کردن اسناد و قابل‌جست‌وجو کردنشان)، بازیابی (پیدا کردن بهترین تکه‌ها برای سؤال) و تولید (پاسخ از روی همان تکه‌ها، یا گفتن «نمی‌دانم»).

یک مثال واقعی: «از صفحه‌ی قوانین بپرس»

یک فروشگاه قوانین خرید، سیاست بازگشت وجه و صفحه‌های پرسش‌های متداول دارد. مشتری‌ها همان سؤال‌ها را با ایمیل می‌پرسند: «اگر زودتر تمدید کنم، روزهای باقی‌مانده از بین می‌رود؟» جواب در قوانین هست، ولی کسی قوانین را نمی‌خواند.

مدل عمومی قواعد این فروشگاه را نمی‌داند و اگر از آن بپرسید، ممکن است جوابی مطمئن و کلی بدهد که برای این فروشگاه غلط است. با RAG، سیستم پاراگراف مربوط به تمدید را پیدا می‌کند و به مدل می‌دهد و مدل جواب می‌دهد: «نه، تمدید یک سال کامل به تاریخ انقضای فعلی اضافه می‌کند [۱]»؛ با ارجاعی که می‌شود نشانش داد.

سه مرحله

ایندکس. هر سند را به تکه‌هایی (Chunk) از چند جمله تا چند پاراگراف تقسیم کنید، با کمی هم‌پوشانی تا ایده‌ای که روی مرز بریده شده در یک تکه کامل بیاید. هر تکه را همراه منبعش (سند، عنوان، آدرس) ذخیره کنید.

بازیابی. سؤال را به همان شکل نمایش تکه‌ها درآورید و نزدیک‌ترین‌ها را پیدا کنید. در محیط عملیاتی این کار معمولاً با Embedding انجام می‌شود: یک مدل هر تکه را به برداری از اعداد تبدیل می‌کند، طوری که متن‌های هم‌معنا به هم نزدیک باشند، و یک ذخیره‌ساز برداری نزدیک‌ترین بردارها را پیدا می‌کند. بسیاری از سیستم‌ها این را با جست‌وجوی کلیدواژه‌ای ترکیب می‌کنند (جست‌وجوی ترکیبی یا Hybrid)، چون Embedding ممکن است کدها و نام‌های دقیق را از دست بدهد.

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

کد قابل‌اجرا

این نمونه بازیابی را با سی‌شارپ ساده (یک ایندکس کوچک TF-IDF) انجام می‌دهد تا فقط با SDK اجرا شود؛ توضیحات کد نشان می‌دهد Embedding کجا جایگزین می‌شود. یک برنامه‌ی کنسول بسازید، dotnet add package Anthropic را اجرا کنید، ANTHROPIC_API_KEY را تنظیم کنید و Program.cs را جایگزین کنید:

using Anthropic;
using Anthropic.Models.Messages;

// 1. Index: split documents into overlapping chunks (done once, offline).
string[] documents =
[
    "Refunds: if the app does not run and support cannot fix it, the full amount is refunded within 7 days of approval.",
    "Licenses are valid for 365 days. Renewing adds a full year to the current expiry date, so remaining days are not lost.",
    "Orders are approved by hand after the card-to-card transfer is matched, usually the same day and within 72 hours at most.",
    "DiskWatch never deletes a file without confirmation. Keep backups of important data before cleaning up.",
];
var index = new KeywordIndex(documents.SelectMany(d => Chunker.Split(d, maxWords: 40, overlapWords: 8)));

// 2. Retrieve: find the chunks closest to the question.
var question = "If I renew two weeks early, do I lose those two weeks?";
var hits = index.Search(question, top: 2);

// 3. Generate: answer from the retrieved chunks only, and say so when they don't cover it.
var context = string.Join("\n", hits.Select((h, i) => $"<source id=\"{i + 1}\">{h}</source>"));
AnthropicClient client = new();
var response = await client.Messages.Create(new MessageCreateParams
{
    Model = "claude-opus-5",
    MaxTokens = 1024,
    System = "Answer only from the <source> blocks. Cite sources like [1]. If they don't contain the answer, say you don't know.",
    Messages = [new UserMessageParam { Content = $"{context}\n\nQuestion: {question}" }],
});

Console.WriteLine(response.Content.Select(b => b.Value).OfType<TextBlock>().First().Text);

static class Chunker
{
    public static IEnumerable<string> Split(string text, int maxWords, int overlapWords)
    {
        var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries);
        for (var start = 0; start < words.Length; start += maxWords - overlapWords)
        {
            yield return string.Join(' ', words.Skip(start).Take(maxWords));
            if (start + maxWords >= words.Length)
            {
                yield break;
            }
        }
    }
}

/// <summary>A tiny TF-IDF retriever. In production, swap it for embeddings in a vector store; the RAG flow stays the same.</summary>
sealed class KeywordIndex
{
    private readonly List<(string Text, Dictionary<string, double> Vector)> _chunks;
    private readonly Dictionary<string, double> _idf;

    public KeywordIndex(IEnumerable<string> chunks)
    {
        var tokenized = chunks.Select(c => (Text: c, Terms: Tokens(c))).ToList();
        _idf = tokenized.SelectMany(c => c.Terms.Distinct()).GroupBy(t => t)
            .ToDictionary(g => g.Key, g => Math.Log(1.0 + tokenized.Count / (double)g.Count()));
        _chunks = tokenized.Select(c => (c.Text, Vectorize(c.Terms))).ToList();
    }

    public IReadOnlyList<string> Search(string query, int top)
    {
        var q = Vectorize(Tokens(query));
        return _chunks.OrderByDescending(c => Cosine(q, c.Vector)).Take(top).Select(c => c.Text).ToList();
    }

    private Dictionary<string, double> Vectorize(IEnumerable<string> terms) =>
        terms.GroupBy(t => t).ToDictionary(g => g.Key, g => g.Count() * _idf.GetValueOrDefault(g.Key));

    private static double Cosine(Dictionary<string, double> a, Dictionary<string, double> b)
    {
        var dot = a.Sum(kv => kv.Value * b.GetValueOrDefault(kv.Key));
        var norm = Math.Sqrt(a.Values.Sum(v => v * v)) * Math.Sqrt(b.Values.Sum(v => v * v));
        return norm == 0 ? 0 : dot / norm;
    }

    private static string[] Tokens(string text) =>
        text.ToLowerInvariant().Split([' ', ',', '.', ':', '?', '!'], StringSplitOptions.RemoveEmptyEntries);
}

نکته‌های مهم این کد:

  • System Prompt همان قاعده‌ی تکیه بر منبع است. «فقط از منابع جواب بده، ارجاع بده، وگرنه بگو نمی‌دانم.» بدون بخش آخر، مدل جاهای خالی را با دانش عمومی پر می‌کند.
  • جداکننده‌های <source id="…"> متن اسناد را از دستورهای شما جدا نگه می‌دارند و به مدل چیزی برای ارجاع می‌دهند.
  • بازیاب قابل‌تعویض است. وقتی به Embedding مهاجرت می‌کنید فقط KeywordIndex.Search عوض می‌شود: یک بار تکه‌ها را Embed کنید، بردارها را ذخیره کنید، سؤال را Embed کنید و نزدیک‌ترین تکه‌ها را برگردانید.

مهاجرت به Embedding

وقتی جست‌وجوی کلیدواژه‌ای دیگر کافی نیست (کاربرها با کلماتی متفاوت از متن اسناد، یا به زبانی دیگر، سؤال می‌پرسند)، Embedding اضافه کنید:

  1. یک مدل Embedding انتخاب کنید. Anthropic خودش مدل Embedding ارائه نمی‌دهد و مستنداتش به ارائه‌دهنده‌هایی مثل Voyage AI اشاره می‌کند. برای تکه‌ها و سؤال‌ها از یک مدل استفاده کنید.
  2. هر تکه را یک بار Embed کنید و بردار را کنار متن تکه و منبعش ذخیره کنید.
  3. اگر می‌شود، بردارها را همان جایی ذخیره کنید که داده‌تان هست: SQL Server 2025 نوع داده‌ی برداری اضافه کرده و PostgreSQL افزونه‌ی pgvector را دارد. دیتابیس برداری اختصاصی در مقیاس بزرگ‌تر می‌ارزد.
  4. جست‌وجوی کلیدواژه‌ای را هم نگه دارید و نتیجه‌ها را ترکیب کنید (Hybrid). کدهایی مثل AB12CD34EF با کلیدواژه پیدا می‌شوند، نه با معنا.

اول اندازه بگیرید، بعد تنظیم کنید

فهرستی از ۲۰ تا ۵۰ سؤال واقعی بسازید، همراه تکه‌ای که باید به هر کدام جواب بدهد. دو چیز را جدا اندازه بگیرید: آیا بازیابی تکه‌ی درست را برگرداند (در ۳ یا ۵ نتیجه‌ی اول)، و آیا جواب درست و متکی به همان تکه بود. بیشتر جواب‌های بد به بازیابی برمی‌گردد؛ پس پیش از بازنویسی پرامپت، Chunking و جست‌وجو را درست کنید.

کی RAG، کی نه

استفاده کنید وقتی

  • جواب‌ها باید از اسناد، سیاست‌ها یا رکوردهای خودتان بیاید.
  • داده تغییر می‌کند؛ ایندکس دوباره در مقایسه با آموزش دوباره ارزان است.
  • به ارجاعی نیاز دارید که کاربر یا حسابرس بتواند بررسی کند.

کنار بگذارید وقتی

  • اسناد راحت در پنجره‌ی کانتکست جا می‌شوند و زیاد تغییر نمی‌کنند: همه را در پرامپت بگذارید (با Prompt Caching) و بازیابی را حذف کنید.
  • سؤال محاسبه یا استعلام زنده لازم دارد: این کار فراخوانی ابزار است (ببینید ایجنت‌ها و فراخوانی ابزار) یا یک سرور MCP (ببینید MCP با سی‌شارپ)، نه بازیابی.

جایگاه این مرحله در کل مسیر: نقشه‌ی راه AI Engineering.


می‌خواهید «از اسناد ما بپرس» را روی همین استک دات‌نت فعلی‌تان داشته باشید؟ ببینید چطور می‌توانم کمک کنم.

پرسش‌های متداول

RAG بهتر است یا Fine-tune؟

برای پاسخ دادن از روی اسناد خودتان که تغییر هم می‌کنند، معمولاً RAG. داده‌ی به‌روز را هنگام درخواست استفاده می‌کند و می‌تواند به آن ارجاع دهد؛ Fine-tune سبک و عادت مدل را تغییر می‌دهد ولی جای خوبی برای نگه داشتن واقعیت‌های متغیر نیست.

برای شروع دیتابیس برداری لازم است؟

نه. چند هزار تکه در حافظه جا می‌شود و وقتی بزرگ‌تر شدید، SQL Server 2025، PostgreSQL با pgvector و دیتابیس‌های اختصاصی مثل Qdrant همه بردار را پشتیبانی می‌کنند. با ساده‌ترین ذخیره‌ساز شروع کنید و اول کیفیت بازیابی را اندازه بگیرید.

چرا سیستم RAG من هنوز جواب غلط می‌دهد؟

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