生成端:引用來源與防幻覺
你把 RAG pipeline 做到第 5 課了——hybrid search 跑起來,reranker 也上了,召回率看起來不錯。接著你把這些文件塊送給模型,模型給你一個語氣流暢的答案,你仔細一看:它說的話有三成根本不在你傳進去的文件裡。
這叫「幻覺混入檢索結果」。更麻煩的是,模型說得很有信心,讀者根本看不出來哪句是真實文件、哪句是模型自己補腦的。這不是換個更強的模型就能解決的問題——它的根源在生成端的工程設計。換句話說,你的向量庫和 reranker 再好,生成端沒有做好,整個系統的可信度就是零。
這堂學什麼
- 把多份檢索結果組進 prompt 的標準模板:讓模型知道哪是「文件」、哪是「問題」、各文件來源是什麼
- 強制引用來源的兩種做法:prompt 工程法 vs Anthropic 原生 Citations API,以及各自的適用場景
- 「查無資料就說不知道」的拒答工程:相似度門檻過濾 + prompt 層強制拒答指令的雙保險設計
- 引用正確性驗證:程式層的後處理 checker,避免模型「編造」引用標記
- 多輪對話 RAG 的核心問題:指代詞消歧與問題改寫(query rewriting)策略
- 實戰:帶引用、帶拒答、支援多輪對話的完整問答系統 Python 實作
觀念一:把檢索結果正確組進 Prompt
第 3 課到第 5 課教你把文件切好、存好、撈出來。現在你手上有一個 list,裡面是 3~5 個和問題最相關的 chunk。問題是:怎麼把它們塞進 prompt?
新手最常見的做法是直接拼字串:
context = "\n".join([chunk["text"] for chunk in retrieved_chunks])
prompt = f"根據以下資料回答問題:\n{context}\n\n問題:{query}"
這個做法有三個隱患。第一,模型不知道「資料在哪裡結束、問題在哪裡開始」,兩者混在一起容易導致模型把問題裡的關鍵字當作資料的一部分。第二,不同文件之間沒有邊界,模型無法準確判斷哪句話出自哪份文件。第三,沒有任何來源識別資訊,模型就算想引用也無從標記。
正確做法是給每個文件塊一個明確的 ID 與 XML 標籤結構:

def build_rag_prompt(query: str, chunks: list[dict]) -> str:
"""
chunks 格式:每個 dict 有 text(內容)、source(來源路徑)、score(相似度分)
"""
doc_blocks = []
for i, chunk in enumerate(chunks):
doc_blocks.append(
f"""<document id="doc_{i}" source="{chunk['source']}">
{chunk['text']}
</document>"""
)
documents_xml = "\n".join(doc_blocks)
return f"""你是一個精確的問答助理。請根據下方 <documents> 區塊中的文件內容回答問題。
<documents>
{documents_xml}
</documents>
<question>
{query}
</question>"""
為什麼選 XML 格式?Claude 在訓練資料裡看過大量 XML,對這種結構有很好的理解能力。每個 <document> 標籤的 id 屬性(如 doc_0)讓模型在回答時能精確引用,source 屬性保存文件來源路徑,方便前端顯示超連結。
下面這個對照表整理了三種組 prompt 方式的差異:
| 做法 | 文件邊界 | 來源識別 | 引用準確度 |
|---|---|---|---|
| 純字串拼接 | 無 | 無 | 差 |
| 換行符分隔 + 序號 | 模糊 | 弱 | 普通 |
| XML 標籤 + id 屬性 | 明確 | 強 | 好 |
觀念二:強制引用來源的兩種做法
做法 A:Prompt 工程法
在 system prompt 裡加上強制引用的規則,讓模型在答案裡標記 [doc_X]:
SYSTEM_PROMPT = """你是精確的文件問答助理,必須遵守以下規則:
1. 只能根據 <documents> 區塊中的內容作答。
2. 每個具體的事實主張,後面必須加上引用標記,格式:[doc_N],N 為文件 id 的數字。
3. 如果文件中找不到足以回答問題的資訊,必須直接說:「根據現有資料,無法回答這個問題。」
4. 禁止引用文件之外的知識。禁止推斷或補充文件沒有提到的事實。"""
這個做法的優點是成本低、彈性高,不需要特殊 API 功能。缺點是模型偶爾會「編造」一個 [doc_0] 標記但指向的文字根本不在那份文件裡——研究者稱為 postrationalization(後合理化),是幻覺的變體。這種情況在問題複雜、需要跨多份文件整合時更容易發生。
做法 B:Anthropic Citations API
2025 年 Anthropic 推出 Citations API,2026 年已全面正式上線,支援所有主力模型(Claude Haiku 3 除外)。它在 API 層保證每個引用都精確指向真實的文件位置,cited_text 欄位是直接從原文裁切的,不是模型生成的文字,也不計入 output tokens。

