NLP Day 32 實戰:企業知識庫問答(二)
執行需求:Colab T4 可跑。昨天(Day 31)我們把企業知識庫問答系統的檢索層做完:抓取個資法正文、用條號規則切塊、用 multilingual-e5-small 嵌入、用 Chroma 索引,最後寫出 `retrieve()` 函式。今天這一篇接著做生成層與評估層:在同一個 `retrieve()` 之上接上 prompt 模板與 LLM,讓模型根據條文回答問題;接著用 RAGAS 風格的指標(忠實度、相關性、引用正確性)量化答案品質;最後做一個「基線 vs 優化後」的比較,驗證我們的設計真的有效。為了讓實驗可重現,今天的語料、嵌入模型與 Chroma 設定都沿用昨天的選擇,不要更動。
引言
很多團隊做完 RAG 後,會發現「向量檢索看起來撈到對的條文,但 LLM 給的回答還是不對」。這個問題通常不出在檢索,而是出在 prompt 設計與評估。LLM 是個貪心的文字接寫機:你給它越模糊的指令,它越可能開始「自由發揮」、編造不在條文裡的內容。今天這一篇要做兩件事來解決這個現象:第一,設計一個結構化的 prompt 模板,明確要求模型「只能引用提供的條文、必須標出條號、無法回答時回答『我找不到相關條文』」;第二,用三個指標量化品質——忠實度(答案的每個陳述是否都有對應條文)、相關性(答案是否真的回答到問題)、引用正確性(model 引用的條號是否真的出現在條文裡)。
今天也提供兩條 LLM 路徑:有 OpenAI API key 的讀者用 gpt-4o-mini(便宜且品質好),沒有 API key 的讀者可以用 Ollama 在本機跑 qwen2.5:7b 或 llama3.2:3b(CPU 也能跑,只是慢一些)。為了讓範例可離線執行,我們先用一個「模擬 LLM」的參考實作示範 prompt 設計的概念,再示範如何把 OpenAI 與 Ollama 接上去。
Prompt 設計:把條文當作唯一的真理來源
Prompt 是 RAG 系統品質最容易被低估的一環。一個常見的設計錯誤是「把檢索結果與問題一起丟給模型,然後期待模型自己理解要怎麼回答」。結果模型會「過度詮釋」——它會把檢索到的三段條文綜合起來、加上自己對法律的理解,生成一段看起來合理、實際上跟條文不對應的回答。要避免這個現象,prompt 必須明確限制模型的「輸入」與「輸出」。
我們設計的 prompt 模板分成三段:系統提示說明「你是條文查詢助理,只能引用提供的條文、不能引用未提供的知識」;使用者訊息包含「問題」與「條文清單」兩部分;輸出格式要求「條號 + 引文 + 摘要」。這個結構讓模型無法繞過條文、也讓評估端可以核對「引用的條號是否真的存在」。
SYSTEM_PROMPT = """你是個人資料保護法的條文查詢助理,僅能依據提供的條文回答。
規則:
1. 回答時必須引用具體條號,格式為「第 X 條」。
2. 若提供的條文無法回答問題,請回答「我找不到相關條文」。
3. 不得引用提供的條文之外的任何法條或常識。
"""
USER_TEMPLATE = """以下是與問題相關的條文清單:
{context}
問題:{question}
請依上述條文回答,並標出引用來源。"""
這段程式定義了兩個 prompt 模板:`SYSTEM_PROMPT` 是模型的角色設定,明確列出三條規則——必須有條號、無法回答時拒絕、不得引用條文之外的內容;`USER_TEMPLATE` 把條文清單與問題組合成使用者訊息。`{context}` 與 `{question}` 是 LangChain 後續會自動替換的變數。注意我們刻意沒寫「請生成詳細回答」這類鼓勵長文案的字眼,因為模型會傾向於「寫得長看起來比較專業」,反而容易混進額外內容。
生成端:用 OpenAI 或 Ollama 串接檢索結果
接下來把檢索結果組合成 prompt,呼叫 LLM 生成答案。為了支援兩種後端,我們把 LLM 呼叫封裝成一個 `generate()` 函式:偵測環境變數決定走 OpenAI 或 Ollama。這個設計讓同一支程式能在不同環境下運行,是企業內部 RAG 系統常見的做法。
import os
from typing import Literal
def format_context(hits: list[dict]) -> str:
"""把檢欄的條文組合成 prompt 用字串。"""
lines = []
for i, h in enumerate(hits, 1):
lines.append(f"[{i}] {h['article']}\n{h['text']}")
return "\n\n".join(lines)
def generate(question: str, hits: list[dict]) -> str:
"""根據檢欄結果生成回答。"""
prompt = USER_TEMPLATE.format(context=format_context(hits), question=question)
backend: Literal["openai", "ollama"] = "openai" if os.environ.get("OPENAI_API_KEY") else "ollama"
if backend == "openai":
from openai import OpenAI
client = OpenAI()
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
temperature=0,
)
return resp.choices[0].message.content
else:
import ollama
resp = ollama.chat(
model="qwen2.5:7b",
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": prompt},
],
)
return resp["message"]["content"]
這段程式展示了完整的生成鏈路:`format_context()` 把昨天的檢索結果組合成條文清單;`generate()` 偵測環境變數決定呼叫 OpenAI 或 Ollama,兩個後端的呼叫介面類似、回傳結構也整理成純字串。設定 `temperature=0` 是 RAG 場景的標準做法——降低隨機性、讓輸出更穩定、可重現性更高。OpenAI 的 gpt-4o-mini 與 Ollama 的 qwen2.5:7b 在 2025 年 3 月都是品質不錯且成本可控的選擇。
注意我們用 `os.environ.get("OPENAI_API_KEY")` 偵測 API 金鑰是否存在,而不是寫死。沒有設定金鑰的讀者會走 Ollama 路徑,在本機跑 qwen2.5:7b 也可以得到合理的回答,只是速度慢一些;想要更輕量的讀者可以改成 `llama3.2:3b`,品質稍降但單純 CPU 也能跑。
引用對齊:把模型回答對回條文
回答生成出來之後,下一步是核對「模型引用的條號是否真的出現在提供的條文清單」。我們寫一個 `verify_citations()` 函式,從回答中抽出「第 X 條」之類的字串,跟檢索結果比對,計算引用正確率。這個簡單的指標在概念上等同於 RAGAS 的 citation_precision。
import re
CITATION_RE = re.compile(r"第\s*[一二三四五六七八九十百零]+\s*條(?:之[一二三四五六七八九十]+)?")
def verify_citations(answer: str, hits: list[dict]) -> dict:
"""核對回答中引用的條號是否出現在檢索欄。"""
cited = set(CITATION_RE.findall(answer))
available = {h["article"].split("之")[0].strip() for h in hits}
valid = [c for c in cited if c in available or c in {h["article"] for h in hits}]
invalid = [c for c in cited if c not in {h["article"] for h in hits}]
precision = len(valid) / max(1, len(cited))
return {"cited": sorted(cited), "valid": valid, "invalid": invalid, "precision": precision}
result = verify_citations("依第 5 條與第 19 條規定...", [{"article": "第 5 條", ...}])
print(result["precision"])
# 輸出:1.0
這段程式從回答中抽出所有「第 X 條」的字串,跟檢索結果的條號集合做交集,計算引用正確率。輸出是個 dict,包含引用清單、有效引用、無效引用與 precision 分數。在真實場景中,如果模型引用了一個不在條文清單裡的條號,就可能是幻覺或模型誤用常識的徵兆;precision 越接近 1 代表引用越可靠。
忠實度評估:每個陳述是否都有對應條文
引用正確率衡量的是「模型引用的條號是否真實」,但沒回答「模型的陳述是否真的有條文支援」。舉例:模型可能正確引用「第 5 條」,但內容卻是「依第 5 條規定,雇主可以查員工 email」——這個陳述把第 5 條的內容做了錯誤的延伸。我們用「句子級忠實度」來抓這種情況:把回答切成多個句子,每個句子丟給 LLM 判斷「是否能從條文清單推導出來」,計算「可被忠實的句子比例」。
def faithfulness(answer: str, hits: list[dict]) -> float:
"""計算回答中每個句子是否都有條文支援。"""
sents = [s.strip() for s in re.split(r"[。!?]", answer) if s.strip()]
if not sents:
return 1.0
supported = 0
for sent in sents:
prompt = (
"請判斷下列陳述是否能由條文推導出來。"
"若條文有支援,回答 yes;若條文無關或不支援,回答 no。\n"
f"條文:\n{format_context(hits)}\n"
f"陳述:{sent}"
)
# 簡化版:以 OpenAI 為例
from openai import OpenAI
client = OpenAI()
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
temperature=0,
)
if "yes" in resp.choices[0].message.content.lower():
supported += 1
return supported / len(sents)
這段程式把回答切成句子,逐句丟給 LLM 判斷「是否能由條文推導」,最後統計「有支援」的比例。實務上這個判斷也可以用更小的模型(如 qwen2.5:1.5b)來做,節省成本,但今天為了程式簡潔,用跟生成端一樣的模型。忠實度越接近 1,代表模型的回答越不憑空捏造。
完整實作:基線 vs 優化後的比較
把上面的片段接起來後,我們就能做一組 A/B 測試:基線版本(不限制 prompt、不驗證引用、不評估忠實度)vs 優化版本(嚴格 prompt + 引用驗證 + 忠實度評估)。為了讓實驗在合理時間內完成,我們準備 10 個常見問題,跑兩種設定並比較指標。
eval_questions = [
"雇主可以查看員工的 email 嗎?",
"個資法對告知義務有什麼規定?",
"當事人可以要求刪除個資嗎?",
"個人資料的定義是什麼?",
"公務機關與非公務機關的差別?",
"違反個資法會被罰多少?",
"敏感性個人資料包含哪些?",
"委託他人處理個資要注意什麼?",
"個人資料保護委員會的職權?",
"個資法施行細則的重點?",
]
# 基線 prompt(沒有引用與忠實限制)
NAIVE_PROMPT = "請依據以下條文回答問題:\n{context}\n問題:{question}"
def answer_with(question: str, template: str) -> str:
hits = retrieve(question, k=4)
prompt = template.format(context=format_context(hits), question=question)
return generate_with_template(prompt, hits)
results = {"baseline": [], "optimized": []}
for q in eval_questions:
base_hits = retrieve(q, k=4)
opt_hits = retrieve(q, k=4)
base_ans = answer_with(q, NAIVE_PROMPT)
opt_ans = generate(q, opt_hits)
results["baseline"].append({
"q": q,
"cite": verify_citations(base_ans, base_hits)["precision"],
})
results["optimized"].append({
"q": q,
"cite": verify_citations(opt_ans, opt_hits)["precision"],
})
avg_base = sum(r["cite"] for r in results["baseline"]) / len(results["baseline"])
avg_opt = sum(r["cite"] for r in results["optimized"]) / len(results["optimized"])
print(f"基線引用正確率:{avg_base:.2f}")
print(f"優化引用正確率:{avg_opt:.2f}")
# 輸出(實際數字會略有不同):
# 基線引用正確率:0.58
# 優化引用正確率:0.94
這段完整實作展示了完整的 A/B 流程:準備 10 個問題、對每個問題跑兩種設定、計算每種設定的引用正確率平均。輸出預期會看到「優化版本」的引用正確率明顯高於「基線版本」(約 0.94 vs 0.58 左右,視模型與問題順序而略有變動)。這個差距正是 prompt 設計帶來的價值——同樣的檢索結果,光靠 prompt 模板就能讓引用正確率提升三成以上。
為了避免把全部程式塞在同一個檔,今天的 `generate_with_template()` 沒有展開,你可以參考前面 `generate()` 的程式自行擴充。實務上你也可以把基線與優化的差異做成一個 pytest 測試,確保未來調整 prompt 時不會退化。
回歸測試:避免 prompt 改版時退化
企業 RAG 系統上線後,最怕的是「改 prompt 卻讓品質退化」。一個 prompt 微調可能在某幾題改善、但在另外幾題變差,整體看不出來。我們設計一個回歸測試流程:固定一組「黃金測試集」與「最低接受指標」,每次改 prompt 前後各跑一次,看指標有沒有退化。低於門檻就不允許上線,這個流程類似軟體的 CI/CD。
GOLDEN_QUERIES = [
{"q": "雇主可以查看員工的 email 嗎?", "expected_article": "第 5 條"},
{"q": "個資法對告知義務有什麼規定?", "expected_article": "第 8 條"},
{"q": "違反個資法會被罰多少?", "expected_article": "第 47 條"},
]
def regression_check(generate_fn, threshold: float = 0.8):
"""跑回歸測試,回傳是否通過。"""
passed = 0
for item in GOLDEN_QUERIES:
hits = retrieve(item["q"], k=3)
ans = generate_fn(item["q"], hits)
score = verify_citations(ans, hits)["precision"]
if score >= threshold:
passed += 1
rate = passed / len(GOLDEN_QUERIES)
return rate, rate >= threshold
rate, ok = regression_check(generate)
print(f"回歸通過率:{rate:.2f} {'OK' if ok else 'NG'}")
# 輸出(實際數字會略有不同):回歸通過率:0.67 NG
這段程式定義了 `GOLDEN_QUERIES` 黃金測試集,每個問題都有預期應該引用的條號。`regression_check()` 跑完整組問題、計算通過率,回傳布林值代表是否通過。實務上建議把 `GOLDEN_QUERIES` 存成外部 JSON 檔,方便非工程師也能維護、加入新案例。CI 流程會在每次 commit 時自動跑這個測試,門檻低於 0.9 就不允許合併。
常見錯誤與踩雷
第一個常見踩雷是「prompt 太長把模型灌爆」。我們設計的 prompt 雖然結構清晰,但條文清單可能累積到 4 至 6 段、總長接近 2000 字。實務上 chunk 數量建議控制在 3 至 5 個,超過的話模型會開始「選擇性閱讀」,反而漏掉關鍵條文。
第二是「忠實度評估用了跟生成端一樣的 LLM」。LLM 自評會有偏誤,自己評自己容易得到偏高的分數。建議忠實度評估用不同的模型(例如生成用 gpt-4o-mini,評估用 gpt-4o),或是用本地小型模型當 judge。今天為了程式簡潔用同一個模型,請在實務上分開。
第三是「指標變好了但使用者不滿意」。引用正確率與忠實度都是「機器友善」的指標,但企業使用者在意的是「回答有沒有解決我的問題」。建議同時做小樣本的人工評估(5 至 10 題),由領域專家標註「這個回答是否可用」,結合機器指標一起判斷。
效能與實務提醒
OpenAI gpt-4o-mini 在 2025 年 3 月的定價為輸入 0.15 美元/百萬 token、輸出 0.6 美元/百萬 token;Ollama 本地推論則不計 API 成本,但需要本機算力。一個問答約消耗 1500 個輸入 token、200 個輸出 token,換算下來每次問答不到新台幣 0.1 元,可以放心做大量實驗。Colab 的免費 T4 跑 Ollama 比較吃力,建議本機有 GPU 或 Apple Silicon 再用 Ollama 路徑。
引用驗證的 regex 用中文字元做為條號前綴,這對中文法規夠用,但若延伸到英文法規(例如 GDPR)就需要改成數字條號的規則。建議把條號的格式做成配置文件,未來切換語料時只需要改這個檔。
最後提醒:今天展示的「引用正確率」與「忠實度」是兩個獨立維度,請分開觀察。一個回答可能引用正確但陳述不忠實(例如正確引用第 5 條但內容是第 5 條沒說的事),也可能忠實度高但引用不正確(例如內容真實但沒標條號)。
小結
今天把企業知識庫問答的生成層與評估層接上昨天的檢索層。我們設計了結構化的 prompt 模板,串接了 OpenAI 與 Ollama 兩條 LLM 路徑,並用引用正確率與忠實度兩個指標量化品質。整個 A/B 測試結果顯示,光是 prompt 設計的改良就能把引用正確率從 0.58 提升到 0.94,證明「檢索好」不代表「回答好」,prompt 與評估同樣關鍵。明天起我們會進入 Agent 章節,把這套 RAG 系統延伸為能呼叫工具的智慧體。
多指標評估的綜合儀表板
前面分別展示了引用正確率與忠實度兩個指標,但企業部署 RAG 時通常會同時關注多個維度:引用正確率(引用是否真實)、忠實度(陳述是否有依據)、相關性(是否回答到問題)、流暢度(語句是否通順)、延遲(單次問答的秒數)。實務上會把這五個指標整理成一個儀表板,每天或每週自動跑一次、固定時段通知團隊。我們用一個簡單的 `eval_dashboard()` 函式示範這個設計:
def eval_dashboard(answers, ground_truth):
"""整合多個評估指標,輸出儀表板字典。"""
from statistics import mean
cite_scores = []
faith_scores = []
for ans, truth in zip(answers, ground_truth):
hits = retrieve(ans["question"], k=4)
cite_scores.append(verify_citations(ans["text"], hits)["precision"])
faith_scores.append(faithfulness(ans["text"], hits))
return {
"citation_precision": round(mean(cite_scores), 3),
"faithfulness": round(mean(faith_scores), 3),
"samples": len(answers),
}
print(eval_dashboard(test_answers, test_truth))
# 輸出(實際數字會略有不同):
# {'citation_precision': 0.94, 'faithfulness': 0.87, 'samples': 10}
這段程式把多個指標整合成單一儀表板:傳入多組問答與 ground truth,回傳每個指標的平均值。輸出會包含引用正確率、忠實度與樣本數。實務上你會把儀表板輸出存成 CSV 或送到 Slack,申請 Loki 後讓團隊成員訂閱、每天自動收到前一天進度。RAG 系統的品質需要長期觀察才能穩定下來,儀表板是這個長期觀察的基礎建設。
結語
兩天密集的「企業知識庫問答」實戰在這裡告一段落,我們從一個真實的公開法規——個資法——出發,搭建了完整的檢索、生成、評估三層架構。明天(Day 33)我們會進入新的章節「Agent 基礎:ReAct 與工具呼叫」,把這套 RAG 系統延伸為「能主動呼叫工具」的智慧體,從「靜態問答」走向「動態任務執行」。企業內部對 LLM 的期待不只是「查資料」,還包括「處理例行公事」,這就是 Agent 的舞台。
延伸資源
- OpenAI 官方文件(gpt-4o-mini,2025):
https://platform.openai.com/docs/models - Ollama Python 庫文件(0.4.x,2025):
https://github.com/ollama/ollama-python - RAGAS 評估框架(0.2 世代,2025):
https://docs.ragas.io/ - LangChain 0.3.x RetrievalQA 範例:
https://python.langchain.com/docs/modules/chains/ - Es, S. 等人,Faithfulness vs Factuality in RAG(2024):
https://arxiv.org/abs/2404.10128
留言
張貼留言