☕ NEW! 完成新手任務即可參加抽獎!LINE 星巴克禮券等你拿,名額有限!        🎉 推廣活動:邀請好友註冊 DevLearn,累積推薦抽 LINE 星巴克禮券! 活動詳情 →        🔥 活動期間 2026/4/1 - 5/31 |已有 0 人參加       
AI 模型 中級

RAG 檢索增強生成

為什麼需要 RAG?

💡 比喻:開卷考試 vs 閉卷考試

  • 沒有 RAG 的 LLM:閉卷考試,只能靠訓練時記住的知識回答

    • 問它最新的產品價格?不知道(訓練資料太舊)
    • 問它公司內部的規定?不知道(沒學過)
    • 問它昨天的新聞?不知道(知識有截止日期)
  • 有 RAG 的 LLM:開卷考試,可以翻書查資料再回答

    • 問任何問題,先從資料庫搜尋相關文件
    • 把找到的資料放進 prompt 中
    • LLM 根據這些資料生成回答

RAG 解決了什麼問題?

// 問題 1:知識截止日期
// LLM 的訓練資料有截止日期
var llmKnowledge = new LLMInfo          // LLM 的知識範圍
{
    TrainingCutoff = "2024-04",        // 訓練資料截止到 2024 年 4 月
    // 2024 年 5 月之後的事都不知道     // 包括新產品、新法規、新技術
};

// 問題 2:缺乏私有知識
// LLM 不知道你公司的內部資料
var question = "我們公司的請假流程是什麼?"; // LLM 答不出來
// 因為訓練資料裡沒有你公司的員工手冊          // 這是私有資訊

// 問題 3:幻覺 (Hallucination)
// LLM 可能會「編造」看起來合理但錯誤的答案
// RAG 提供了參考資料,大幅降低幻覺的可能性

// RAG 的解法:把相關資料餵給 LLM
var ragAnswer = await AskWithRAG(question);    // 先搜尋再回答
// 1. 搜尋向量資料庫,找到「員工手冊.pdf」     // 找到相關文件
// 2. 取出相關段落放進 prompt                   // 提供參考資料
// 3. LLM 根據這些資料生成正確回答              // 有依據的回答

Embedding 向量化概念

💡 比喻:GPS 座標 想像每個詞都有一個 GPS 座標:

  • 「貓」的座標是 (3.2, 1.5, 7.8, ...)
  • 「狗」的座標是 (3.1, 1.6, 7.9, ...)(很接近,因為都是寵物)
  • 「汽車」的座標是 (8.5, 4.2, 1.1, ...)(很遠,因為完全不同類別)

意思相近的詞,座標就很接近。這就是 Embedding。

// Embedding 把文字轉成數字向量
// 向量的維度通常是 768 或 1536(很多數字組成一個座標)

var embeddingService = new EmbeddingService();   // 嵌入服務

// 把文字轉成向量
var vec1 = await embeddingService                // 文字 → 向量
    .EmbedAsync("如何在 C# 中使用 LINQ?");    // 關於 C# LINQ 的問題
// vec1 = [0.023, -0.156, 0.892, ...]            // 1536 維的向量

var vec2 = await embeddingService                // 文字 → 向量
    .EmbedAsync("C# 的 LINQ 查詢語法");         // 類似的問題
// vec2 = [0.025, -0.148, 0.885, ...]            // 跟 vec1 很接近!

var vec3 = await embeddingService                // 文字 → 向量
    .EmbedAsync("今天晚餐吃什麼?");             // 完全不同的問題
// vec3 = [0.754, 0.332, -0.102, ...]            // 跟 vec1 差很遠

// 計算相似度(餘弦相似度)
double CosineSimilarity(                          // 計算兩個向量的相似度
    float[] a, float[] b)                         // 輸入兩個向量
{
    var dotProduct = a.Zip(b,                     // 對應元素相乘
        (x, y) => x * y).Sum();                   // 然後加總
    var magnitudeA = Math.Sqrt(                   // 計算向量 A 的長度
        a.Sum(x => x * x));                       // 各元素平方和開根號
    var magnitudeB = Math.Sqrt(                   // 計算向量 B 的長度
        b.Sum(x => x * x));                       // 各元素平方和開根號
    return dotProduct /                            // 內積除以
        (magnitudeA * magnitudeB);                 // 兩個長度的乘積
}

// CosineSimilarity(vec1, vec2) ≈ 0.95  → 非常相似!
// CosineSimilarity(vec1, vec3) ≈ 0.12  → 完全不同

向量資料庫

💡 比喻:智慧型圖書館 傳統資料庫像是用書名或作者找書(精確搜尋)。 向量資料庫像是說「我想找關於貓咪健康的書」, 圖書館員會找出所有相關的書,不管標題有沒有「貓」這個字。