import anthropic
client = anthropic.Anthropic()
def rag_with_citations(query: str, chunks: list[dict]) -> dict:
"""
用 Citations API 做帶原生引用的 RAG。
chunks: [{"text": "...", "source": "doc_path", "title": "文件標題"}, ...]
回傳解析後的答案文字與引用列表。
"""
# 把每個 chunk 包成 document block
content_blocks = []
for chunk in chunks:
content_blocks.append({
"type": "document",
"source": {
"type": "text",
"media_type": "text/plain",
"data": chunk["text"],
},
"title": chunk.get("title", chunk["source"]),
# context 欄位傳 metadata,不會被引用,也不計入 cited_text
"context": f"來源:{chunk['source']}",
"citations": {"enabled": True},
})
# 最後加上問題
content_blocks.append({"type": "text", "text": query})
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=1024,
system="你是精確的文件問答助理。只根據提供的文件作答。若資料不足,請說明查無相關資料。",
messages=[{"role": "user", "content": content_blocks}],
)
# 解析 response:content 是 text block 與 citations block 交錯的 list
answer_parts = []
all_citations = []
for block in response.content:
if block.type == "text":
answer_parts.append(block.text)
elif block.type == "citations":
all_citations.extend(block.citations)
return {
"answer": "".join(answer_parts),
"citations": all_citations,
}
回傳的每個 citation block 長這樣:
{
"type": "char_location",
"cited_text": "RAG 系統在 2026 年的企業採用率已超過六成",
"document_index": 0,
"document_title": "AI 趨勢報告",
"start_char_index": 42,
"end_char_index": 89
}
cited_text 是直接從文件原文裁切的字串,這讓你可以在前端顯示引用片段,使用者點擊就能跳到原始文件的確切位置。另外,Citations API 和 prompt caching 可以一起用——在 document block 上加 "cache_control": {"type": "ephemeral"},重複查詢同一份文件庫時,文件的 token 只計算一次,可大幅降低成本。
兩種做法的選擇建議:產品雛形階段或需要 JSON 輸出時用做法 A;正式上線、對引用準確度要求高的場景用做法 B。
觀念三:「查無資料就說不知道」的拒答工程
幻覺最嚴重的情境不是模型答錯——而是文件裡根本沒有答案,模型卻假裝有。拒答工程的目標就是讓這個情境乾淨收場。

