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); // 搜尋
// 確保向量空間一致,搜尋才有意義