// 常見的向量資料庫比較
// ┌──────────────┬────────────────┬───────────────┬──────────┐
// │ 向量資料庫   │ 特色           │ 部署方式      │ 適合場景 │
// ├──────────────┼────────────────┼───────────────┼──────────┤
// │ Pinecone     │ 全託管,簡單   │ 雲端 SaaS     │ 快速上手 │
// │ Qdrant       │ 開源,效能好   │ 自架或雲端    │ 進階使用 │
// │ ChromaDB     │ 超簡單,適合學 │ 本地嵌入式    │ 原型開發 │
// │ Weaviate     │ 功能豐富       │ 自架或雲端    │ 企業級   │
// │ pgvector     │ PostgreSQL 擴充│ 現有 PG 資料庫│ 已用 PG  │
// └──────────────┴────────────────┴───────────────┴──────────┘

// 使用 Qdrant 的 C# 範例
using Qdrant.Client;                              // 引用 Qdrant 套件
using Qdrant.Client.Grpc;                         // gRPC 通訊協定

var qdrantClient = new QdrantClient("localhost"); // 連接到本地 Qdrant

// 建立 Collection(類似資料庫的 Table)
await qdrantClient.CreateCollectionAsync(          // 建立集合
    "my_documents",                               // 集合名稱
    new VectorParams                                // 向量參數設定
    {
        Size = 1536,                                // 向量維度(配合嵌入模型)
        Distance = Distance.Cosine                  // 使用餘弦相似度
    });

// 插入文件向量
await qdrantClient.UpsertAsync(                    // 新增或更新向量
    "my_documents",                               // 集合名稱
    new List<PointStruct>                           // 資料點列表
    {
        new()                                       // 一個文件的向量
        {
            Id = 1,                                 // 文件 ID
            Vectors = embeddingVector,              // 嵌入向量
            Payload =                               // 附加資料(原文)
            {
                ["text"] = "C# 是一種強型別語言...", // 原始文字
                ["source"] = "csharp-guide.pdf"      // 來源檔案
            }
        }
    });

// 搜尋相似文件
var searchResult = await qdrantClient               // 執行相似度搜尋
    .SearchAsync("my_documents",                   // 在這個集合中搜尋
        queryVector,                                 // 查詢向量
        limit: 5);                                   // 取前 5 筆最相似的

RAG Pipeline:切割 → 嵌入 → 檢索 → 生成

RAG 完整流程
┌─────────────────────────────────────────────────────────┐
│ 離線階段(準備資料)                                      │
│                                                          │
│  文件 ──→ 切割成 Chunk ──→ Embedding ──→ 存入向量 DB    │
│  📄          ✂️              🔢              💾          │
│                                                          │
├─────────────────────────────────────────────────────────┤
│ 線上階段(回答問題)                                      │
│                                                          │
│  使用者問題 ──→ Embedding ──→ 搜尋向量 DB ──→ 取回相關文件│
│  ❓              🔢              🔍              📋      │
│                                                          │
│  相關文件 + 問題 ──→ 組合 Prompt ──→ LLM 生成回答       │
│  📋    ❓              📝              🤖   💬          │
└─────────────────────────────────────────────────────────┘
// 完整 RAG Pipeline 的 C# 實作概念
public class RagPipeline // RAG 管線
{
    private readonly IEmbeddingService _embedding; // 嵌入服務
    private readonly IVectorStore _vectorStore;     // 向量資料庫
    private readonly ILlmService _llm;              // LLM 服務

    // 步驟 1:切割文件成小段落
    public List<string> ChunkDocument(             // 切割文件的方法
        string document, int chunkSize = 500)      // 每段預設 500 字元
    {
        var chunks = new List<string>();            // 儲存切割後的段落
        for (int i = 0; i < document.Length;        // 從頭到尾遍歷文件
             i += chunkSize)                        // 每次跳一個 chunk 的大小
        {
            var chunk = document.Substring(         // 取出一段文字
                i, Math.Min(chunkSize,              // 取 chunkSize 或剩餘長度
                document.Length - i));               // 避免超出範圍
            chunks.Add(chunk);                      // 加入列表
        }
        return chunks;                              // 回傳所有段落
    }

    // 步驟 2:嵌入並存入向量資料庫
    public async Task IndexDocumentAsync(           // 索引文件的方法
        string document, string source)             // 文件內容和來源
    {
        var chunks = ChunkDocument(document);       // 先切割文件
        foreach (var chunk in chunks)               // 對每個段落
        {
            var vector = await _embedding           // 轉成向量
                .EmbedAsync(chunk);                  // 呼叫嵌入 API
            await _vectorStore.StoreAsync(           // 存入向量資料庫
                vector, chunk, source);              // 向量、原文、來源
        }
    }