拒答工程有兩層設計,缺一不可。
第一層:相似度門檻過濾
在把 chunks 送給模型之前,先檢查檢索分數。如果最高分都低於門檻,代表資料庫裡根本沒有相關內容,直接拒答、不消耗 LLM tokens:
SIMILARITY_THRESHOLD = 0.42 # 根據你的 embedding 模型和語料調整
def filter_by_threshold(chunks: list[dict], threshold: float = SIMILARITY_THRESHOLD) -> list[dict]:
"""
過濾相似度低於門檻的 chunk。
- voyage-4 / voyage-4-lite:建議從 0.40~0.50 開始調整
- text-embedding-3-small:建議從 0.35~0.45 開始調整
回傳空 list 代表「資料庫中查無相關文件」,應直接拒答。
"""
return [c for c in chunks if c.get("score", 0) >= threshold]
門檻值需要用你自己的語料調校,沒有通用的「最佳值」。調試方法:收集 20~30 個有標準答案的問題,把門檻從 0.30 往上每次調 0.05,記錄「拒答率」和「答案品質」,找到兩者之間的最佳平衡點。
第二層:Prompt 層拒答指令
即使有通過門檻的 chunks,文件裡也可能「有相關文字但沒有答案」——例如問「這個產品的退款政策是什麼?」,資料庫裡只有產品說明,完全沒提退款政策。這時靠 prompt 指令讓模型自行判斷:
REFUSAL_SYSTEM = """你是精確的文件問答助理。遵守以下規則:
1. 只能根據 <documents> 裡的內容作答,嚴禁引用文件以外的知識。
2. 如果問題的答案完全不在文件裡,只輸出以下這句話,不加任何其他內容:
「根據現有資料,無法回答這個問題。若需要更多資訊,請聯繫原始資料來源。」
3. 如果問題的答案只有部分在文件裡,先回答有的部分,再標明哪些部分查無資料。
4. 每個具體事實後面加 [doc_N] 引用標記,N 是文件 id 的數字。"""
注意第 2 條的寫法:「只輸出以下這句話」比「你可以說不知道」強硬得多。模型在模糊指令下容易找藉口「部分回答」然後偷偷補充訓練資料裡的知識。要讓拒答工程有效,指令必須明確到不留任何詮釋空間。
觀念四:引用驗證——後處理 Checker
用做法 A(Prompt 工程法)時,模型產生的 [doc_N] 標記需要在程式層驗證。模型有時會在沒有引用的情況下提到 [doc_2],或者引用一個不存在的 [doc_5]——這在你只傳了 3 個文件時當然是無效引用。
import re
def verify_citations(answer: str, chunks: list[dict]) -> dict:
"""
檢查答案中的 [doc_N] 標記是否都指向有效的文件。
回傳:
valid: 所有引用都合法
invalid_refs: 引用了不存在的 doc_N(超出範圍)
uncited_chunks: 有哪些文件完全沒被引用(僅供參考,不算錯誤)
"""
cited_ids = set(re.findall(r'\[doc_(\d+)\]', answer))
valid_ids = set(str(i) for i in range(len(chunks)))
invalid_refs = cited_ids - valid_ids # 引用了不存在的 doc_N
uncited_chunks = valid_ids - cited_ids # 傳入但未被引用的文件
return {
"valid": len(invalid_refs) == 0,
"invalid_refs": [f"doc_{i}" for i in sorted(invalid_refs, key=int)],
"uncited_chunks": [f"doc_{i}" for i in sorted(uncited_chunks, key=int)],
}
如果 valid 為 False,實際產品可以選擇:把這個回答標記為「需人工複審」、記錄到 log 供後續分析、或觸發一次重試(重試前把拒答規則強化後再送一次 request)。不建議把無效引用的回答直接呈現給使用者。
觀念五:多輪對話中的 RAG
多輪對話讓 RAG 複雜了一個數量級。使用者說「它的價格是多少?」——「它」指的是上一輪問的產品。如果你直接用「它的價格是多少?」做 embedding 查詢,向量庫根本不知道「它」是什麼,回傳的結果跟問題毫無關係,最終觸發拒答或回答錯誤內容。

