بناء نظام RAG في C# مع Semantic Kernel

· 6 دقيقة قراءة

مقدمة

إذا حاولت استخدام LLM للإجابة على أسئلة حول بياناتك الخاصة - مستندات الشركة، ومواصفات المنتج، وقواعد المعرفة الداخلية - فمن المحتمل أنك لاحظت أنها إما تهذي أو تقول فقط “ليس لدي معلومات حول ذلك”. وذلك لأن النموذج يعرف فقط ما تم تدريبه عليه.

يعمل RAG (جيل الاسترجاع المعزز) على إصلاح هذه المشكلة. بدلاً من ضبط نموذج على بياناتك، يمكنك استرداد الأجزاء ذات الصلة من مستنداتك في وقت الاستعلام وتمريرها إلى LLM كسياق. يقوم النموذج بعد ذلك بإنشاء إجابات ترتكز على بياناتك الفعلية.

في هذا المنشور، سأرشدك خلال عملية إنشاء مسار RAG كامل في لغة C# باستخدام Semantic Kernel.

كيف يعمل RAG

التدفق واضح ومباشر:

  1. الاستيعاب: قم بتقسيم مستنداتك إلى أجزاء، وقم بإنشاء تضمينات لكل جزء، ثم قم بتخزينها في قاعدة بيانات متجهة
  2. الاستعلام: عندما يطرح المستخدم سؤالاً، أنشئ تضمينًا للاستعلام، وابحث في قاعدة بيانات المتجهات عن أجزاء مماثلة
  3. إنشاء: قم بتمرير الأجزاء المستردة كسياق إلى LLM مع سؤال المستخدم

هذا كل شيء. يكمن السحر في التضمينات - فهي تلتقط المعنى الدلالي للنص كمتجهات، بحيث يمكنك العثور على المحتوى ذي الصلة حتى عندما لا تتطابق الكلمات تمامًا.

المتطلبات الأساسية

dotnet add package Microsoft.SemanticKernel
dotnet add package Microsoft.SemanticKernel.Connectors.AzureOpenAI
dotnet add package Microsoft.Extensions.VectorData.Abstractions
dotnet add package Microsoft.SemanticKernel.Connectors.InMemory

بالنسبة للإنتاج، يمكنك تبديل مخزن الذاكرة بـ Azure AI Search أو Qdrant أو Pinecone أو أي قاعدة بيانات متجهة أخرى مدعومة. لكن الذاكرة الداخلية مثالية للتعلم وإنشاء النماذج الأولية.

إعداد النواة

using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Connectors.AzureOpenAI;
using Microsoft.Extensions.VectorData;
using Microsoft.SemanticKernel.Connectors.InMemory;
using Microsoft.SemanticKernel.Embeddings;

var builder = Kernel.CreateBuilder();

builder.AddAzureOpenAIChatCompletion(
    deploymentName: "gpt-4o",
    endpoint: config["AzureOpenAI:Endpoint"],
    apiKey: config["AzureOpenAI:ApiKey"]);

builder.AddAzureOpenAITextEmbeddingGeneration(
    deploymentName: "text-embedding-3-small",
    endpoint: config["AzureOpenAI:Endpoint"],
    apiKey: config["AzureOpenAI:ApiKey"]);

var kernel = builder.Build();

نحتاج إلى نموذجين: أحدهما لإكمال الدردشة (الإجابة على الأسئلة) والآخر لإنشاء التضمينات (تحويل النص إلى متجهات).

تحديد نموذج البيانات

نحتاج إلى فئة لتمثيل أجزاء المستند الخاصة بنا في متجر المتجهات:

using Microsoft.Extensions.VectorData;

public class DocumentChunk
{
    [VectorStoreRecordKey]
    public string Id { get; set; } = Guid.NewGuid().ToString();

    [VectorStoreRecordData]
    public string Content { get; set; } = string.Empty;

    [VectorStoreRecordData]
    public string Source { get; set; } = string.Empty;

    [VectorStoreRecordData]
    public int ChunkIndex { get; set; }

    [VectorStoreRecordVector(1536)]
    public ReadOnlyMemory<float> Embedding { get; set; }
}

تخبر السمة VectorStoreRecordVector(1536) مخزن المتجهات بأبعاد التضمينات الخاصة بنا. ينتج النموذج text-embedding-3-small متجهات ذات 1536 بُعدًا.

تقطيع المستندات

قبل أن نتمكن من إنشاء التضمينات، نحتاج إلى تقسيم مستنداتنا إلى أجزاء يمكن التحكم فيها. إليك مقسم نص بسيط:

