Ian Chou's Blog

用 NLI 框架檢驗技能覆蓋:從模糊判斷到結構化推理

用 NLI 框架檢驗技能覆蓋:從模糊判斷到結構化推理

「履歷素材真的能證明這個技能嗎?」這是一個 Natural Language Inference 問題。本文分享如何用 Pydantic 強制 Schema、Chain-of-Thought 推理、和 Embedding 初篩來建構可靠的技能驗證系統。

什麼是 NLI (Natural Language Inference)

NLI 是判斷兩個句子之間邏輯關係的任務:

Premise (前提):  "我用 Kubernetes 部署了 50 個微服務到生產環境"
Hypothesis (假設): "這個人有 Kubernetes 經驗"

判斷: 蘊含 (Entailment) → COVERED

三種關係:

為什麼履歷驗證是 NLI 問題

Premise = 你的履歷素材
Hypothesis = "候選人具備技能 X"
Task = 判斷 Entailment / Neutral / Contradiction

這比單純的關鍵字匹配更可靠:

方法 優點 缺點
關鍵字匹配 快速 「提到 K8s」≠「會用 K8s」
Embedding 相似度 語意理解 只看相似,不看邏輯關係
NLI 判斷邏輯支持 需要更強的模型

增強版 NLI 架構

Input: skill + evidence_texts
              ↓
┌─────────────────────────────────────┐
│ 1. Embedding Pre-filter             │
│    cosine(skill, evidence) >= 0.8   │
│    → 過濾無關證據,減少 token       │
└────────────────┬────────────────────┘
                 ↓
┌─────────────────────────────────────┐
│ 2. Chain-of-Thought Analysis        │
│    對每條證據獨立分析:              │
│    - supports_skill: bool           │
│    - reasoning: str                 │
└────────────────┬────────────────────┘
                 ↓
┌─────────────────────────────────────┐
│ 3. Final Judgment                   │
│    綜合所有證據分析做最終判斷        │
│    - status: COVERED/IMPLIED/WEAK/  │
│              MISSING                │
│    - confidence: 0-1                │
└─────────────────────────────────────┘

Pydantic 強制 Schema

用 Pydantic 定義輸出結構,確保 LLM 回傳格式一致:

class EvidenceAnalysis(BaseModel):
    """分析單條證據"""
    evidence_text: str = Field(description="被分析的證據")
    supports_skill: bool = Field(description="是否支持技能")
    reasoning: str = Field(description="為什麼支持/不支持")


class NLIVerificationResult(BaseModel):
    """完整 NLI 驗證結果"""
    # Chain-of-thought: 先分析每條證據
    evidence_analysis: list[EvidenceAnalysis]
    
    # 最終判斷
    supports: bool
    status: EntailmentStatus
    confidence: float = Field(ge=0.0, le=1.0)
    reason: str

為什麼用 Pydantic 而非 JSON 字串

# ❌ 舊方法:解析 JSON 字串,可能格式錯誤
result = json.loads(response)
status = result.get("status", "MISSING")  # 可能 typo: "COVERD"

# ✅ 新方法:Pydantic 強制驗證
structured_llm = llm.with_structured_output(NLIVerificationResult)
result = structured_llm.invoke(prompt)  # 保證類型正確

Chain-of-Thought Prompt

讓 LLM 先逐條分析,再做最終判斷:

NLI_PROMPT_TEMPLATE = """You are performing Natural Language Inference (NLI) 
to determine if resume evidence supports a skill hypothesis.

## Hypothesis
The candidate has demonstrated the skill: "{skill}"

## Evidence (Premise)
{evidence}

## Instructions
1. Analyze each piece of evidence separately
2. For each, determine if it supports the hypothesis
3. Aggregate to make a final judgment

## Status Definitions
- COVERED: Direct, explicit evidence (entailment)
- IMPLIED: Indirect evidence, can be inferred (weak entailment)
- WEAK: Mentioned but not demonstrated (neutral)
- MISSING: No evidence (contradiction/no evidence)
"""

Chain-of-Thought 的效果

方法 輸出 可解釋性
直接判斷 {"status": "COVERED"}
Chain-of-Thought 每條證據分析 + 最終判斷