解法是「問題改寫(query rewriting)」:在每一輪,先用一個輕量呼叫把含指代詞的問題改寫成獨立完整的問題,再用改寫後的問題做向量查詢。改寫任務可以用最輕量的模型(如 claude-haiku-4-5)來做,成本很低:
def rewrite_query(conversation_history: list[dict], current_query: str) -> str:
"""
把含指代詞的問題改寫成獨立查詢。
conversation_history: [{"role": "user"/"assistant", "content": "..."}, ...]
只傳最近 6 筆(3 輪問答),過多的歷史對改寫幫助有限但會增加 token 消耗。
"""
if not conversation_history:
return current_query # 第一輪沒有歷史,直接用原問題
history_str = "\n".join(
f"{msg['role'].upper()}: {msg['content']}"
for msg in conversation_history[-6:]
)
rewrite_prompt = f"""以下是對話歷史:
{history_str}
使用者的最新問題是:「{current_query}」
請把這個問題改寫成一個獨立完整的問題,補上所有必要的指代對象,讓這個問題在沒有上下文時也能被理解。
只輸出改寫後的問題,不要加任何解釋。"""
response = client.messages.create(
model="claude-haiku-4-5", # 改寫任務用輕量模型省錢
max_tokens=200,
messages=[{"role": "user", "content": rewrite_prompt}],
)
return response.content[0].text.strip()
多輪對話還有另一個問題:對話歷史會持續增長,最終把 context window 撐爆。解法是設定硬性上限——在更新 conversation_history 時只保留最近 20 筆(10 輪問答)。超過上限的舊歷史直接丟棄,對改寫任務的影響通常可以接受。
手把手實戰
把上面所有觀念串成一個完整的 RAG 問答 class,帶引用、帶拒答、帶多輪對話:
安裝依賴
pip install anthropic qdrant-client voyageai
假設你已有第 3 課建好的 Qdrant 向量庫,裡面存有文件的 voyage-4-lite 向量。如果你用的是 pgvector 或 Pinecone,把 _retrieve() 方法裡的客戶端換掉,其他邏輯完全一樣。
定義完整的 RAG 生成器
import re
import anthropic
import voyageai
from qdrant_client import QdrantClient
VOYAGE_MODEL = "voyage-4-lite" # $0.02/MTok,新帳號有 200M free tokens
CLAUDE_MODEL = "claude-sonnet-5" # 生成用
REWRITE_MODEL = "claude-haiku-4-5" # 問題改寫用,省成本
SIMILARITY_THRESHOLD = 0.42 # 低於此值直接拒答(不送 LLM)
TOP_K = 5 # 每次檢索前 5 塊送給模型
SYSTEM_PROMPT = """你是精確的文件問答助理。遵守以下規則:
1. 只能根據 <documents> 區塊的內容作答,禁止引用文件以外的任何知識。
2. 每個具體事實後面必須加引用標記 [doc_N],N 是文件 id 的數字。
3. 若文件中完全找不到答案,只回傳:「根據現有資料,無法回答這個問題。」
4. 不要推測或補充文件沒有明確提到的資訊。"""
class RAGGenerator:
def __init__(self, collection_name: str):
self.voyage = voyageai.Client()
self.qdrant = QdrantClient(host="localhost", port=6333)
self.claude = anthropic.Anthropic()
self.collection = collection_name
self.conversation_history: list[dict] = []
def _embed_query(self, text: str) -> list[float]:
result = self.voyage.embed([text], model=VOYAGE_MODEL, input_type="query")
return result.embeddings[0]
def _retrieve(self, query: str) -> list[dict]:
"""向量查詢 + 相似度門檻過濾,回傳合格的 chunk list。"""
vector = self._embed_query(query)
hits = self.qdrant.search(
collection_name=self.collection,
query_vector=vector,
limit=TOP_K,
with_payload=True,
)
# 門檻過濾:低分 chunk 直接排除
return [
{
"text": hit.payload["text"],
"source": hit.payload.get("source", "未知來源"),
"title": hit.payload.get("title", hit.payload.get("source", "")),
"score": hit.score,
}
for hit in hits
if hit.score >= SIMILARITY_THRESHOLD
]
def _rewrite_if_needed(self, query: str) -> str:
"""只有在有對話歷史時才做問題改寫,節省 API 呼叫。"""
if not self.conversation_history:
return query
history_str = "\n".join(
f"{m['role'].upper()}: {m['content']}"
for m in self.conversation_history[-6:]
)
prompt = (
f"對話歷史:\n{history_str}\n\n"
f"最新問題:「{query}」\n\n"
"把最新問題改寫成獨立完整的問題(補上指代對象)。只輸出改寫結果。"
)
resp = self.claude.messages.create(
model=REWRITE_MODEL,
max_tokens=150,
messages=[{"role": "user", "content": prompt}],
)
return resp.content[0].text.strip()
def _build_prompt(self, query: str, chunks: list[dict]) -> str:
"""把 chunks 組成帶 XML 邊界的 prompt,加上問題。"""
doc_blocks = "\n".join(
f'<document id="doc_{i}" source="{c["source"]}">\n{c["text"]}\n</document>'
for i, c in enumerate(chunks)
)
return (
f"<documents>\n{doc_blocks}\n</documents>\n\n"
f"<question>\n{query}\n</question>"
)
def _verify_citations(self, answer: str, chunks: list[dict]) -> bool:
"""確認答案裡所有 [doc_N] 都指向合法的文件索引。"""
cited = set(re.findall(r'\[doc_(\d+)\]', answer))
valid = set(str(i) for i in range(len(chunks)))
return cited.issubset(valid)
def ask(self, query: str) -> dict:
# 步驟 1:問題改寫(多輪對話用)
search_query = self._rewrite_if_needed(query)
# 步驟 2:向量檢索 + 門檻過濾
chunks = self._retrieve(search_query)
if not chunks:
# 查無相關文件,直接拒答
return {
"answer": "根據現有資料,無法回答這個問題。",
"sources": [],
"refused": True,
}
# 步驟 3:組 prompt
user_content = self._build_prompt(query, chunks)
# 步驟 4:生成答案(帶對話歷史)
messages = self.conversation_history + [
{"role": "user", "content": user_content}
]
response = self.claude.messages.create(
model=CLAUDE_MODEL,
max_tokens=1024,
system=SYSTEM_PROMPT,
messages=messages,
)
answer = response.content[0].text
# 步驟 5:驗證引用
if not self._verify_citations(answer, chunks):
# 偵測到無效引用,記 log(實際產品可觸發重試或標記待審)
print(f"[WARN] 無效引用標記,原始問題:「{query}」")
# 步驟 6:更新對話歷史(存原始問題,不存改寫版)
self.conversation_history.append({"role": "user", "content": query})
self.conversation_history.append({"role": "assistant", "content": answer})
# 硬性上限:最多保留 20 筆(10 輪問答)
if len(self.conversation_history) > 20:
self.conversation_history = self.conversation_history[-20:]
sources = [
{"id": f"doc_{i}", "source": c["source"], "score": round(c["score"], 4)}
for i, c in enumerate(chunks)
]
return {"answer": answer, "sources": sources, "refused": False}
執行測試
if __name__ == "__main__":
rag = RAGGenerator(collection_name="my_docs")
# 第一輪:正常問題
result = rag.ask("RAG 系統的主要優點是什麼?")
print("答案:", result["answer"])
print("來源:", result["sources"])
print("---")
# 第二輪:含指代詞,測試問題改寫
result2 = rag.ask("它有什麼限制?") # 「它」= RAG 系統
print("答案:", result2["answer"])
print("---")
# 測試拒答:問資料庫裡沒有的資訊
result3 = rag.ask("台灣總統是誰?")
print("答案:", result3["answer"]) # 應該輸出拒答訊息
print("拒答:", result3["refused"]) # 應該是 True
執行後重點觀察兩件事:第一,第二輪的問題「它有什麼限制?」要看 log 裡的 search_query 是否已被正確改寫為「RAG 系統有什麼限制?」;第二,拒答測試要確認回傳的 refused 是 True 而不是讓模型亂答。

