Ian Chou's Blog

用 NLI 驗證履歷技能覆蓋:一個完整的實戰案例

用 NLI 驗證履歷技能覆蓋:一個完整的實戰案例

問題:JD 要求的技能,履歷素材真的能證明嗎?

你收到一份 JD,要求候選人具備 PythonKubernetesRAG 三項技能。你有一個素材庫存放過去的專案經歷。問題是:

這些素材能否證明你具備這些技能?

關鍵字匹配太弱——提到 "K8s" 不代表真的會用。Embedding 相似度只看語意相近,不看邏輯支持。我們需要的是 NLI(Natural Language Inference):判斷素材是否「蘊含」技能主張。

本文用一個完整案例帶你建構這套系統。


案例背景

輸入

輸出


Step 1:定義輸出結構

用 Pydantic 強制 LLM 輸出格式,避免 JSON 解析錯誤:

from pydantic import BaseModel, Field
from enum import Enum

class EntailmentStatus(str, Enum):
    COVERED = "COVERED"   # 有明確證據(蘊含)
    IMPLIED = "IMPLIED"   # 相關技能推論(弱蘊含)
    WEAK = "WEAK"         # 關鍵字出現但缺深度(中立)
    MISSING = "MISSING"   # 無證據(矛盾/無證據)

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

class SkillVerdict(BaseModel):
    """技能驗證結果"""
    skill: str
    status: EntailmentStatus
    confidence: float = Field(ge=0.0, le=1.0)
    evidence_analysis: list[EvidenceAnalysis]
    final_reasoning: str

Step 2:從素材庫檢索相關證據

用混合搜尋(向量 + BM25)從 LanceDB 撈取候選證據:

import lancedb
from langchain_openai import OpenAIEmbeddings

def search_evidence(skill: str, db_path: str, limit: int = 10) -> list[str]:
    """混合搜尋相關證據"""
    db = lancedb.connect(db_path)
    table = db.open_table("materials")
    
    # 向量搜尋
    embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
    query_vector = embeddings.embed_query(skill)
    
    results = (
        table.search(query_vector)
        .limit(limit)
        .to_pandas()
    )
    
    return results["text"].tolist()

Step 3:Context Compression(減少噪音)

不是每個句子都跟技能相關。用 Embedding 相似度過濾:

import numpy as np
from langchain_openai import OpenAIEmbeddings

def compress_context(
    skill: str, 
    evidence_texts: list[str], 
    threshold: float = 0.35
) -> list[str]:
    """只保留與技能語意相關的句子"""
    embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
    
    skill_vector = embeddings.embed_query(skill)
    
    filtered = []
    for text in evidence_texts:
        # 拆成句子
        sentences = text.split("。")
        for sentence in sentences:
            if len(sentence.strip()) < 10:
                continue
            
            sent_vector = embeddings.embed_query(sentence)
            
            # Cosine similarity
            similarity = np.dot(skill_vector, sent_vector) / (
                np.linalg.norm(skill_vector) * np.linalg.norm(sent_vector)
            )
            
            if similarity >= threshold:
                filtered.append(sentence)
    
    return filtered

效果:壓縮率通常 30-60%,同時減少 LLM 處理的噪音。


Step 4:NLI 判定(核心)

用 LangChain 的 with_structured_output 強制輸出 Pydantic schema:

from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate

NLI_PROMPT = """你正在進行自然語言推論(NLI),判斷證據是否支持技能假設。

## 假設(Hypothesis)
候選人具備技能:"{skill}"

## 證據(Premise)
{evidence}

## 任務
1. 逐條分析每項證據是否支持假設
2. 綜合判斷最終狀態

## 狀態定義
- COVERED:有明確證據展示該技能(具體專案、量化成果)
- IMPLIED:相關技能暗示能力,但未直接提及
- WEAK:關鍵字出現但缺乏深度/範例
- MISSING:找不到任何支持證據
"""

def verify_skill(skill: str, evidence: list[str]) -> SkillVerdict:
    """用 NLI 驗證技能覆蓋"""
    llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.1)
    structured_llm = llm.with_structured_output(SkillVerdict)
    
    prompt = ChatPromptTemplate.from_template(NLI_PROMPT)
    chain = prompt | structured_llm
    
    result = chain.invoke({
        "skill": skill,
        "evidence": "\n".join(f"- {e}" for e in evidence)
    })
    
    return result

Step 5:Self-RAG Retry(失敗時重試)

當結果是 WEAK 或 MISSING,自動生成替代查詢重試:

def refine_query(skill: str, reason: str, tried: list[str]) -> str:
    """根據失敗原因生成替代查詢"""
    llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.3)
    
    prompt = f"""技能 "{skill}" 驗證失敗。
原因:{reason}
已嘗試查詢:{tried}

生成一個替代查詢來找更好的證據。
策略:
- 同義詞擴展(如 Kubernetes → K8s, container orchestration)
- 上下文豐富(如 Python → Python backend, Python API)
- 分解技能(如 Full-stack → React frontend, Node.js backend)

只回傳一個查詢字串。"""
    
    return llm.invoke(prompt).content.strip()


def verify_with_retry(
    skill: str, 
    db_path: str, 
    max_retries: int = 2
) -> SkillVerdict:
    """帶重試的技能驗證"""
    tried_queries = [skill]
    current_query = skill
    
    for attempt in range(max_retries + 1):
        # 搜尋
        raw_evidence = search_evidence(current_query, db_path)
        
        # 壓縮
        compressed = compress_context(skill, raw_evidence)
        
        if not compressed:
            if attempt < max_retries:
                current_query = refine_query(skill, "無相關證據", tried_queries)
                tried_queries.append(current_query)
                continue
            else:
                return SkillVerdict(
                    skill=skill,
                    status=EntailmentStatus.MISSING,
                    confidence=0.9,
                    evidence_analysis=[],
                    final_reasoning="搜尋無結果"
                )
        
        # NLI 驗證
        verdict = verify_skill(skill, compressed)
        
        if verdict.status in [EntailmentStatus.COVERED, EntailmentStatus.IMPLIED]:
            return verdict
        
        # 失敗,準備重試
        if attempt < max_retries:
            current_query = refine_query(skill, verdict.final_reasoning, tried_queries)
            tried_queries.append(current_query)
    
    return verdict

Step 6:批次驗證所有技能

def verify_all_skills(skills: list[str], db_path: str) -> dict[str, SkillVerdict]:
    """驗證 JD 要求的所有技能"""
    results = {}
    
    for skill in skills:
        print(f"驗證:{skill}")
        verdict = verify_with_retry(skill, db_path)
        results[skill] = verdict
        
        # 顯示結果
        print(f"  狀態:{verdict.status.value}")
        print(f"  信心:{verdict.confidence:.0%}")
        print(f"  理由:{verdict.final_reasoning}")
        print()
    
    return results


# 使用範例
if __name__ == "__main__":
    skills = ["Python", "Kubernetes", "RAG"]
    results = verify_all_skills(skills, "./lancedb")
    
    # 統計
    covered = sum(1 for v in results.values() if v.status == EntailmentStatus.COVERED)
    print(f"覆蓋率:{covered}/{len(skills)}")

完整流程圖

flowchart TD
    S[JD 技能清單] --> SEARCH[混合搜尋]
    SEARCH --> COMP[Context Compression]
    COMP --> NLI[NLI 判定]
    NLI -->|COVERED/IMPLIED| DONE[完成]
    NLI -->|WEAK/MISSING| REFINE[生成替代查詢]
    REFINE --> SEARCH

實際輸出範例

驗證:Python
  狀態:COVERED
  信心:95%
  理由:多個專案明確使用 Python 建構後端 API 和資料處理流程

驗證:Kubernetes
  狀態:WEAK
  信心:40%
  理由:提及 Docker 容器化,但未直接展示 K8s 經驗

驗證:RAG
  狀態:COVERED
  信心:90%
  理由:Career Knowledge Base 專案完整實作 RAG 流水線

覆蓋率:2/3

附錄

A. NLI 理論背景

NLI(Natural Language Inference)是判斷兩個句子之間邏輯關係的任務:

關係 定義 範例
Entailment(蘊含) 前提能推導出假設 「他在 Google 當 SWE 三年」→「他有軟體工程經驗」✓
Contradiction(矛盾) 前提與假設衝突 「沒有雲端經驗」→「精通 AWS」✗
Neutral(中立) 無法判斷關係 「他會 Python」→「他會 Kubernetes」?

B. 為什麼 Evidence Selection > Chunks?

方面 傳統 Chunks Evidence Selection
選擇標準 向量相似度 邏輯支持度
處理衝突 全部塞進 prompt 衝突檢測 + 過濾
幻覺率 低(已預先驗證)
可追溯性 每個結論對應明確證據

C. 為什麼不在 Indexing 時做 Evidence Selection?

NLI 需要 Premise(證據)和 Hypothesis(假設)兩者才能判斷蘊含關係。

階段 已知資訊
Indexing ✅ 有 Premise(原文)、❌ 沒有 Hypothesis(查詢)
Query ✅ 有 Premise、✅ 有 Hypothesis

因此 Evidence Selection 只能在 Query 時進行。

D. 延伸閱讀


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