混合搜尋:讓履歷素材無處遁形
混合搜尋:讓履歷素材無處遁形
單純的向量搜尋會漏掉關鍵字精準匹配,單純的 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
實作細節
Path A: Vector Search
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)
Path B: BM25 Search (獨立)
不是對 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?
- 素材庫大(> 100 筆):Hybrid Score 可能不夠精準
- Query 複雜:「找能展現領導力的分散式系統經驗」
- 最終輸出:履歷生成前的最後篩選
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 邏輯:
- impact_level 高的優先 - 高影響力經驗更值得放進履歷
- used_count 低的優先 - 避免每份履歷都用同一段
- 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 |
建議:
- 開發/測試時用
--no-bm25或不用--use-rerank加速 - 最終生成履歷時開啟
--use-rerank確保品質
與其他步驟的銜接
verify → 找出 MISSING/WEAK
↓
search ← 本文重點
↓
找到可用素材或可遷移證據
↓
generate → 組裝履歷
Search 是連接「診斷」和「生成」的橋樑:
- 對 MISSING 技能:找可遷移經驗
- 對 WEAK 技能:找更強的證據
- 對 generate:提供高品質候選素材
總結
| 功能 | 解決的問題 |
|---|---|
| Vector + BM25 Union | 語意和關鍵字都不漏 |
| skills_mode all/any | 精確控制技能篩選 |
| exclude_used_in | 避免素材重複使用 |
| Rerank | 確保最終排序貼合 query |
| Fallback Sort | Rerank 失敗時的保底 |
混合搜尋讓履歷素材「無處遁形」,不管是語意相關還是關鍵字命中,都能被找到。
Career Knowledge Base 是一個本地優先的履歷知識庫系統,使用 Python + LanceDB + Voyage AI 建構。
- ← Previous
教練循環:用 STAR 法把經驗變成履歷素材 - Next →
用 Reflection Chain 生成高品質履歷