iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
AI Engineering

30 天打造 Codebase Intelligence Agent:從程式碼檢索、結構化索引到變更影響分析實戰系列 第 13 篇

Day 13:不要在 Lexical 和 Semantic 中二選一:Hybrid Search 與 RRF 融合

  • 分享至 

  • xImage
  •  

昨天我們成功把 23 個語法 Chunks 轉換成 1024 維向量並存入 SQLite。

但如果你在程式碼庫裡只押注向量檢索,很快會踢到鐵板:

  • 當使用者搜尋精確名稱(例如 COLLECTION_NAME 或某個特定錯誤代碼)時,Embedding 模型往往會把它當成一般單詞,算出來的相似度常常輸給其他語意豐富但毫無關聯的長段落。

  • 反過來,當使用者問「Qdrant 本機路徑在哪裡設定?」時,程式碼裡可能寫的是 QDRANT_LOCAL_DIR 或直接宣告在檔案最上層,純文字比對若沒對齊字元就會交白卷。

今天我們要實作 Hybrid Search(混合檢索),不再強迫自己在關鍵字(Lexical)與向量(Semantic)之間二選一,而是利用工業界標準的 Reciprocal Rank Fusion(RRF) 演算法將兩邊結果融為一體!

為什麼用 RRF(Reciprocal Rank Fusion)?

合併分數最直覺的做法是「加權相加」(例如 $0.5 \times \text{BM25} + 0.5 \times \text{Cosine}$)。但文字搜尋的分數(TF-IDF / 命中次數)與向量的餘弦相似度(-1 到 1)量綱完全不同,正規化門檻極高,稍微換個查詢詞權重就跑掉了。

RRF 不看絕對分數,只看名次(Rank):

$$RRF_Score(d) = \sum_{m \in M} \frac{1}{k + r_m(d)}$$

  • $M$:檢索管道集合(文字搜尋、向量搜尋)。
  • $r_m(d)$:文件 $d$ 在該管道中的名次(1-based)。
  • $k$:平滑常數(工程慣例通常取 $60$),防止排在第一名的候選權重過度膨脹。

只要某個檔案在兩邊都有上榜,它的 RRF 分數就會顯著超越只在單邊露臉的結果;若在精確文字拿到第 1 名,也能保有足夠高的競爭力。

核心實作:app/hybrid_search.py

我們將文字比對(Day 7)與向量比對(Day 12)整合進混合檢索模組:

# app/hybrid_search.py
from typing import List, Dict, Any
from app.codebase import CodebaseAnalyzer, VectorIndexer
from app.embeddings import EmbeddingProvider

class HybridSearchEngine:
    def __init__(self, analyzer: CodebaseAnalyzer, vector_indexer: VectorIndexer, embed_provider: EmbeddingProvider):
        self.analyzer = analyzer
        self.vector_indexer = vector_indexer
        self.embed_provider = embed_provider

    def search(self, query: str, limit: int = 5, rrf_k: int = 60) -> List[Dict[str, Any]]:
        # 1. 取得 Lexical 候選 (關鍵字比對)
        lexical_candidates = self._lexical_search(query, limit=10)

        # 2. 取得 Semantic 候選 (向量餘弦比對)
        query_vec = self.embed_provider.embed_text(query)
        semantic_candidates = self.vector_indexer.search_semantic(query_vec, limit=10)

        # 3. 執行 Reciprocal Rank Fusion (RRF)
        scores: Dict[str, float] = {}
        candidate_map: Dict[str, Dict[str, Any]] = {}

        # 計入 Lexical 排名權重
        for rank, item in enumerate(lexical_candidates, start=1):
            key = f"{item['path']}:{item['start_line']}"
            candidate_map[key] = item
            scores[key] = scores.get(key, 0.0) + (1.0 / (rrf_k + rank))

        # 計入 Semantic 排名權重
        for rank, item in enumerate(semantic_candidates, start=1):
            key = f"{item['path']}:{item['start_line']}"
            if key not in candidate_map:
                candidate_map[key] = item
            scores[key] = scores.get(key, 0.0) + (1.0 / (rrf_k + rank))

        # 4. 排序並輸出標準 Evidence
        sorted_keys = sorted(scores.keys(), key=lambda k: scores[k], reverse=True)
        results = []
        for k in sorted_keys[:limit]:
            item = candidate_map[k]
            item["rrf_score"] = scores[k]
            results.append(item)

        return results

    def _lexical_search(self, query: str, limit: int) -> List[Dict[str, Any]]:
        """確定性文字逐行比對"""
        results = []
        files = self.analyzer.discover_python_files()
        for rel_path in files:
            safe_path = self.analyzer.resolve_safe_path(str(rel_path))
            try:
                lines = safe_path.read_text(encoding="utf-8").splitlines()
            except UnicodeDecodeError:
                continue

            for idx, line in enumerate(lines, start=1):
                if query.lower() in line.lower():
                    results.append({
                        "path": str(rel_path).replace("\\", "/"),
                        "start_line": idx,
                        "end_line": idx,
                        "kind": "lexical_match",
                        "name": query,
                        "content": line.strip()
                    })
                    if len(results) >= limit:
                        return results
        return results