public static class TextChunker
{
    public static List<string> SplitText(string text, int maxChunkSize = 500, int overlap = 50)
    {
        var chunks = new List<string>();
        var paragraphs = text.Split("\n\n", StringSplitOptions.RemoveEmptyEntries);
        var currentChunk = new System.Text.StringBuilder();

        foreach (var paragraph in paragraphs)
        {
            if (currentChunk.Length + paragraph.Length > maxChunkSize && currentChunk.Length > 0)
            {
                chunks.Add(currentChunk.ToString().Trim());

                // Keep overlap from the end of the previous chunk
                var overlapText = currentChunk.ToString();
                currentChunk.Clear();
                if (overlapText.Length > overlap)
                {
                    currentChunk.Append(overlapText[^overlap..]);
                    currentChunk.Append(' ');
                }
            }

            currentChunk.Append(paragraph);
            currentChunk.Append("\n\n");
        }

        if (currentChunk.Length > 0)
        {
            chunks.Add(currentChunk.ToString().Trim());
        }

        return chunks;
    }
}

يعد التداخل مهمًا - فهو يضمن عدم فقدان السياق عند الحدود بين القطع. إذا تم تقسيم الجملة ذات الصلة إلى جزأين، فإن التداخل يعني أنها ستظهر بالكامل في واحدة منهما على الأقل.

استيعاب المستندات

الآن دعونا نجمع كل ذلك معًا لاستيعاب المستندات في متجر المتجهات الخاص بنا:

var vectorStore = new InMemoryVectorStore();
var collection = vectorStore.GetCollection<string, DocumentChunk>("documents");
await collection.CreateCollectionIfNotExistsAsync();

var embeddingService = kernel.GetRequiredService<ITextEmbeddingGenerationService>();

async Task IngestDocument(string content, string source)
{
    var chunks = TextChunker.SplitText(content);

    for (int i = 0; i < chunks.Count; i++)
    {
        var embedding = await embeddingService.GenerateEmbeddingAsync(chunks[i]);

        var chunk = new DocumentChunk
        {
            Content = chunks[i],
            Source = source,
            ChunkIndex = i,
            Embedding = embedding
        };

        await collection.UpsertAsync(chunk);
    }

    Console.WriteLine($"✅ Ingested {chunks.Count} chunks from {source}");
}

// Ingest some documents
var doc1 = await File.ReadAllTextAsync("docs/product-guide.md");
var doc2 = await File.ReadAllTextAsync("docs/faq.md");
var doc3 = await File.ReadAllTextAsync("docs/troubleshooting.md");

await IngestDocument(doc1, "product-guide.md");
await IngestDocument(doc2, "faq.md");
await IngestDocument(doc3, "troubleshooting.md");

البحث عن القطع ذات الصلة

عندما يطرح المستخدم سؤالاً، نقوم بإنشاء تضمين لاستعلامه والبحث عن أجزاء مماثلة:

async Task<List<DocumentChunk>> SearchAsync(string query, int topK = 3)
{
    var queryEmbedding = await embeddingService.GenerateEmbeddingAsync(query);

    var searchResults = await collection.VectorizedSearchAsync(
        queryEmbedding,
        new VectorSearchOptions { Top = topK });

    var results = new List<DocumentChunk>();
    await foreach (var result in searchResults.Results)
    {
        results.Add(result.Record);
    }

    return results;
}

توليد الإجابات مع السياق

الآن الجزء RAG - نأخذ الأجزاء المستردة وندرجها كسياق في موجهنا:

using Microsoft.SemanticKernel.ChatCompletion;

var chatService = kernel.GetRequiredService<IChatCompletionService>();

async Task<string> AskAsync(string question)
{
    // Step 1: Retrieve relevant chunks
    var relevantChunks = await SearchAsync(question);

    // Step 2: Build context from chunks
    var context = string.Join("\n\n---\n\n",
        relevantChunks.Select(c => $"[Source: {c.Source}]\n{c.Content}"));

    // Step 3: Generate answer with context
    var history = new ChatHistory();
    history.AddSystemMessage($$"""
        You are a helpful assistant that answers questions based on the provided context.
        Use ONLY the information from the context to answer. If the context doesn't contain
        enough information to answer the question, say "I don't have enough information
        to answer that question."

        Do not make up information. Always cite the source document when possible.

        Context:
        {{context}}
        """);

    history.AddUserMessage(question);

    var response = await chatService.GetChatMessageContentAsync(history);
    return response.Content ?? "No response generated.";
}

