Ian Chou's Blog

從 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 吧?」

為什麼需要 BM25?

純 Vector Search 的核心問題是:語意匹配 ≠ 關鍵字匹配

想像一下這個場景:

這是因為 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 再怎麼重評分也救不回來。

雙路檢索才能真正解決召回率(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();

分數歸一化:關鍵細節

這是一個容易被忽略但極其重要的細節:

如果直接 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 AIrerank-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 順便判斷
});

問題

解法:RAG-based Entailment(蘊含檢查)

我將 Gap 識別變成一個**邏輯蘊含(NLI, Natural Language Inference)**任務:

  1. 分解:將 JD 拆解為獨立的 Skill Claims
  2. 驗證:針對每個 Claim,用 Hybrid Search 找證據
  3. 判決:用 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 反思迴圈

選擇邏輯


第六章:副產品 — 素材補強建議

這套架構的一個意外收穫是:不只識別 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)**模式讓每個判斷都有跡可循,這對於嚴肅的職涯決策至關重要。


延伸閱讀