NLP Day 25 RAG 架構總覽:檢索、增強與生成
執行需求:CPU 可跑。本篇以「架構圖+小型端到端 demo」說明檢索增強生成(RAG)的三段流程:檢索(retrieval)、增強(augmentation)、生成(generation)。範例用 Day 24 的維基百科資料與 Qdrant 1.12 做檢索,搭配一個簡單的 prompt 模板與「抽取式回答」當作本地生成器,整個 demo 在 CPU 上 5 分鐘內完成;章節末會展示如何把本地生成器換成 Ollama 上的小模型,整篇啟用一個「不用 API Key 也能完整跑」的 RAG 流程。
引言
Day 24 我們用 sentence-transformers 與 Qdrant 1.12 做了一個能用的語意搜尋系統:給定查詢文字、回傳最相關的幾個段落。這個搜尋本身已經有價值(例如做文件推薦、相似段落比對),但對大多數產品來說還差一步——使用者想看的是「自然語言的答案」,而不是「五個段落讓我自己讀」。這就是檢索增強生成(Retrieval-Augmented Generation,RAG) 出現的原因:把檢索與 LLM 生成結合,讓模型在「有資料可查」的條件下生成答案。
RAG 的核心動機是解兩個 LLM 的老問題。第一是幻覺:LLM 會自信地編造不存在的事實,這在醫療、法律、企業內部問答是不可接受的。第二是知識截止:模型只知道自己訓練截止日的知識,無法回答 2024 年之後的事件,也無法記住企業私有資料。RAG 把「外部事實」先檢索出來、塞進 prompt、再讓模型基於這些事實回答,從而把生成內容鎖在可控範圍內。這是 2020 年 Lewis 等人在 Meta 發表的原始論文之後,整個產業大量採用的工作流。
更精確地說,RAG 系統在「幻覺」問題上只能緩解、無法完全消除。即使 prompt 中放入了正確的段落,LLM 仍可能在生成時偏離資料、加入自己的猜測,或對「資料未提到」的細節自由發揮。這就是為什麼 Day 29 的評估章節要把「忠實度(faithfulness)」當成關鍵指標——它衡量的是「答案真的根據提供的段落回答」這件事的可靠度。實務上 RAG 通常與其他技術疊加使用(例如 citation 標註、self-check、事後驗證),組成多層防線。
今天會把 RAG 拆成三段:檢索(retrieval,昨天的 Qdrant)、增強(augmentation,把檢索結果組裝進 prompt)、生成(generation,LLM 看 prompt 回答)。我們會用一個端到端的小型 demo 把三段串起來,並指出每一段在 RAG 系統中可以換成哪些常見實作。讀完這篇你應該了解 RAG 的工作流,以及為什麼「檢索品質」決定了整個系統的上限。明天我們會把檢索這一塊展開成完整管線:文件切分、嵌入、metadata 設計。
RAG 的三段架構
RAG 的工作流可以拆成三個階段,每個階段各自有常見實作。檢索階段負責「從大量文件中找出與查詢最相關的段落」。最常見的實作是昨天那套:先用嵌入模型把查詢編碼成向量,在向量資料庫裡 ANN 搜尋前 k 筆,再用 metadata filter 縮窄範圍。檢索階段的關鍵指標是召回率(recall):相關段落有沒有被找回來。漏召回是 RAG 系統最致命的問題,因為漏召回的段落再也無法被生成階段挽回。
增強階段負責「把檢索結果組裝成 LLM 能消化的 prompt」。最簡單的作法是把前 k 個段落的原文串接起來,前面加上「請根據以下資料回答問題」之類的指示;進階的作法會做壓縮(把段落摘要成幾個關鍵句)、排序(把最相關的放最前面,因為 LLM 對 prompt 中段的注意力通常最弱)、標記(給每個段落編號,讓 LLM 在回答時引用編號)。增強階段的關鍵指標是資訊密度:在不超過 LLM context window 的前提下,把最多相關資訊塞進去。
生成階段負責「用 LLM 根據 prompt 生成答案」。最常見的實作是用商用 API(OpenAI GPT-4o、Anthropic Claude 3.7 Sonnet、Google Gemini)或本地開源模型(Llama 3.3、Qwen 2.5、Mistral)。生成階段的關鍵指標是忠實度(faithfulness):答案有沒有真的根據提供的段落回答、還是模型自己編的。RAG 系統的回應風格可以透過 system prompt 控制:「請只根據提供的資料回答、若資料未涵蓋請回答「我不知道」」是常見的設計。
三段架構彼此解耦:你可以把檢索階段從 Qdrant 換成 BM25,增強階段加上壓縮,生成階段從 GPT-4o 換成 Llama 3.3,整個系統仍能運作。這種模組化是 RAG 受歡迎的原因之一——企業可以根據資料機密、成本、效能需求各自調整。例如醫療機構常見的「資料不能離開內網」需求,可以把生成階段鎖在本地 Llama 3.3 8B;新創公司追求「最低 API 成本」則會選擇 GPT-4o-mini 或 Claude 3.5 Haiku,這些選擇都能與同一個檢索系統共存。
完整實作:用 Day 24 的索引示範 RAG 三段
以下範例在 Colab CPU 上跑約 3 分鐘。我們沿用 Day 24 的 Qdrant 索引,示範「檢索 → 增強 → 生成」三段端到端流程;生成階段先用「抽取式回答」(直接回傳最相關段落的開頭 100 字)當本地替代方案,再展示如何把生成階段換成 Ollama 上的小模型。
# 1. 安裝套件;本篇只用到 qdrant-client 與 sentence-transformers
pip install -q qdrant-client==1.12.0 sentence-transformers==3.4.1
這段安裝兩個套件。qdrant-client 1.12 是當時穩定版,sentence-transformers 3.4 是當時對應 PyTorch 2.6 的版本。本篇沿用 Day 24 的索引與模型,不需要額外下載;若讀者直接從 Day 25 開始,可參考 Day 24 文章的「維基百科 → 段落切分 → 嵌入 → 上傳 Qdrant」流程先建立 wiki_zh collection。
# 2. 建立 retriever:包裝 Qdrant 1.12 的查詢
import numpy as np
from qdrant_client import QdrantClient
from qdrant_client.models import Filter, FieldCondition, MatchValue
from sentence_transformers import SentenceTransformer
class Retriever:
"""RAG 系統的檢索器封裝"""
def __init__(self, collection_name: str = "wiki_zh"):
self.client = QdrantClient(":memory:")
self.model = SentenceTransformer(
"sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
)
self.collection_name = collection_name
def retrieve(self, query: str, top_k: int = 3, topic: str | None = None) -> list[dict]:
q_vec = self.model.encode([query], convert_to_numpy=True).tolist()[0]
q_filter = None
if topic:
q_filter = Filter(must=[FieldCondition(key="topic", match=MatchValue(value=topic))])
hits = self.client.search(
collection_name=self.collection_name,
query_vector=q_vec,
query_filter=q_filter,
limit=top_k,
)
return [{"text": h.payload["text"], "title": h.payload["title"],
"score": h.score, "url": h.payload["url"]} for h in hits]
# 範例:對「台北 101 有多高」這個查詢跑檢索
retriever = Retriever()
hits = retriever.retrieve("台北 101 有多高", top_k=3)
for h in hits:
print(f"[{h['score']:.3f}] {h['title']}|{h['text'][:80]}…")
# 輸出(實際分數會略有不同):
# [0.812] 台北|台北 101 是台灣最高的建築,曾是世界第一高樓…
# [0.745] 台北|東京的經典地標是東京鐵塔與晴空塔…(誤召回)
# [0.621] 半導體|…
這段把 Day 24 的查詢邏輯包成 Retriever 類別。retrieve(query, top_k, topic) 回傳一個 list of dict,每個 dict 包含原文、條目名稱、相似度分數、條目網址。回傳 dict 而非 Qdrant 內部的 ScoredPoint,是為了後續增強階段好處理。注意第二名的「東京的經典地標」是誤召回——雖然與「高」「塔」相關,但查詢問的是台北 101;這種情況在 RAG 中需要靠增強階段的重新排序或 LLM 的判斷來處理,Day 28 我們會專門講 rerank。
# 3. 建立 augmenter:把檢索結果組裝成 LLM prompt
SYSTEM_PROMPT = """你是隻會根據「資料」回答事實的助理。若資料未涵蓋使用者的問題,請回答「我不知道」並說「提供的資料未涵蓋此問題」。請使用繁體中文回答。"""
def build_prompt(query: str, hits: list[dict]) -> str:
"""把檢索結果組裝成 user prompt。"""
if not hits:
return f"問題:{query}\n資料:(無)\n"
context_parts = []
for i, h in enumerate(hits, start=1):
context_parts.append(f"[資料 {i}] 來源:{h['title']}({h['url']})\n{h['text']}")
context = "\n\n".join(context_parts)
return f"問題:{query}\n\n資料:\n{context}\n\n請根據以上資料回答問題。"
# 範例 prompt
sample_prompt = build_prompt("台北 101 有多高?", hits)
print(sample_prompt[:400])
# 輸出(實際內容會略有不同):
# 問題:台北 101 有多高?
#
# 資料:
# [資料 1] 來源:台北(https://…)\n台北 101 是台灣最高的建築…
# [資料 2] 來源:台北(https://…)\n東京的經典地標是東京鐵塔與晴空塔…
# [資料 3] 來源:半導體(https://…)\n…
這段示範增強階段。build_prompt(query, hits) 把檢索結果編號(資料 1、資料 2…)、附上來源 URL、組裝成 user prompt。系統提示 SYSTEM_PROMPT 給模型三條規則:根據資料、不知道就說不知道、用繁體中文。這種 system prompt 是 RAG 系統的「契約」——把模型的角色與限制講清楚,回答品質會明顯提升。注意我們把 system prompt 與 user prompt 分開:system prompt 設定規則,user prompt 放資料與問題,這是 LangChain 0.3.x 與大多 LLM API 的慣例。
# 4. 本地生成器:抽取式回答(CPU 上即時)
def local_generator(prompt: str, hits: list[dict]) -> str:
"""沒有 LLM 時的本地替代方案:直接回傳最相關段落。"""
if not hits:
return "我不知道。"
top = hits[0]
snippet = top["text"][:120].replace("\n", " ").strip()
return f"根據「{top['title']}」條目:{snippet}…(來源:{top['url']})"
# 範例:對同一個查詢的完整 RAG 回應
query = "台北 101 有多高?"
hits = retriever.retrieve(query, top_k=3)
prompt = build_prompt(query, hits)
answer = local_generator(prompt, hits)
print(answer)
# 輸出(實際內容會略有不同):
# 根據「台北」條目:台北 101 是台灣最高的建築,曾是世界第一高樓,總高度 508 公尺,含 101 層…(來源:https://…)
這段示範本地生成器。local_generator 是 RAG 三段中的「生成」階段,但用「抽取式回答」代替 LLM:直接回傳檢索結果中分數最高的段落片段,並附上來源。這種作法沒有 LLM 的「理解」能力,但有兩個好處:第一,CPU 即時、不需要昂貴的 GPU;第二,100% 忠實於資料、不會編造。對一些簡單問答(例如客服 FAQ)這種「直白式回答」就夠用。
但對大多數自然語言問答,「抽取式」會顯得僵硬、缺乏語句通順度。這時候就要接 LLM。下一段我們把 local_generator 換成 Ollama 上的本地模型。
# 5. 換成 Ollama 上的小模型:完整本地 RAG
# 安裝:pip install ollama==0.4.0
# 並確認 Ollama 服務已啟動、已 ollama pull gemma3:4b
import os
def ollama_generator(prompt: str, hits: list[dict], model: str = "gemma3:4b") -> str:
"""用本地 Ollama 上的小模型生成。需先啟動 Ollama 服務。"""
import ollama # type: ignore
context = "\n\n".join(f"[{i}] {h['title']}:{h['text']}" for i, h in enumerate(hits, 1))
response = ollama.chat(
model=model,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": f"問題:{prompt}\n資料:\n{context}"},
],
)
return response["message"]["content"]
# 若 Ollama 服務未啟動,可改回 local_generator
try:
answer = ollama_generator("台北 101 有多高?", hits)
print("=== Ollama 生成 ===")
print(answer)
except Exception as e:
print(f"Ollama 未啟動或模型未下載:{e}")
print("改用 local_generator:")
print(local_generator("", hits))
# 輸出(Ollama 實際生成會略有不同):
# 根據資料,台北 101 是台灣最高的建築,總高度 508 公尺,地上 101 層…
# 6. 把三段封裝成單一 RAGChain:方便日後切換檢索器或生成器
class RAGChain:
"""三段式 RAG 鏈:retriever → augmenter → generator"""
def __init__(self, retriever, generator):
self.retriever = retriever
self.generator = generator
def __call__(self, query: str, **kwargs) -> dict:
hits = self.retriever.retrieve(query, **kwargs)
prompt = build_prompt(query, hits)
answer = self.generator(prompt, hits)
return {"query": query, "hits": hits, "prompt": prompt, "answer": answer}
# 範例:用 local_generator 組裝
rag = RAGChain(retriever=Retriever(), generator=local_generator)
result = rag("台北 101 的地址是什麼?", top_k=3)
print("回答:", result["answer"][:80])
# 輸出(實際內容會略有不同):
# 回答:根據「台北」條目:台北 101 座落在台北市信義區…
# 7. 簡單的評估函式:檢查生成答案是否引用了資料編號(粗略忠實度指標)
def citation_check(answer: str, hits: list[dict]) -> float:
"""計算答案中引用的資料編號比例。"""
import re
refs = re.findall(r"\[(\d+)\]", answer)
if not refs:
return 0.0
valid = sum(1 for r in refs if 1 <= int(r) <= len(hits))
return valid / len(refs)
# 範例:local_generator 通常不引用編號(0.0),LLM 應該要引用
score = citation_check(result["answer"], result["hits"])
print(f"引用分數:{score:.2f}")
# 輸出:引用分數:0.00
這段把生成器換成 Ollama。ollama_generator 呼叫 ollama.chat(),傳入 system prompt 與 user prompt(含檢索結果),回傳模型的文字生成。gemma3:4b 是當時(2025 年 3 月)Ollama 上對中文支援不錯的小模型(4B 參數、約 8 GB VRAM 或 4 GB VRAM 量化),CPU 也能跑只是較慢。若 Ollama 服務未啟動或模型未下載,except 區塊會自動 fallback 到 local_generator,整個 RAG 仍能跑——這就是 RAG 的解耦設計價值:生成階段壞了、檢索與增強仍能單獨驗證。
商用 API 的接法類似,只要把 ollama.chat() 換成 openai.ChatCompletion.create() 或 anthropic.Anthropic().messages.create() 即可。實務上多數企業 RAG 系統會用 OpenAI 或 Anthropic 的 API(中文品質穩定、context window 大),但本地小模型在「資料機密不能外流」或「成本敏感」的情境仍是首選。
常見錯誤與踩雷
錯誤一:把整個 prompt 塞太多段,導致超過 LLM context window。GPT-4o 是 128K、Claude 3.7 Sonnet 是 200K、本地 Llama 3.3 8B 通常是 8K context。塞超過 context window 會直接報 context_length_exceeded 或被截斷。對應排查:在 build_prompt 中加長度檢查、若超過上限就降 top_k 或用 Day 28 的 rerank 濃縮。
錯誤二:生成的答案引用了「資料中沒有」的內容。這是 RAG 系統最常見的失敗模式——LLM 看完 prompt 後,仍用自己的世界知識補充。對應排查:system prompt 中明確寫「只能引用資料中的編號」,並用 regex 檢查生成結果是否包含 [資料 N] 格式的編號;若沒有,表示模型在自由發揮。
錯誤三:檢索結果都是同一個條目的不同段落。如果 top_k=3 都來自同一個條目,模型會得到冗餘資訊、甚至找不到其他相關觀點。對應排查:在 build_prompt 中用 set 去重,確保每個條目最多貢獻 2 段;或在檢索階段就對來源做 diversify。
錯誤四:system prompt 與 user prompt 內容重疊。例如 system prompt 寫「請根據以下資料回答」、user prompt 又寫「請根據以下資料回答」,模型會困惑。對應排查:system prompt 只放「角色與限制」、user prompt 只放「資料與問題」。
錯誤五:local_generator 在沒有檢索結果時直接當機。hits 是空 list 時,hits[0] 會 IndexError。對應排查:在函式開頭加 if not hits: return "我不知道。",讓無資料的查詢回傳固定字串而不是崩潰。
效能與實務提醒
本篇 demo 的時間分配:檢索階段(Qdrant ANN)每次小於 30 毫秒;增強階段(拼 prompt)小於 1 毫秒;生成階段(Ollama gemma3:4b)每次 1–3 秒;OpenAI API 端到端(含網路)約 1–2 秒。整體延遲瓶頸在 LLM 生成,因此 RAG 系統的延遲最佳化主要落在生成階段(用更快的模型、量化、串流輸出)。
成本面上,商用 API 約每千次查詢 0.5–3 美元(依模型與輸入長度),本地 Ollama 約每千次查詢 0 美元但需要 GPU。對中小企業來說,每天 1,000 筆查詢用 GPT-4o-mini 約 0.5 美元、用本地 Llama 3.3 8B 約 0 美元——這是 RAG 系統選型時最常被討論的權衡點之一。
更重要的提醒:檢索品質決定 RAG 上限。如果檢索找不到相關段落,再貴的 LLM 也救不回來;反過來,檢索能找到好段落、LLM 寫得「普通」,整體回答品質仍可接受。這也是 Day 26–28 都在講檢索優化(切分、查詢改寫、rerank)的原因。明天我們會把「文件切分」這個檢索優化的關鍵環節展開,從 fixed、recursive 到 semantic 三種策略的差別。
小結
今天把 RAG 拆成三段:檢索(Day 24 的 Qdrant)、增強(把檢索結果組裝成 prompt)、生成(LLM 看 prompt 回答)。我們用 Retriever、build_prompt、local_generator 三個函式把三段串起來,整個 demo 在 CPU 上 5 分鐘內完成,並展示了把 local_generator 換成 Ollama 上的小模型或商用 API 的方法。重點回顧:檢索品質決定 RAG 上限、system prompt 要明確「只能引用資料」、每段都要有 fallback、top_k 不能太大以免超過 context window。我們用「台北 101 有多高」這個查詢走了完整 RAG 流程,並展示了誤召回案例(東京的塔被召回)需要靠 rerank 處理。明天會把檢索的第一步「文件切分」展開成完整管線,從 fixed、recursive 到 semantic 三種策略。
結語
今天的重點是「把 RAG 拆成三段並各自展示最小可行實作」。我們從「為什麼需要 RAG」出發(解幻覺、補知識截止),介紹了三段架構的分工與各自的關鍵指標,並用 Day 24 的維基百科索引 + Qdrant 1.12 做檢索,用簡單的 build_prompt 做增強,用「抽取式回答」做本地生成,最後把生成器換成 Ollama 上的 gemma3:4b。讀完這篇你應該能回答:RAG 為什麼能解幻覺?三段架構各自的關鍵指標是什麼?為什麼檢索品質決定 RAG 上限?system prompt 與 user prompt 的分工是什麼?
RAG 的檢索階段比我們今天展示的複雜得多:文件怎麼切分、切多大、metadata 怎麼設計、query 怎麼改寫、結果怎麼 rerank——每一個都是 RAG 系統能否上線的關鍵。明天,我們會把「文件切分」展開成完整管線:fixed、recursive、semantic 三種策略的差別,以及它們對檢索品質的影響。
延伸資源
- RAG 原始論文(Meta,2020):Lewis et al., Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks,arXiv 2005.11401,RAG 概念的標準來源。
- LangChain 0.3 官方文件(2025):
https://python.langchain.com/docs/introduction/,RetrievalQA、ConversationalRetrievalChain等高階介面;本篇用的是手寫的三段,LangChain 0.3 則把它們封裝成 Chain。 - OpenAI Chat Completions API 官方文件(2025):
https://platform.openai.com/docs/guides/chat,把local_generator換成 GPT-4o 的範例。 - Ollama 官方文件(2025):
https://github.com/ollama/ollama,ollama.chat()的 Python SDK、模型清單與 pull 指令。 - Qdrant 1.12 官方文件(2025):
https://qdrant.tech/documentation/,Filter與 payload 索引設計。
留言
張貼留言