    // 步驟 3:檢索相關文件
    public async Task<List<string>> RetrieveAsync(  // 檢索的方法
        string question, int topK = 5)              // 問題和取回數量
    {
        var queryVector = await _embedding           // 把問題轉成向量
            .EmbedAsync(question);                    // 呼叫嵌入 API
        var results = await _vectorStore              // 搜尋向量資料庫
            .SearchAsync(queryVector, topK);           // 取最相似的 topK 筆
        return results.Select(r => r.Text)            // 取出原文
            .ToList();                                 // 轉成列表
    }

    // 步驟 4:組合 Prompt 並生成回答
    public async Task<string> AskAsync(              // 完整的 RAG 問答
        string question)                              // 使用者的問題
    {
        var relevantDocs = await RetrieveAsync(       // 先檢索相關文件
            question);                                 // 用問題去搜尋
        var context = string.Join("\n\n",            // 把文件合併
            relevantDocs);                              // 用換行分隔

        var prompt = $@"根據以下參考資料回答問題。      // 組合 prompt
如果資料中沒有提到,請說「根據現有資料無法回答」。

參考資料:
{context}

問題:{question}

請用繁體中文回答:";                                   // 指定語言

        return await _llm.GenerateAsync(prompt);       // 呼叫 LLM 生成回答
    }
}

Chunk 策略

💡 比喻:切蛋糕

  • 固定大小切割:每隔 5 公分切一刀(簡單但可能切到裝飾)
  • 語意切割:沿著蛋糕的分層線切(保留完整的口味層次)
// 策略 1:固定大小切割 (Fixed-size Chunking)
public List<string> FixedSizeChunk(               // 固定大小切割
    string text, int size = 500, int overlap = 50) // 大小 500,重疊 50
{
    var chunks = new List<string>();                // 結果列表
    for (int i = 0; i < text.Length;                // 從頭開始
         i += size - overlap)                       // 每次前進 size - overlap
    {
        var end = Math.Min(i + size, text.Length);  // 計算結束位置
        chunks.Add(text.Substring(i, end - i));     // 取出一段
    }
    return chunks;                                  // 回傳所有段落
}
// 優點:簡單快速
// 缺點:可能在句子中間切斷,破壞語意

// 策略 2:語意切割 (Semantic Chunking)
public List<string> SemanticChunk(                 // 語意切割
    string text)                                    // 輸入文字
{
    var paragraphs = text.Split(                    // 先按段落分割
        new[] { "\n\n" },                          // 雙換行通常是段落分界
        StringSplitOptions.RemoveEmptyEntries);      // 移除空段落

    var chunks = new List<string>();                 // 結果列表
    var current = new StringBuilder();               // 目前的 chunk

    foreach (var para in paragraphs)                 // 遍歷每個段落
    {
        if (current.Length + para.Length > 800)       // 如果加了會太長
        {
            chunks.Add(current.ToString());           // 先存目前的 chunk
            current.Clear();                          // 清空重來
        }
        current.AppendLine(para);                     // 加入段落
    }

    if (current.Length > 0)                           // 處理最後剩餘的
        chunks.Add(current.ToString());               // 加入最後一段
    return chunks;                                    // 回傳所有段落
}
// 優點:保留完整語意
// 缺點:chunk 大小不一致

// 策略 3:遞迴切割 (Recursive Chunking)
// 先按大段落切 → 太長的按小段落切 → 還太長的按句子切
// 這是 LangChain 預設的策略
var separators = new[] { "\n\n", "\n", "。", ",", " " }; // 分隔符優先級
// 先嘗試用雙換行切,不夠再用單換行,再不夠用句號...

C# 實作 RAG 範例 (Semantic Kernel)

// 使用 Microsoft Semantic Kernel 實作 RAG
// 安裝套件:
// dotnet add package Microsoft.SemanticKernel
// dotnet add package Microsoft.SemanticKernel.Connectors.OpenAI

using Microsoft.SemanticKernel;                    // Semantic Kernel 核心
using Microsoft.SemanticKernel.Memory;             // 記憶功能
using Microsoft.SemanticKernel.Connectors.OpenAI;  // OpenAI 連接器

// 建立 Kernel
var builder = Kernel.CreateBuilder();               // 建立 Kernel 建構器
builder.AddOpenAIChatCompletion(                    // 加入 OpenAI 聊天模型
    "gpt-4o",                                     // 模型名稱
    "your-api-key");                               // API 金鑰

var kernel = builder.Build();                       // 建立 Kernel 實例

