Ian Chou's Blog

混合搜尋:讓履歷素材無處遁形

混合搜尋:讓履歷素材無處遁形

單純的向量搜尋會漏掉關鍵字精準匹配,單純的 BM25 會漏掉語意相關。本文分享如何用 Union Merge + Rerank 實現完整的混合搜尋管道。

問題:向量搜尋的盲點

假設你搜尋 "Kubernetes deployment experience"

向量搜尋結果:
1. "Managed container orchestration for microservices" ✓ 語意相關
2. "Built CI/CD pipeline with ArgoCD" ✓ 語意相關
3. "Led cloud infrastructure team" ~ 有點相關

但你的素材庫裡有這樣一筆:

"Migrated 50+ Kubernetes workloads to multi-cluster setup"

這筆明明包含 Kubernetes 關鍵字,但因為整體語意和 query 的「經驗敘述」角度不同,向量相似度反而不高。

BM25 會抓到它,因為 BM25 在乎的是「Kubernetes」這個詞有沒有出現、出現幾次。

解決方案:Union Merge Pipeline

Query: "Kubernetes deployment experience"
                ↓
┌───────────────────────────────────────┐
│ Path A: Vector Search                 │
│ → 語意相似的 Top N                    │
└────────────────┬──────────────────────┘
                 │
┌────────────────┼──────────────────────┐
│ Path B: BM25 Search                   │
│ → 關鍵字命中的 Top N                  │
└────────────────┬──────────────────────┘
                 │
                 ▼
┌───────────────────────────────────────┐
│ Union Merge (by id)                   │
│ → 合併去重                            │
│ → 同時命中的標記 source: "both"       │
└────────────────┬──────────────────────┘
                 ↓
        ┌────────┴────────┐
        │ Apply Filters   │
        │ (skills, impact │
        │  role, exclude) │
        └────────┬────────┘
                 ↓
        ┌────────┴────────┐
        │ Rerank (Voyage) │
        │ or Fallback     │
        └────────┬────────┘
                 ↓
           Final Results

實作細節

query_vector = embed_query(focus)
vector_results = table.search(query_vector).limit(fetch_limit).to_list()

for r in vector_results:
    r["_source"] = "vector"
    r["_vector_score"] = r.get("_distance", 0)

不是對 vector 結果做 BM25,而是對全庫做:

# 取全庫文檔(上限 500)
all_docs = table.search().limit(500).to_list()

# 建立 BM25 索引
texts = [d.get("text", "") for d in all_docs]
bm25_index = BM25Index(texts)

# 搜尋
bm25_matches = bm25_index.search(focus, top_k=fetch_limit)

這樣能抓到「向量沒給高分但關鍵字命中」的素材。

Union Merge

seen_ids = set()
merged_results = []

# 先加 vector 結果
for r in vector_results:
    doc_id = r.get("id")
    if doc_id not in seen_ids:
        seen_ids.add(doc_id)
        merged_results.append(r)

# 再加 BM25 結果(去重)
for r in bm25_results:
    doc_id = r.get("id")
    if doc_id not in seen_ids:
        seen_ids.add(doc_id)
        merged_results.append(r)
    else:
        # 同時命中:合併 BM25 分數
        for existing in merged_results:
            if existing.get("id") == doc_id:
                existing["_bm25_score"] = r.get("_bm25_score", 0)
                existing["_source"] = "both"  # 標記
                break

為什麼 source: "both" 很重要?

同時被向量和關鍵字命中的素材,通常是最相關的。這個標記可以在後續處理中給予加權。

Hybrid Score

合併後計算混合分數:

hybrid_score = 0.7 * vector_score + 0.3 * bm25_score
權重 理由
70% Vector 語意理解更重要,避免關鍵字刷分
30% BM25 精準命中不能漏,專有名詞很重要

篩選器設計

required_skills (any vs all)

# any: 素材包含 Python 或 RAG 就算匹配
--skills "Python,RAG" --skills-mode any