當結果是 WEAK 時,你可以看到是哪條證據不夠強:

{
  "evidence_analysis": [
    {
      "evidence_text": "Familiar with container technologies",
      "supports_skill": false,
      "reasoning": "Only mentions familiarity, not actual usage"
    },
    {
      "evidence_text": "Deployed services using Docker",
      "supports_skill": true,
      "reasoning": "Docker experience, but Kubernetes not mentioned"
    }
  ],
  "status": "WEAK",
  "reason": "Docker experience implies container knowledge, but no direct K8s evidence"
}

Embedding Pre-filter

當證據很多時,先用 embedding 相似度過濾:

def prefilter_by_similarity(skill, evidence_texts, threshold=0.8):
    skill_embedding = embed_query(skill)
    
    filtered = []
    for text in evidence_texts:
        text_embedding = embed_document(text)
        
        # Cosine similarity
        similarity = np.dot(skill_embedding, text_embedding) / (
            np.linalg.norm(skill_embedding) * np.linalg.norm(text_embedding)
        )
        
        if similarity >= threshold:
            filtered.append(text)
    
    return filtered

為什麼 Pre-filter 有效

10 條證據 5 條相關證據
LLM 處理 10 條 LLM 處理 5 條
~8000 tokens ~4000 tokens
成本 2x 成本 1x

同時減少雜訊,讓 NLI 更準確。

LangChain 整合

from langchain_openai import ChatOpenAI

def verify_skill_with_nli(skill, evidence_texts):
    # 取得 LangChain 模型
    llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.1)
    
    # 用 Pydantic schema 做結構化輸出
    structured_llm = llm.with_structured_output(NLIVerificationResult)
    
    # 建構 prompt
    prompt = NLI_PROMPT_TEMPLATE.format(
        skill=skill,
        evidence=format_evidence(evidence_texts)
    )
    
    # 調用 LLM
    result = structured_llm.invoke(prompt)
    
    return result

CLI 使用

# 標準 NLI (快速)
uv run career-kb verify --skills "Python,Kubernetes"

# 增強版 NLI (Chain-of-Thought)
uv run career-kb verify --skills "Python,Kubernetes" --enhanced-nli

與驗證流程的整合

Step 1: Skill Graph 快速推斷
        ↓ (如果不是 IMPLIED)
Step 2: Hybrid Search 找證據
        ↓
Step 3: Context Compression
        ↓
Step 4: NLI 判斷 ← 本文重點
        ↓
Step 5: Self-RAG Retry (對 WEAK/MISSING)

增強版 NLI 讓 Step 4 更可靠,減少 Step 5 的 retry 次數。

效能考量

模式 Tokens 延遲
標準 NLI ~500 ~1s
增強 NLI (Chain-of-Thought) ~1500 ~2s
+ Embedding Pre-filter 減少 40% +0.2s

建議

常見問題

1. 為什麼不用專門 NLI 模型?

傳統 NLI 模型(如 DeBERTa-NLI):

LLM-based NLI:

對履歷驗證,可解釋性比速度重要

2. threshold 0.8 怎麼來的?

經驗值。太低會保留太多無關證據,太高會過濾掉邊緣相關的。

0.7: 保留較多,NLI 負擔重
0.8: 平衡點 ← 推薦
0.9: 可能過濾掉邊緣相關證據

3. 如果所有證據都被過濾掉?

if not filtered_evidence:
    return NLIVerificationResult(
        evidence_analysis=[],
        supports=False,
        status=EntailmentStatus.MISSING,
        confidence=0.9,
        reason="No semantically similar evidence found"
    )

這本身就是一個信號:素材庫中沒有相關經驗。

總結

技術 功能
Pydantic Schema 強制輸出格式
Chain-of-Thought 可解釋的逐條分析
Embedding Pre-filter 降低成本和雜訊
LangChain Integration 簡化模型切換

增強版 NLI 讓技能驗證從「黑箱判斷」變成「可追溯推理」。


Career Knowledge Base 是一個本地優先的履歷知識庫系統,使用 Python + LanceDB + LangChain 建構。