NLI + Evidence Selection:為什麼 RAG 的真正價值在「選證據」而非「堆 Chunks」?
NLI + Evidence Selection:為什麼 RAG 的真正價值在「選證據」而非「堆 Chunks」?
RAG(Retrieval-Augmented Generation)經過幾年發展,大多數工程團隊都已經掌握了「把文件切 chunk → 向量化 → 檢索 → 塞進 prompt」的基本套路。
但這個套路有一個致命問題:
檢索到的 chunks ≠ 可支持結論的證據
你可能拿到了 10 個相關片段,但其中:
- 3 個是冗餘重複
- 2 個是間接相關但無法支持主張
- 還有 2 個彼此矛盾
如果你直接把這 10 個 chunks 塞進 LLM,它只能「自己挑」——這正是幻覺(hallucination)的溫床。
本文要介紹的是 NLI(Natural Language Inference)引導的 Evidence Selection——一種把 RAG 從「堆料」升級成「可稽核推理」的架構。
NLI 是什麼?為什麼要引入 RAG?
NLI 核心概念
NLI(自然語言推論)是一個經典的 NLP 任務,評估兩段文字之間的邏輯關係:
| 關係 | 定義 | 範例 |
|---|---|---|
| Entailment(蘊含) | 前提能推導出假設 | 前提:「他在 Google 當 SWE 三年」→ 假設:「他有軟體工程經驗」✓ |
| Contradiction(矛盾) | 前提與假設衝突 | 前提:「沒有雲端經驗」→ 假設:「精通 AWS」✗ |
| Neutral(中立) | 無法判斷關係 | 前提:「他會 Python」→ 假設:「他會 Kubernetes」? |
在 RAG 中的意義
當你把 NLI 思維帶進 RAG,每個檢索結果就不再是「相關就好」,而是要回答:
這個 chunk 能「蘊含」我要做的主張嗎?
這讓 RAG 從「資訊檢索」升級成「證據驗證」——更像法庭舉證,而非搜尋引擎結果。
從 Chunks 到 Evidence:差在哪裡?
Chunks 的局限性
傳統 RAG 的 chunks 問題:
- 噪音干擾:向量相似不代表邏輯支持
- 冗餘浪費:多個 chunk 說同一件事,token 浪費
- 衝突隱藏:彼此矛盾的內容同時出現,LLM 無所適從
- 不可稽核:不知道哪個 chunk 支持了哪個結論
Evidence Selection 的升級
Evidence Selection 加入 NLI 後的改變:
| 方面 | Chunks(傳統) | Evidence(NLI 輔助) |
|---|---|---|
| 選擇標準 | 向量相似度 | 邏輯支持度(蘊含關係) |
| 處理衝突 | 全部塞進 prompt | 衝突檢測 + 過濾 |
| 幻覺率 | 高(依賴 LLM 自己判斷) | 低(已預先驗證證據質量) |
| 可追溯性 | 無 | 每個結論對應明確證據 |
根據 MedTrust-RAG 等研究,這種架構可以:
- 準確率提升 20-50%(尤其在衝突場景)
- 幻覺率降低 40%+
- Token 消耗減少(壓縮到核心證據)
實戰案例:履歷技能覆蓋驗證
我們以一個具體場景說明:驗證履歷是否涵蓋 JD 要求的技能。
問題定義
- 輸入:JD 要求的技能清單(例:
["Kubernetes", "Python", "RAG"]) - 檢索:從履歷素材庫搜尋相關經歷
- 輸出:每個技能的覆蓋狀態 + 證據
Verdict Schema:借用 NLI 架構設計
我們設計了四級評估,直接對應 NLI 概念:
const VerdictSchema = z.object({
status: z.enum(["COVERED", "IMPLIED", "WEAK", "MISSING"]),
reasoning: z.string(),
improvement_suggestion: z.string().optional(),
});
| 狀態 | NLI 對應 | 定義 |
|---|---|---|
| COVERED | Entailment | 有明確證據——具體專案、量化成果 |
| IMPLIED | Neutral(正向推論) | 相關技能暗示能力,但未直接提及 |
| WEAK | Neutral(弱推論) | 關鍵字出現但缺乏深度/範例 |
| MISSING | Contradiction / 無證據 | 找不到任何支持 |
完整流程:Skill Graph → Search → Compress → NLI Verify → Retry
flowchart TD S[技能清單] --> G[Skill Graph 預檢] G -->|已推論| SKIP[IMPLIED - 跳過 LLM] G -->|需驗證| SEARCH[混合搜尋] SEARCH --> COMP[Context Compression] COMP --> NLI[LLM NLI 判定] NLI -->|COVERED/IMPLIED| DONE[完成] NLI -->|WEAK/MISSING| REFINE[Query Refinement] REFINE --> SEARCH
關鍵模組解析
1. Context Compression:減少噪音,聚焦證據
不是所有檢索到的句子都跟目標技能相關。我們用兩階段壓縮:
Stage 1:Sentence-level Vector Filtering
// 只保留與技能向量相似度 > 0.35 的句子
const relevant = scoredSentences
.filter(s => s.score >= SIMILARITY_THRESHOLD)
.sort((a, b) => b.score - a.score);
Stage 2:LLM Summarization(可選)
當 Stage 1 後仍太長(>800 字元),用 LLM 進一步提煉:
const { object } = await generateObject({
schema: SummarySchema,
prompt: `Extract only facts relevant to "${skill}"...`,
});
效果:壓縮率通常達 30-60%,同時保留核心證據。
2. Self-RAG Retry:WEAK/MISSING 時自動重試
傳統 RAG 是「一次檢索定生死」。Self-RAG 模式允許根據 NLI 結果動態調整:
// 根據失敗原因生成替代查詢
const refinement = await refineQuery(skill, verdict.reasoning, triedQueries);
// 四種策略
// 1. synonym_expansion: "Kubernetes" → ["K8s", "container orchestration"]
// 2. context_enrichment: "Python" → ["Python backend", "Python API"]
// 3. decomposition: "Full-stack" → ["React frontend", "Node.js backend"]
// 4. related_skills: "Kubernetes" → ["Docker", "Helm charts"]
漸進放寬:重試時逐步調整搜尋參數
suggestSearchAdjustments(attemptNumber) {
return {
relaxFilters: attemptNumber >= 1, // 放寬 min_impact
expandLimit: attemptNumber >= 1, // 增加結果數量
disableReranking: attemptNumber >= 2, // 跳過 reranking 取更廣結果
};
}
3. Skill Graph:快速路徑推論
有些技能可以透過圖譜關係直接推論,不需要 LLM:
你有 "Docker" 經驗 → Skill Graph 連結 → "Container" 被推論為 IMPLIED
這是 O(1) 的查詢,大幅減少 LLM 調用次數。
為什麼 Evidence Selection > Chunks?
回到開頭的問題:為什麼不能直接把 chunks 塞給 LLM?
類比:AST vs Source Code
把 chunks 直接餵給 LLM,就像把原始程式碼塞給編譯器後端,期望它自己找到正確的 expression。
而 Evidence Selection 像是做了 program slicing:只保留影響結論的最小子結構。
量化對比
| 指標 | 傳統 Chunks | Evidence Selection |
|---|---|---|
| Token 消耗 | ~3000-5000 | ~800-1500(壓縮後) |
| LLM 判斷品質 | 受噪音影響 | 聚焦核心證據 |
| 可重現性 | 低(隨機性高) | 高(相同輸入→相同證據包) |
| 偵錯能力 | 難(不知道哪個 chunk 影響結果) | 易(每個結論有 evidence trace) |
常見誤解:為什麼不在 Indexing 時做 Evidence Selection?
一個常見的問題:「既然 Evidence Selection 這麼好,為什麼不直接在建索引時就做,存入向量庫的都是精選證據?」
核心原因:Evidence Selection 是 Query-dependent
| 階段 | 問題 | 已知資訊 |
|---|---|---|
| Indexing 階段 | 「這段文字未來可能支持哪些查詢?」 | ❌ 不知道查詢是什麼 |
| Query 階段 | 「這段文字是否支持這個具體查詢?」 | ✅ 有明確的查詢 |
NLI 的核心是判斷 Premise → Hypothesis 的關係。但在 indexing 時:
- ✅ 有 Premise(原始文本)
- ❌ 沒有 Hypothesis(查詢/技能)
你無法判斷「這段話是否蘊含某個結論」——因為結論還不存在。
如果硬要在 Indexing 做會怎樣?
方案 1:預生成所有可能的技能標籤
原文:「在 GKE 上用 Helm Chart 部署微服務」
→ 預先判斷它支持哪些技能?
→ Kubernetes? Docker? Cloud? DevOps? CI/CD? 微服務架構?...
問題:技能清單從哪來?標籤可能爆炸(每段對應 10+ 個技能),且過度推論無法控制。
方案 2:用 LLM 提取「證據摘要」存入向量庫
原文 → LLM → 精煉後的證據片段 → embedding
問題:
- 資訊損失:你不知道未來的查詢需要什麼細節
- 偏見固化:LLM 可能忽略某些上下文,但那正是特定 JD 需要的
- 無法回溯:原文 → 摘要 是有損壓縮
正確的架構:分層處理
Indexing (粗粒度) Query (細粒度)
───────────────── ─────────────────
保留完整原文 Evidence Selection
向量 + BM25 索引 ↓
Context Compression
↓
NLI Verdict
- Indexing 保持高召回率(recall):寧可多撈,不要漏掉
- Query 時精準篩選(precision):有了具體查詢,才能判斷蘊含關係
類比:Google 搜尋 vs Featured Snippet
- Indexing:Google 爬取整個網頁,不預判「這段話回答什麼問題」
- Query:根據搜尋詞,即時判斷哪一段適合當 Featured Snippet
如果 Google 在 indexing 時就決定「這段話只能回答 X 問題」,那當用戶問 Y(相關但不同)時,就會錯過。
結論
| 方法 | 適用場景 |
|---|---|
| Indexing 時做 Evidence Selection | 只適用於查詢範圍已知且固定(例:FAQ 問答對) |
| Query 時做 Evidence Selection | 查詢不可預測、需要高靈活性的場景(如履歷 vs 多元 JD) |
延伸:EG-RAG 與 MedTrust-RAG 的啟發
學術界已有多個 NLI + RAG 整合的研究:
EG-RAG(Evidence Graph RAG)
- 建立「證據圖」量化句子間支持度
- 用圖走訪替代簡單排序
MedTrust-RAG
- 針對醫療領域,用 NLI 迭代驗證證據完整性
- 重視衝突檢測(用藥禁忌等)
這些研究的共同點:把 NLI 從「判斷」升級成「迭代驗證迴圈」。
總結:RAG 的真正升級路徑
傳統 RAG 解決的是「LLM 知識不足」的問題。但解決方式(堆 chunks)帶來新問題:噪音、冗餘、不可稽核。
NLI + Evidence Selection 提供的是結構性升級:
- 不只檢索,還要驗證——每個 chunk 是否真的「蘊含」結論
- 不只壓縮,還要篩選——留下邏輯上必要的最小證據集
- 不只一次,還要重試——Self-RAG 機制讓失敗可恢復
當你的 RAG 系統開始問「這個證據能支持這個結論嗎?」而不只是「這個 chunk 相關嗎?」,你就進入了下一個層次。
附錄:核心程式碼片段
Verdict Schema(TypeScript + Zod)
const VerdictSchema = z.object({
status: z.enum(["COVERED", "IMPLIED", "WEAK", "MISSING"]).describe(
"COVERED: clearly demonstrated with examples. " +
"IMPLIED: related skills suggest capability. " +
"WEAK: keyword mentioned but lacks depth. " +
"MISSING: no evidence found."
),
reasoning: z.string(),
improvement_suggestion: z.string().optional(),
});
Self-RAG Retry Loop 核心邏輯
while (attempt <= maxRetries) {
const searchResult = await searchMaterialByJdFocus({ focus: currentQuery });
const verdict = await evaluateCoverageWithLLM(skill, compressedContext);
if (verdict.status === "COVERED" || verdict.status === "IMPLIED") {
return result; // 成功,結束
}
// 失敗:生成替代查詢重試
const refinement = await refineQuery(skill, verdict.reasoning, triedQueries);
currentQuery = refinement.refined_queries[0];
attempt++;
}
Context Compression 兩階段
// Stage 1: 向量過濾
const relevantSentences = await extractRelevantSentences(skill, snippets);
// Stage 2: LLM 摘要(可選)
if (filteredText.length > THRESHOLD) {
return await summarizeForSkill(skill, filteredText);
}
這套架構已在 Career MCP Server 中實作,用於履歷技能驗證。歡迎參考 verify-skill-coverage.ts 查看完整實作。