Ian Chou's Blog

NLI + Evidence Selection:為什麼 RAG 的真正價值在「選證據」而非「堆 Chunks」?

NLI + Evidence Selection:為什麼 RAG 的真正價值在「選證據」而非「堆 Chunks」?

RAG(Retrieval-Augmented Generation)經過幾年發展,大多數工程團隊都已經掌握了「把文件切 chunk → 向量化 → 檢索 → 塞進 prompt」的基本套路。

但這個套路有一個致命問題:

檢索到的 chunks ≠ 可支持結論的證據

你可能拿到了 10 個相關片段,但其中:

如果你直接把這 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 問題:

  1. 噪音干擾:向量相似不代表邏輯支持
  2. 冗餘浪費:多個 chunk 說同一件事,token 浪費
  3. 衝突隱藏:彼此矛盾的內容同時出現,LLM 無所適從
  4. 不可稽核:不知道哪個 chunk 支持了哪個結論

Evidence Selection 的升級

Evidence Selection 加入 NLI 後的改變:

方面 Chunks(傳統) Evidence(NLI 輔助)
選擇標準 向量相似度 邏輯支持度(蘊含關係)
處理衝突 全部塞進 prompt 衝突檢測 + 過濾
幻覺率 高(依賴 LLM 自己判斷) 低(已預先驗證證據質量)
可追溯性 每個結論對應明確證據

根據 MedTrust-RAG 等研究,這種架構可以:


實戰案例:履歷技能覆蓋驗證

我們以一個具體場景說明:驗證履歷是否涵蓋 JD 要求的技能

問題定義

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 時:

你無法判斷「這段話是否蘊含某個結論」——因為結論還不存在。

如果硬要在 Indexing 做會怎樣?

方案 1:預生成所有可能的技能標籤

原文:「在 GKE 上用 Helm Chart 部署微服務」
→ 預先判斷它支持哪些技能?
→ Kubernetes? Docker? Cloud? DevOps? CI/CD? 微服務架構?...

問題:技能清單從哪來?標籤可能爆炸(每段對應 10+ 個技能),且過度推論無法控制。

方案 2:用 LLM 提取「證據摘要」存入向量庫

原文 → LLM → 精煉後的證據片段 → embedding

問題:

正確的架構:分層處理

Indexing (粗粒度)          Query (細粒度)
─────────────────         ─────────────────
保留完整原文              Evidence Selection
向量 + BM25 索引          ↓
                         Context Compression
                         ↓
                         NLI Verdict

如果 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 從「判斷」升級成「迭代驗證迴圈」


總結:RAG 的真正升級路徑

傳統 RAG 解決的是「LLM 知識不足」的問題。但解決方式(堆 chunks)帶來新問題:噪音、冗餘、不可稽核。

NLI + Evidence Selection 提供的是結構性升級

  1. 不只檢索,還要驗證——每個 chunk 是否真的「蘊含」結論
  2. 不只壓縮,還要篩選——留下邏輯上必要的最小證據集
  3. 不只一次,還要重試——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 查看完整實作。