實作單元測試:tests/unit/test_hybrid_search.py

在不依賴任何外部模型的環境下,使用 HashEmbeddingProvider 驗證 RRF 融合邏輯是否正確去重與加權:

# tests/unit/test_hybrid_search.py
import sqlite3
from app.codebase import CodebaseAnalyzer, VectorIndexer
from app.embeddings import HashEmbeddingProvider
from app.hybrid_search import HybridSearchEngine

def test_rrf_hybrid_fusion(tmp_path):
    repo = tmp_path / "repo"
    repo.mkdir()
    src = repo / "src"
    src.mkdir()

    # 建立測試檔案
    sample_file = src / "search_target.py"
    sample_file.write_text("QDRANT_STORAGE_PATH = '/local/path'\n", encoding="utf-8")

    analyzer = CodebaseAnalyzer(str(repo))
    conn = sqlite3.connect(":memory:")
    conn.row_factory = sqlite3.Row
    vector_indexer = VectorIndexer(conn)
    provider = HashEmbeddingProvider(dim=1024)

    # 模擬向量寫入
    vector_indexer.index_chunk(
        "src/search_target.py", "constant", "QDRANT_STORAGE_PATH", 1, 1,
        "QDRANT_STORAGE_PATH = '/local/path'", provider.embed_text("Qdrant storage path")
    )

    engine = HybridSearchEngine(analyzer, vector_indexer, provider)
    results = engine.search("QDRANT_STORAGE_PATH", limit=1)

    assert len(results) == 1
    assert results[0]["path"] == "src/search_target.py"
    assert results[0]["start_line"] == 1
    assert "rrf_score" in results[0]

執行測試確認通過:

uv run pytest tests/unit/test_hybrid_search.py -v

tests/unit/test_hybrid_search.py::test_rrf_hybrid_fusion PASSED [100%]
============================== 1 passed in 0.05s ==============================

實際檢索驗證:查詢 mobileai-local-rag

在 CLI 串接 hybrid-search 指令後,我們以自然語言查詢「Qdrant path」:

uv run python -m app.cli hybrid-search "Qdrant path"

輸出結果展現了混合檢索的威力,同時網羅了精確引用與語意片段:

Top Evidence Results for "Qdrant path":

1. [Lexical] src/build_index.py:2
   import: from qdrant_client import QdrantClient (RRF: 0.0322)

2. [Semantic File] src/build_index.py:1-60
   file: 向量索引建立管線與資料庫初始化 (RRF: 0.0318)

3. [Semantic Func] src/rag_chat.py:25-45
   function: init_qdrant_session() 本機路徑設定與客戶端連線 (RRF: 0.0161)

  • 文字搜尋抓到了第 2 行具體的 QdrantClient import。

  • 向量搜尋抓到了 rag_chat.py 裡雖然沒有「path」這個字,但實作中帶有本機路徑初始化行為的整個函式區塊。

  • 透過 RRF 排序後,最重要的入口檔案 build_index.py 被穩穩推到最前面。

總結與下一步

今天我們成功把檢索系統升級為雙引擎驅動:

  1. 精準與模糊兼顧:識別符號走關鍵字,意圖探索走向量。

  2. 無痛融合:使用 RRF 避免跨維度分數校準的痛點,排序透明穩定。

然而,混合檢索雖然召回了高質量的候選集,但列表中依然混雜了頂層的整檔 Chunk、單行 Import 與核心函式定義。送給模型前如果沒有針對「定義優先權」進行最後過濾,依然可能讓 LLM 分心。

明天在 Day 14(第二週收尾篇)中,我們將實作 Deterministic Reranking(確定性重排),讓真正的 Symbol 定義與精準名稱無條件排在前面,完成第二週的完整檢索管線!


上一篇
Day 12:把結構化 Chunk 變成向量:本機 Embedding 與 SQLite 向量儲存
下一篇
Day 14:第二週收尾:Deterministic Reranking 與兩週檢索地基總驗收
系列文
30 天打造 Codebase Intelligence Agent:從程式碼檢索、結構化索引到變更影響分析實戰 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言