// 建立記憶儲存
var memoryBuilder = new MemoryBuilder();             // 記憶建構器
memoryBuilder.WithOpenAITextEmbeddingGeneration(     // 加入嵌入模型
    "text-embedding-3-small",                       // 嵌入模型名稱
    "your-api-key");                                // API 金鑰
memoryBuilder.WithMemoryStore(                       // 記憶儲存
    new VolatileMemoryStore());                       // 使用記憶體儲存(開發用)

var memory = memoryBuilder.Build();                   // 建立記憶實例

// 步驟 1:將文件存入記憶
var documents = new Dictionary<string, string>        // 準備文件
{
    ["doc1"] = "C# 的 var 關鍵字用於隱式型別推斷...",  // 第一份文件
    ["doc2"] = "LINQ 提供了統一的資料查詢語法...",     // 第二份文件
    ["doc3"] = "async/await 是 C# 的非同步程式模式...", // 第三份文件
};

foreach (var doc in documents)                         // 逐一處理文件
{
    await memory.SaveInformationAsync(                 // 存入記憶
        "csharp-docs",                                // 集合名稱
        doc.Value,                                      // 文件內容
        doc.Key);                                       // 文件 ID
}

// 步驟 2:搜尋相關文件
var question = "如何使用非同步程式設計?";              // 使用者的問題
var searchResults = memory.SearchAsync(                 // 搜尋記憶
    "csharp-docs",                                    // 集合名稱
    question,                                           // 搜尋查詢
    limit: 3);                                          // 取前 3 筆

var context = new StringBuilder();                      // 組合搜尋結果
await foreach (var result in searchResults)              // 非同步遍歷結果
{
    context.AppendLine(result.Metadata.Text);           // 加入原文
}

// 步驟 3:組合 Prompt 並生成回答
var ragPrompt = $@"你是一個 C# 程式教師。               // 設定角色
根據以下參考資料回答問題。

參考資料:
{context}

問題:{question}

請用繁體中文、初學者友善的方式回答:";                   // 設定風格

var result = await kernel.InvokePromptAsync(            // 呼叫 LLM
    ragPrompt);                                          // 傳入完整 prompt
Console.WriteLine(result);                               // 印出回答

🤔 我這樣寫為什麼會錯?

❌ 錯誤 1:Chunk 太大或太小

// ❌ Chunk 太大(例如整份文件不切)
var bigChunk = entireDocument;          // 整份文件當一個 chunk
// 問題:搜尋時會取回太多不相關的內容
// LLM 的 Context Window 也可能放不下

// ❌ Chunk 太小(例如一句話一個)
var tinyChunks = document.Split('。');  // 每句話一個 chunk
// 問題:缺少上下文,LLM 看不懂前後關係

// ✅ 適當大小,保留重疊
var goodChunks = ChunkWithOverlap(      // 適當切割
    document,                            // 輸入文件
    chunkSize: 500,                      // 每段 500 字
    overlap: 50);                        // 重疊 50 字,保留上下文

❌ 錯誤 2:沒有處理搜尋結果為空的情況

// ❌ 假設一定能找到相關文件
var docs = await SearchAsync(question);  // 搜尋
var prompt = $"根據 {docs} 回答";       // 直接用,沒檢查
// 如果 docs 是空的,LLM 會亂回答(幻覺)

// ✅ 檢查搜尋結果
var docs = await SearchAsync(question);   // 搜尋
if (docs.Count == 0 ||                    // 沒有找到結果
    docs.All(d => d.Score < 0.5))          // 或相似度都太低
{
    return "根據現有資料無法回答此問題。";  // 誠實告知
}

❌ 錯誤 3:忽略 Embedding 模型的選擇

// ❌ 索引和搜尋用不同的 Embedding 模型
// 索引時用 text-embedding-3-small
await IndexWithModel("text-embedding-3-small", docs);  // 用 A 模型建索引

// 搜尋時用 text-embedding-ada-002
var results = await SearchWithModel(                      // 用 B 模型搜尋
    "text-embedding-ada-002", question);                 // 向量維度不同!
// 結果:完全搜不到東西,因為向量空間不一致

// ✅ 索引和搜尋必須用相同的 Embedding 模型
const string MODEL = "text-embedding-3-small";          // 統一使用同一模型
await IndexWithModel(MODEL, docs);                         // 建索引
var results = await SearchWithModel(MODEL, question);      // 搜尋
// 確保向量空間一致,搜尋才有意義

💡 大家的想法 · 0

載入中...
💬 即時聊天室 🟢 0 人在線
😀 😎 🤓 💻 🎮 🎸 🔥
➕ 新問題
📋 我的工單
💬 LINE 社群
🔒
需要註冊才能使用此功能
註冊帳號即可解鎖測驗、遊戲、簽到、筆記下載等所有功能,完全免費!
免費註冊