常見坑
坑 1:傳進去的 context 超過 token 限制
症狀:呼叫 API 時拿到 400 Bad Request,錯誤訊息包含 prompt is too long 或 Input tokens exceed the model maximum。
原因:TOP_K 設太高或每個 chunk 太大,加上對話歷史和 system prompt,總 token 超過模型上限。Claude Sonnet 5 的 context window 是 1M tokens,一般不會爆;但如果你用的是 200K context 的模型(如 Claude Haiku 4.5),對話歷史累積幾輪後就很容易超標,而且就算沒超標,把過多低相關 chunk 塞進去也會拉高成本與延遲。
解法:用 Anthropic SDK 的 client.beta.messages.count_tokens() 在送出 request 前先計算 token 數。如果快超限,把 TOP_K 從 5 降到 3,或把每個 chunk 的 text 截到 500 tokens 以內。長遠要在 _retrieve() 之後加 token budget 截斷邏輯:計算目前所有 chunk 的 token 總和,超過預算就丟掉分數最低的那幾個。
坑 2:模型無視拒答指令,繼續用訓練知識回答
症狀:文件裡明明查無資料,模型還是給了一個「看起來合理」但完全是捏造的答案,而且語氣非常肯定。
原因:system prompt 的拒答指令太軟。例如用「可以說不知道」而不是「必須只說這一句」,或問題的措辭讓模型判斷「這個我從訓練資料就知道,不需要查文件」。另一個常見原因是相似度門檻太低——有低相關的 chunk 通過了門檻,模型以為有依據就開始混合訓練知識。
解法:把拒答指令措辭改得更強硬,用「禁止」、「絕對不可以」、「唯一允許的回答是」這類詞;同時提高 SIMILARITY_THRESHOLD 到 0.45~0.50 試試。如果問題還是持續發生,可以在拒答判斷上加一個後驗步驟:讓另一個輕量模型檢查「這個回答裡有沒有
坑 3:多輪對話後,檢索結果開始不準
症狀:前幾輪回答正常,對話進行到第 4~5 輪後,refused: True 的比例明顯上升,但使用者問的是合理的跟進問題。
原因:最常見是 _rewrite_if_needed 沒有被呼叫,或對話歷史傳入改寫器時截取的輪數太少,導致改寫器沒有足夠上下文、改寫結果不完整甚至完全沒變。另一個原因是對話歷史裡存的是 XML 格式的 prompt(含 <documents> 標籤),傳給改寫器的歷史太噪雜,反而干擾改寫品質。
解法:確認 conversation_history 存的是「原始問題和原始答案」,而不是帶 <documents> 標籤的 prompt。debug 時在 _rewrite_if_needed 裡 print 出改寫後的 search_query,確認指代詞有被正確展開再繼續。
坑 4:Citations API 的 document_index 對不上文件順序
症狀:用 Citations API 時,citation block 裡的 document_index 是 2,但你的第 2 份文件內容跟引用文字完全不符。
原因:document_index 是以整個 content list 裡所有 document block 的順序為準(0-indexed),跨越整個 request 的所有訊息。如果你在 document block 之前插了一個純文字 block 做說明,索引就會跑掉:那個文字 block 不是 document block 所以不算,但如果前面的某個 document block 的順序跟你預期的不同,就會對不上。
解法:把所有 document block 連續放在 content list 的最前面,最後才放問題的 text block,確保 document 的索引序號從 0 開始、和你的 chunks list 一一對應。可以用 enumerate(chunks) 建 content_blocks 時同步建一個 index_to_source dict,之後解析 citations 時直接查這個 dict。
坑 5:回答品質很好但引用率很低——大量 uncited_chunks
症狀:verify_citations 回報 uncited_chunks 很多,答案裡根本沒有 [doc_N] 標記,但答案內容看起來正確。
原因:system prompt 裡的引用規則和答案格式要求之間有衝突,或問題的答案需要整合多份文件的資訊,模型選擇「說清楚概念」而不是「逐句標引用」。有時候是中文 prompt 對引用格式的描述不夠精確,模型產生了 (文件一) 這種格式而不是 [doc_0]。
解法:在 system prompt 裡加上格式範例——「例如:○○○ [doc_0],○○○ [doc_1]」。範例比描述更有效。如果問題確實是要整合多份文件,接受「段落末尾引用」而不是「句句引用」的設計,對 uncited_chunks 的容忍度可以放寬。
作業
- 用你在第 5 課建好的 pipeline,串上這堂課的
RAGGenerator,對你自己的文件庫出 10 個問題測試,記錄「拒答率」、「無效引用率」、「回答耗時」三個指標。 - 把
SIMILARITY_THRESHOLD從 0.35 調到 0.55,每次調 0.05 做一輪測試,找出你語料的最佳門檻值,把結果做成小表格。 - 選做 A:把做法 A(Prompt 引用法)換成做法 B(Citations API),比較同一組問題下兩種做法的引用準確度差異,留意 Citations API 在你的語料上是否有更好的句子邊界切割。
- 選做 B:加一個「引用正確性評估」步驟——呼叫另一個 Claude 實例,讓它判斷「這個引用標記 [doc_N] 所對應的文件內容,真的能支撐答案裡的這個主張嗎?」用這個自動評估器跑 20 個問題,看看兩種引用做法的通過率差異。
下一課預告
第 6 課把生成端的工程做紮實了——你現在有一個帶引用、會拒答、能應付多輪對話的 RAG 問答核心。模組齊備,但這些還是在本機跑的腳本。第 7 課是整個課程的綜合實戰:我們要把這條完整的 pipeline 包進一個實際的 Web 服務,加上文件的自動更新機制與使用者回饋收集,讓系統在上線後能持續優化——第 1 課到第 6 課累積的所有模組,全部在這裡接起來跑。