# all: 素材必須同時包含 Python 和 RAG
--skills "Python,RAG" --skills-mode all

實作:

if skills_mode == "all":
    # 集合包含檢查
    results = [r for r in results
               if skill_set.issubset({s.lower() for s in r.get("skills", [])})]
else:  # any
    results = [r for r in results 
               if any(s.lower() in skill_set for s in r.get("skills", []))]

exclude_used_in

避免同一份履歷重複使用相同素材:

--exclude-used-in "google-jd-123,meta-jd-456"
results = [r for r in results 
           if not any(jd in exclude_jds for jd in r.get("used_in_jobs", []))]

Rerank(Voyage AI)

混合搜尋後的結果順序是「系統算出來的」,不一定是「對這個 query 最相關的」。Rerank 用專門的模型重新評估每個結果對 query 的相關性。

from voyageai import Client

client = Client()
response = client.rerank(
    query=focus,
    documents=[r["text"] for r in results],
    model="rerank-2",
    top_k=limit,
)

# 結果包含原始索引和相關性分數
for item in response.results:
    print(f"Index: {item.index}, Score: {item.relevance_score}")

Rerank 的效果

排序方式 優點 缺點
Hybrid Score 快、免費 可能沒抓到 query 意圖
Rerank 更懂 query 語意 額外 API 成本

什麼時候用 Rerank?

Fallback 排序

當 Rerank API 失敗時:

def fallback_sort(results, top_k):
    def sort_key(r):
        impact = r.get("impact_level", 0)
        used_count = len(r.get("used_in_jobs", []))
        hybrid = r.get("_hybrid_score", 0)
        # 高 impact 優先,低 used_count 優先(增加多樣性)
        return (-impact, used_count, -hybrid)
    
    return sorted(results, key=sort_key)[:top_k]

Fallback 邏輯:

  1. impact_level 高的優先 - 高影響力經驗更值得放進履歷
  2. used_count 低的優先 - 避免每份履歷都用同一段
  3. hybrid_score 保底 - 還是要顧相關性

輸出結構

{
  "results": [
    {
      "id": "abc123",
      "text": "Led Kubernetes migration...",
      "skills": ["Kubernetes", "Docker"],
      "impact_level": 4,
      "used_count": 2,
      "vector_score": 0.85,
      "bm25_score": 0.72,
      "hybrid_score": 0.81,
      "relevance_score": 0.93,  // Rerank 分數
      "source": "both"          // 雙路命中
    }
  ],
  "reranked": true,
  "skills_mode": "any"
}

CLI 使用

# 基本混合搜尋
uv run career-kb search --focus "distributed systems" --skills "K8s"

# 啟用 Rerank
uv run career-kb search --focus "..." --use-rerank

# skills 必須全部匹配
uv run career-kb search --focus "..." --skills "Python,RAG" --skills-mode all

# 排除已用素材
uv run career-kb search --focus "..." --exclude-used-in "jd-123"

# 純向量搜尋(關閉 BM25)
uv run career-kb search --focus "..." --no-bm25

效能考量

步驟 延遲 成本
Vector Search ~50ms 包含在 LanceDB
BM25 Search ~10ms 免費(本地)
Union Merge <1ms 免費
Filters <1ms 免費
Rerank ~200ms Voyage API

建議:

與其他步驟的銜接

verify → 找出 MISSING/WEAK
           ↓
       search ← 本文重點
           ↓
   找到可用素材或可遷移證據
           ↓
       generate → 組裝履歷

Search 是連接「診斷」和「生成」的橋樑:

總結

功能 解決的問題
Vector + BM25 Union 語意和關鍵字都不漏
skills_mode all/any 精確控制技能篩選
exclude_used_in 避免素材重複使用
Rerank 確保最終排序貼合 query
Fallback Sort Rerank 失敗時的保底

混合搜尋讓履歷素材「無處遁形」,不管是語意相關還是關鍵字命中,都能被找到。


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