從 Vector Search 到 Advanced RAG:Hybrid Search、Reranking 與證據驗證的完整實踐
前言:當「能用」不等於「好用」
你寫了一個 RAG(Retrieval-Augmented Generation)系統,Vector Search 跑起來了,LLM 也能回答問題了。但當你深入測試,問題開始浮現:
「為什麼搜尋 'Next.js' 找到的是 'React Web Framework' 的內容,而不是真正提到 Next.js 的文件?」
「為什麼 LLM 說我的履歷缺少 'GraphQL 經驗',但我明明有寫過 GraphQL 專案?」
這就是從「Demo 級 RAG」到「生產級 RAG」的差距。本文將分享我在開發個人職涯知識庫 MCP Server時,如何一步步將基礎的 Vector Search 升級為工業級的 Advanced RAG 架構。
第一章:原始架構與痛點分析
原始流程
最初的搜尋流程很單純:
JD Focus Query → Embedding → Vector Search (L2 distance) → Top K Results
使用 LanceDB 作為向量資料庫,BGE-M3 作為 Embedding 模型,流程如下:
// 原始的 searchMaterialByJdFocus 實作(簡化版)
const queryEmbedding = await generateEmbedding(focus);
const results = await table
.vectorSearch(queryEmbedding)
.limit(limit)
.toArray();
三大痛點
| 痛點 | 描述 | 實際影響 |
|---|---|---|
| 關鍵字缺失 | Vector Search 只看「語意相似」,會忽略精確關鍵字 | 搜尋 "CISSP" 可能只找到「資安」相關,但沒有提到 CISSP 的文件 |
| 排序不精準 | Cosine Similarity 是近似搜尋,速度快但精度一般 | Top 5 的文件不一定是最相關的 |
| Gap 識別不可靠 | 依賴 LLM 在生成階段「順便」判斷缺口 | LLM 可能幻覺:「你有 React 應該也懂 Redux 吧?」 |
第二章:Hybrid Search — 雙路檢索架構
為什麼需要 BM25?
純 Vector Search 的核心問題是:語意匹配 ≠ 關鍵字匹配。
想像一下這個場景:
- 你的履歷裡有一段:「負責 AST-grep 工具的開發與維護」
- JD 要求:「熟悉 AST-grep」
- Vector Search 的結果:找到一堆「程式碼分析工具」、「Linter」的內容,但 AST-grep 那段分數反而不高
這是因為 Embedding 模型把 "AST-grep" 理解為「一種程式碼分析工具」的概念,而不是一個專有名詞。
BM25(Best Matching 25)是一個經典的關鍵字匹配演算法,它保證了:包含精確關鍵字的文件權重更高。
雙路檢索架構
┌─────────────────────────────────────────────────────────────────┐
│ Focus Query │
│ │ │
│ ┌─────────────┴─────────────┐ │
│ ▼ ▼ │
│ ┌────────────────┐ ┌────────────────┐ │
│ │ Vector Search │ │ BM25 Search │ │
│ │ (Top 50) │ │ (Top 50) │ │
│ └───────┬────────┘ └───────┬────────┘ │
│ │ │ │
│ └───────────┬───────────────┘ │
│ ▼ │
│ ┌────────────────┐ │
│ │ Set Union │ ← 合併去重 │
│ │ (60-80 docs) │ │
│ └───────┬────────┘ │
│ ▼ │
│ ┌────────────────┐ │
│ │ Fusion Score │ ← 分數歸一化 + 加權 │
│ └───────┬────────┘ │
│ ▼ │
│ ┌────────────────┐ │
│ │ Rerank │ ← Voyage Reranker │
│ │ (Top K) │ │
│ └────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
為什麼是「雙路」而非「重評分」?
這裡有一個關鍵的邏輯區別:
- 重評分模式:只用 Vector Search 撈候選名單,然後用 BM25 重新排序
- 雙路檢索模式:Vector 和 BM25 各自獨立撈候選名單,然後合併
重評分模式的問題:如果 Vector Search 漏掉了某個文件(因為語意不夠相似),BM25 再怎麼重評分也救不回來。
雙路檢索才能真正解決召回率(Recall)的問題——讓 BM25 找回那些「關鍵字完全匹配但語意分數低」的文件。
BM25 實作:使用 okapibm25
在 JavaScript/TypeScript 生態中,我選擇了 okapibm25 這個輕量級的 BM25 實作:
// bm25.ts
import BM25 from "okapibm25";
interface IndexedDocument {
id: string;
text: string;
}
class BM25IndexManager {
private documents: IndexedDocument[] = [];
private bm25Instance: BM25 | null = null;
// 建立索引(Server 啟動時執行)
buildIndex(docs: IndexedDocument[]): void {
this.documents = docs;
const corpus = docs.map((doc) => doc.text);
this.bm25Instance = new BM25(corpus);
}
// 搜尋
search(query: string, topK: number = 10): SearchResult[] {
if (!this.bm25Instance) return [];
// 取得每個文件的 BM25 分數
const scores = this.bm25Instance.search(query);
// 排序並取 Top K
return scores
.map((score, idx) => ({
id: this.documents[idx].id,
score,
text: this.documents[idx].text,
}))
.sort((a, b) => b.score - a.score)
.slice(0, topK);
}
}
export const bm25Manager = new BM25IndexManager();
分數歸一化:關鍵細節
這是一個容易被忽略但極其重要的細節:
- Vector Score:通常是 0 ~ 1(Cosine Similarity)
- BM25 Score:通常是 0 ~ ∞(取決於文檔長度和詞頻)
如果直接 0.7 * Vector + 0.3 * BM25,BM25 可能產生 10 或 20 的分數,瞬間壓過 Vector。
解法:在 Fusion 之前,必須將 BM25 分數進行 Min-Max Normalization:
function normalizeScores(scores: number[]): number[] {
if (scores.length === 0) return [];
const min = Math.min(...scores);
const max = Math.max(...scores);
if (max === min) return scores.map(() => 1);
return scores.map((s) => (s - min) / (max - min));
}
// Fusion 計算
const fusionScore =
VECTOR_WEIGHT * normalizedVectorScore +
BM25_WEIGHT * normalizedBM25Score;
第三章:Voyage Reranker — Cross-Encoder 的威力
為什麼需要 Reranker?
雙路檢索解決了「召回」問題,但合併後可能有 60-80 個候選文件。我們需要從中挑出真正最相關的 Top K。
這時候就需要 Reranker——它使用 Cross-Encoder 架構,會精讀你的 Query 和每一份文件,判斷它們的語意關聯性。
| 模型類型 | 運作方式 | 速度 | 精度 |
|---|---|---|---|
| Bi-Encoder(Embedding) | Query 和 Doc 各自 Embed,算 Cosine | 快 | 中 |
| Cross-Encoder(Reranker) | Query + Doc 一起餵進 Transformer | 慢 | 高 |
Voyage Rerank API 整合
我選擇了 Voyage AI 的 rerank-2.5 模型:
// reranker.ts
const VOYAGE_RERANK_URL = "https://api.voyageai.com/v1/rerank";
interface RerankResult {
index: number;
relevance_score: number;
}
export async function rerank(
query: string,
documents: string[],
options: { model?: string; topK?: number; truncation?: boolean } = {}
): Promise<RerankResult[]> {
const {
model = "rerank-2.5",
topK,
truncation = true // 預設開啟截斷,避免超過 32K token 限制
} = options;
const response = await fetch(VOYAGE_RERANK_URL, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.VOYAGE_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model,
query,
documents,
top_k: topK,
truncation,
}),
});
const data = await response.json();
return data.data;
}
Token 限制處理
Voyage rerank-2.5 的 Context Window 是 32,000 tokens。當 truncation: true 時,API 會自動截斷過長的內容,不會報錯。
對於履歷素材(每個 bullet point 通常只有幾十到幾百 tokens),這個限制綽綽有餘。
第四章:完整的搜尋 Pipeline
將以上元件組合,完整的搜尋流程如下:
async function hybridSearch(query: string, options: SearchOptions) {
const { limit = 5, vectorWeight = 0.7, bm25Weight = 0.3 } = options;
// Step 1: 並行執行雙路檢索
const [vectorResults, bm25Results] = await Promise.all([
vectorSearch(query, limit * 3),
bm25Manager.search(query, limit * 3),
]);
// Step 2: 合併結果 (Set Union)
const mergedMap = new Map<string, MergedResult>();
// 歸一化 Vector scores
const vectorScores = vectorResults.map(r => r.score);
const normalizedVectorScores = normalizeScores(vectorScores);
vectorResults.forEach((result, idx) => {
mergedMap.set(result.id, {
...result,
vectorScore: normalizedVectorScores[idx],
bm25Score: 0,
});
});
// 歸一化 BM25 scores 並合併
const bm25Scores = bm25Results.map(r => r.score);
const normalizedBM25Scores = normalizeScores(bm25Scores);
bm25Results.forEach((result, idx) => {
const existing = mergedMap.get(result.id);
if (existing) {
existing.bm25Score = normalizedBM25Scores[idx];
} else {
mergedMap.set(result.id, {
...result,
vectorScore: 0,
bm25Score: normalizedBM25Scores[idx],
});
}
});
// Step 3: 計算 Fusion Score
const fusedResults = Array.from(mergedMap.values()).map(r => ({
...r,
fusionScore: vectorWeight * r.vectorScore + bm25Weight * r.bm25Score,
}));
// Step 4: Rerank
const sortedByFusion = fusedResults
.sort((a, b) => b.fusionScore - a.fusionScore)
.slice(0, limit * 2);
const rerankedResults = await rerank(
query,
sortedByFusion.map(r => r.text),
{ topK: limit }
);
// Step 5: 組合最終結果
return rerankedResults.map(rr => ({
...sortedByFusion[rr.index],
rerankScore: rr.relevance_score,
}));
}
第五章:證據驗證式 Gap 識別 — 從幻覺到 Grounding
問題:LLM 的「順便判斷」不可靠
原始的 Gap 識別方式是讓 LLM 在生成履歷時「順便」輸出 skills_gap:
const ResumeSchema = z.object({
// ... 履歷內容
skills_matched: z.array(z.string()),
skills_gap: z.array(z.string()), // LLM 順便判斷
});
問題:
- LLM 可能幻覺:「你有 React,應該也懂 Redux 吧?」→ Gap 被掩蓋
- Context Window 擠滿其他雜訊,LLM 漏看某段經歷中的關鍵字
- 完全依賴 LLM 推理,無法追溯「為什麼這是 Gap」
解法:RAG-based Entailment(蘊含檢查)
我將 Gap 識別變成一個**邏輯蘊含(NLI, Natural Language Inference)**任務:
- 分解:將 JD 拆解為獨立的 Skill Claims
- 驗證:針對每個 Claim,用 Hybrid Search 找證據
- 判決:用 LLM 判斷「找到的證據」是否足以支撐「該技能主張」
更精細的覆蓋狀態
不再用 matched / gaps 二分法,而是引入證據強度:
enum CoverageStatus {
COVERED = "COVERED", // 證據確鑿
IMPLIED = "IMPLIED", // 語意相關但不直接(可遷移技能)
WEAK = "WEAK", // 只有提到關鍵字,缺乏深度描述
MISSING = "MISSING" // 完全無相關證據 → 真實 Gap
}
interface SkillCoverageResult {
skill: string;
status: CoverageStatus;
confidence: number;
evidenceSnippets: string[]; // 來自資料庫的原始文句
reasoning: string; // LLM 的判斷依據
improvementStrategy?: string; // 如果是 WEAK/MISSING,建議補強方向
}
完整的驗證流程
// verify-skill-coverage.ts
import { generateObject } from "ai";
import { google } from "@ai-sdk/google";
async function verifySkillCoverage(jdSkills: string[]) {
const results: SkillCoverageResult[] = [];
for (const skill of jdSkills) {
// Step 1: 用 Hybrid Search 找證據
const evidences = await hybridSearch(skill, { limit: 3 });
// Step 2: 用 LLM 做 NLI 判斷
const { object: verdict } = await generateObject({
model: google("gemini-2.0-flash"),
schema: VerdictSchema,
prompt: `
You are evaluating whether a skill requirement is covered by the candidate's resume materials.
Skill to verify: "${skill}"
Evidence snippets from resume:
${evidences.map((e, i) => `${i + 1}. ${e.text}`).join("\n")}
Determine the coverage status:
- COVERED: Direct evidence with specific details
- IMPLIED: Related experience suggests transferable skills
- WEAK: Keyword mentioned but lacks depth
- MISSING: No relevant evidence found
`,
temperature: 0.2,
});
results.push({
skill,
...verdict,
evidenceSnippets: evidences.map(e => e.text),
});
}
return results;
}
為什麼用 Vercel AI SDK 而非 LangChain?
在這個專案中,我混用了兩個 LLM 框架:
| 檔案 | 框架 | 使用情境 |
|---|---|---|
verify-skill-coverage.ts |
Vercel AI SDK | 簡單的單次 LLM 呼叫 |
reflection-chain.ts |
LangChain | Writer-Critic 反思迴圈 |
選擇邏輯:
- Vercel AI SDK 的
generateObject+ Zod schema 非常簡潔,適合簡單任務 - LangChain 適合複雜的多輪對話或狀態機(如 LangGraph)
第六章:副產品 — 素材補強建議
這套架構的一個意外收穫是:不只識別 Gap,還能提供具體的補強建議。
當 status === "WEAK" 時,表示你有相關經歷,但描述不夠具體:
{
"skill": "CI/CD",
"status": "WEAK",
"evidenceSnippets": [
"負責專案的 CI/CD 設定",
"使用 GitHub Actions 自動化部署"
],
"reasoning": "有提及 CI/CD 關鍵字,但缺乏具體細節(如:使用什麼工具、解決什麼問題、量化成果)",
"improvementStrategy": "建議擴寫其中一個專案的 CI/CD 細節,例如:「建置 GitHub Actions pipeline,將部署時間從 30 分鐘縮短至 5 分鐘」"
}
這讓「Gap 識別」從一次性的診斷工具,變成了持續改進素材庫的回饋機制。
第七章:架構總覽
最終的 Advanced RAG 架構:
┌─────────────────────────────────────────────────────────────────┐
│ Resume Generation MCP Server │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────┐ │
│ │ JD Input │ │
│ └──────┬──────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Hybrid Search Pipeline │ │
│ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ │
│ │ │ Vector │ │ BM25 │ │ Voyage │ │ │
│ │ │ Search │ + │ Search │ → │ Rerank │ │ │
│ │ │ (LanceDB) │ │(okapibm25)│ │ 2.5 │ │ │
│ │ └───────────┘ └───────────┘ └───────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Evidence-Based Gap Verification │ │
│ │ ┌───────────┐ ┌───────────┐ ┌───────────┐ │ │
│ │ │ Skill │ → │ Hybrid │ → │ NLI │ │ │
│ │ │ Claims │ │ Search │ │ Verdict │ │ │
│ │ │ │ │ Evidence │ │ (Gemini) │ │ │
│ │ └───────────┘ └───────────┘ └───────────┘ │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ LangChain Reflection Chain │ │
│ │ Writer → Critic → Reviser (Iterative) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ Output │ Resume JSON + Gap Analysis │
│ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
總結:三個關鍵 Takeaways
1. Hybrid Search 是 Advanced RAG 的標配
純 Vector Search 的召回率有天花板,BM25 補足了關鍵字匹配的能力。雙路檢索 + 分數歸一化 + Reranking 是目前業界通用的配置。
2. Reranker 是性價比最高的升級
如果你只能做一個改進,加 Reranker。Cross-Encoder 的精度遠超 Bi-Encoder,而且 Voyage 等服務的成本很低。
3. Gap 識別需要 Grounding,不能依賴幻覺
讓 LLM「順便判斷」缺口是不可靠的。**證據驗證(NLI Check)**模式讓每個判斷都有跡可循,這對於嚴肅的職涯決策至關重要。