استخدامه

// Ask questions about your documents
var answer1 = await AskAsync("How do I reset my password?");
Console.WriteLine($"Q: How do I reset my password?\nA: {answer1}\n");

var answer2 = await AskAsync("What are the system requirements?");
Console.WriteLine($"Q: What are the system requirements?\nA: {answer2}\n");

var answer3 = await AskAsync("What's the capital of France?");
Console.WriteLine($"Q: What's the capital of France?\nA: {answer3}\n");
// Should respond with "I don't have enough information" since it's not in the docs

الانتقال إلى الإنتاج

يعد مخزن المتجهات الموجود في الذاكرة رائعًا للنماذج الأولية، ولكن بالنسبة للإنتاج، ستحتاج إلى قاعدة بيانات متجهات ثابتة. يحتوي Semantic Kernel على موصلات لعدة خيارات:

# Azure AI Search
dotnet add package Microsoft.SemanticKernel.Connectors.AzureAISearch

# Qdrant
dotnet add package Microsoft.SemanticKernel.Connectors.Qdrant

# Redis
dotnet add package Microsoft.SemanticKernel.Connectors.Redis

يعد التبديل أمرًا مباشرًا نظرًا لأن جميعهم ينفذون نفس واجهة IVectorStore:

// Instead of InMemoryVectorStore, use:
using Azure;
using Microsoft.SemanticKernel.Connectors.AzureAISearch;

var vectorStore = new AzureAISearchVectorStore(
    new Azure.Search.Documents.Indexes.SearchIndexClient(
        new Uri(config["AzureAISearch:Endpoint"]),
        new AzureKeyCredential(config["AzureAISearch:ApiKey"])));
```كل شيء آخر يبقى على حاله. هذا هو جمال التجريد.

## نصائح من بناء أنظمة RAG

بعض الأشياء التي تعلمتها بالطريقة الصعبة:

- **حجم القطعة مهم جدًا.** صغير جدًا ويؤدي إلى فقدان السياق. كبيرة جدًا وتضيع الرموز المميزة على محتوى غير ذي صلة. ابدأ بـ 500-800 رمزًا وقم بالتعديل بناءً على بياناتك.
- **التداخل يمنع مشاكل الحدود.** عادةً ما يكون التداخل بين القطع من 50 إلى 100 رمزًا كافيًا.
- **استرجع أكثر مما تعتقد.** ابدأ بـ `topK = 5` وقلل إذا كنت تحصل على الكثير من الضوضاء. من الأفضل أن يكون لديك سياق إضافي بدلاً من تفويت الجزء ذي الصلة.
- **تعد مطالبات النظام أمرًا بالغ الأهمية.** كن واضحًا جدًا بشأن استخدام السياق المتوفر فقط. وبدون هذه التعليمات، سوف يهلوس النموذج بسعادة "بناءً على بيانات التدريب الخاصة به".
- **تتبع المصادر.** قم دائمًا بتخزين البيانات الوصفية مع الأجزاء الخاصة بك حتى تتمكن من ذكر مصدر الإجابة. يثق المستخدمون في الإجابات بشكل أكبر عندما يمكنهم التحقق من المصدر.
- **أعد الترتيب إذا لزم الأمر.** تشابه المتجهات ليس مثاليًا. بالنسبة للتطبيقات الهامة، أضف خطوة إعادة التصنيف باستخدام نموذج التشفير المتبادل لتحسين الدقة.

## الخلاصة

يعد RAG أحد أكثر الأنماط العملية في الذكاء الاصطناعي في الوقت الحالي. فهو يتيح لك إنشاء أنظمة أسئلة وأجوبة مدعومة بالذكاء الاصطناعي على بياناتك الخاصة دون ضبط دقيق، كما أن Semantic Kernel يجعلها نظيفة بشكل مدهش في C#. ابدأ بالمخزن الموجود في الذاكرة، وقم بإجراء عملية التقطيع والمطالبات بشكل صحيح، ثم قم بالتبديل في قاعدة بيانات متجهة حقيقية عندما تكون جاهزًا للإنتاج.

ترميز سعيد!

## الموارد

- [وثائق مخزن ناقلات النواة الدلالية](https://learn.microsoft.com/en-us/semantic-kernel/concepts/vector-store-connectors/)
- [نمط RAG مع بحث Azure AI](https://learn.microsoft.com/en-us/azure/search/retrieval-augmented-generation-overview)
- [نماذج تضمين النص](https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/models#embeddings)