跳到主要內容

AG Day 22 檢索品質:查詢改寫與 rerank

AG Day 22 檢索品質:查詢改寫與 rerank

執行需求:CPU+API key。今天是「檢索」區塊的第三篇,我們要把一個「能查到東西」的知識庫,推進到「查得準、查得穩、查得可量化」的檢索層。前兩天我們已經把網頁擷取、分塊與向量化做完:AG Day 20 RAG 基礎:embedding 與向量檢索(原文連結)建立了向量檢索的基本原理,AG Day 21 文件擷取與分塊:從網頁到知識庫(原文連結)則把清洗、切塊、寫入 SQLite 與 Chroma 的流程串成可重複執行的管線。今天要處理的是那條流程跑完之後才浮現的問題:top-k 向量檢索的結果,常常不是我們真正想要的。查詢改寫(query rewriting)與精排(rerank)就是兩個最常見、投報率也最高的補救手段。文章需要 LLM API key 才能完整跑完,但我們照慣例提供離線的 --dry-run 模式,讓沒有金鑰的讀者也能把整條流程跑通並看到示範輸出。

引言

如果你跟著前兩天動手做,現在手上應該有一個 data/knowledge.db(內含 documents、chunks 兩張表)與一個 data/chroma/ 向量庫。丟一個問題進去,程式會回傳五段最接近的文字。問題是:這五段「最接近」,跟你心裡的「最相關」常常是兩回事。你問「2026 年邊緣 AI 晶片的能源效率有什麼進展」,模型回給你的可能是三段談「AI 晶片」的總論、一段談「能源政策」,以及一段剛好出現「效率」兩個字但完全在講別的領域的段落。這種檢索品質若直接餵給後面的生成步驟,報告自然充滿似是而非的內容。

為什麼會這樣?因為「向量相似度」衡量的是語意接近程度,而不是「這段文字能不能回答這個問題」。這兩個目標高度相關但不相等。實務上我習慣把檢索拆成兩段式漏斗:先用便宜、寬鬆的方式盡量撈出候選(召回),再用昂貴但精準的方式在少數候選裡排序(精排)。今天的兩個主角正好對應漏斗的兩端——查詢改寫負責提高召回,rerank 負責提高精確度。讀完這篇之後,你應該能自己回答三個問題:我的檢索現在的 recall 是多少?查詢改寫值不值得?rerank 的成本該花在多少個候選上?

先講清楚本篇的範圍。我們不會重新發明向量檢索,也不換掉 Chroma;我們是在既有的 retrieval.py 上疊加三層能力:查詢改寫、多路融合、精排,並補上一組可以量測變化的評估函式。這套「先建立量測、再談最佳化」的順序非常重要,後面 Day 32 到 Day 34 的評估主題會反覆用到同一套思維。

原理/觀念

召回與精排:兩段式漏斗

檢索系統的第一個指標是召回率(recall):在所有真正相關的段落中,我們撈回了多少比例。第二個是精確度(precision):撈回來的段落裡,有多少比例真的相關。向量檢索通常可以靠「把 k 開大」來提高召回,代價是精確度下降、而且候選變多之後送入生成階段的雜訊也變多。這就形成一個天然的漏斗結構:前段用便宜的方式大量召回(例如 k=30),後段用昂貴的方式精挑細選(例如留下 k=5)。

這個結構之所以有效,是因為「判斷兩段文字像不像」比「判斷一段文字能不能回答問題」便宜得多,也容易用向量預先算好。嵌入模型(embedding model)可以離線把整個知識庫算成向量,查詢時只需一次編碼與一次近似最近鄰搜尋;但真正的相關性判斷往往需要把問題與候選「放在一起看」,這種交互(interaction)無法預先計算,只能對少數候選即時運算。所以漏斗的兩段並不是替代關係,而是分工。

原始查詢為什麼常常檢索不到

第一個原因是查詢與文本不對稱。使用者的查詢通常很短、口語、帶代名詞,甚至只有三個關鍵詞;而文本段落是完整的書面敘述。兩邊的用詞分布與句法結構差異很大,嵌入模型若沒針對這種非對稱檢索訓練過,向量就會落在不同區域。第二個原因是詞彙落差(vocabulary mismatch):你問「斷電保護」,文本寫的是「UPS 與備援電力」;語意上確實接近,但若模型對這個領域的詞彙覆蓋不足,距離就會被拉開。第三個原因是多面向問題。一句「這個方案的優點、缺點與成本大概如何」其實包含三個子問題,被壓縮到一段查詢文字裡之後,語意中心會模糊掉,檢索結果就會偏向「三者都沾一點」但「三者都不深」的段落。第四個原因是分塊切斷上下文,這在 AG Day 21 談過:一段話被切成兩塊,指涉主體留在前一塊,後一塊單獨看就失去意義。

這四個原因分別對應不同的解法:不對稱與詞彙落差適合用查詢改寫,多面向問題適合查詢拆解,分塊問題則要在分塊策略或上下文補償(例如把前後鄰塊一起送進 context)上處理。本篇聚焦前兩者。

查詢改寫的常見手法

「多查詢展開」(multi-query)是最直覺的一招:請模型把原始問題改寫成三到五種不同說法,各自去檢索,最後把結果合併。它的好處是不需要改動索引,缺點是每次查詢的模型呼叫與檢索次數都變多。

「HyDE」(Hypothetical Document Embeddings)是另一招,出自 Gao 等人 2022 年的論文:先請模型「假裝自己知道答案」,生出一段假設性的答案草稿,再用這段草稿去檢索。背後的理由是「答案與答案」的語意分布比「問題與答案」更接近,用假答案當查詢可以縮小不對稱落差。需要注意的是,假答案的內容可能是錯的,但這篇文章的重點不是內容正確性,而是把向量推向正確的語意區域——這件事在文獻中被反覆驗證有效,但如果你對生成內容很敏感,可以在流程結束後丟棄草稿、只留檢索結果。

「抽象化或退一步提問」(step-back prompting)則是把具體問題提升到更上位的概念再檢索,適合需要背景知識的題目。例如「某型號晶片的能耗表現」可以先退一步問「這一世代邊緣晶片的功耗設計取捨」,先撈到背景段,再回答細節。最後是「子問題拆解」:把多面向問題拆成獨立查詢,各自檢索後合併。四種手法的共同點是「用模型把使用者的說法,翻譯成對索引更友善的說法」,本質上是查詢側的提示工程。

用 RRF 融合多份排序

多路檢索之後,我們會拿到好幾份排序清單。問題是不同清單的分數不可直接比較:向量距離、關鍵詞分數、模型評分的量尺完全不一樣,硬把數字相加沒有意義。遞迴排名融合(Reciprocal Rank Fusion,RRF)繞過了這件事——它只看名次,不看分數。作法是對每份清單的第 r 名給 1 / (k + r) 分,再依段落把所有清單的分數加總排序。常數 k 是平滑項,文獻中常見的慣例值為 60,作用是壓低「第一名」的獨占性,讓同時出現在多份清單中段的段落也能浮上來。RRF 的優點是實作只有十來行、不需要調權重、對量尺差異免疫,是目前多路檢索融合的常見預設選擇。

精排:cross-encoder 與 LLM rerank

精排要把「查詢—候選」成對放進模型一起讀,這條路線有兩種實作。第一種是 cross-encoder:以 sentence-transformers 生態為例,載入一個 cross-encoder 模型,對每個「查詢 + 段落」配對輸出一組相關性分數,再依分數排序。它延遲低、可完全在本機 CPU 上跑,但需要準備一個合適的模型,且只給你分數、不給你理由。第二種是 LLM rerank:把候選段落編號後交給語言模型,請它輸出「哪些編號最相關、排序為何」。它的優點是零樣本、可解釋、能遵循額外的判斷準則(例如「優先官方來源、優先三年內資料」),缺點是延遲與成本都高,而且輸出格式需要用結構化輸出嚴格約束。

本質上的取捨是:cross-encoder 便宜、穩定、適合放進正式服務的請求路徑;LLM rerank 昂貴、彈性、適合離線批次或對可解釋性要求高的場景。兩者也可以疊用,但實務上很少值得——漏斗越長,維護成本越高,而邊際效益通常在第兩層就開始遞減。

怎麼知道變好了:檢索指標

沒有量測的最佳化都是猜測。檢索評估的第一個必需品是黃金集(golden set):一批問題,加上每個問題「真正相關的段落編號」。在本專案裡最自然的做法,就是記下 chunks.doc_id 或 Chroma 的段落 id。有了它,就能算三種常見指標。Recall@k:前 k 名裡命中了多少比例的正解,衡量召回能力。MRR(Mean Reciprocal Rank):第一個正解出現在第幾名,取其倒數並對所有問題平均,衡量「正確答案夠不夠前面」。nDCG@k(Normalized Discounted Cumulative Gain)則同時考慮多個正解與它們的排序位置,會給排在前面的正解較高權重。三者的實作都不難,重點是「同一組黃金集、同一段程式碼」去比較改動前後,否則數字沒有意義。

完整實作

接下來我們在 src/research_agent/retrieval.py 上疊加能力。以下每一步都可獨立執行;整份檔案最後會提供 python -m research_agent.retrieval --demo 的入口,讓你把 baseline 與改寫後+rerank 的結果並排比較。

第一步,先把 Chroma 連線與最基礎的向量檢索包成一個函式。這裡刻意只做一件事,方便後面當成 baseline 對照:

# research-agent/src/research_agent/retrieval.py
import os
from pathlib import Path
from typing import Any

import chromadb

CHROMA_DIR = Path("data/chroma")
COLLECTION_NAME = "chunks"


def get_collection() -> Any:
    """取得(必要時建立)分塊集合;embedding 由 Chroma 預設函式處理。"""
    client = chromadb.PersistentClient(path=str(CHROMA_DIR))
    return client.get_or_create_collection(
        name=COLLECTION_NAME,
        metadata={"hnsw:space": "cosine"},
    )


def vector_search(query: str, k: int = 5) -> list[dict[str, Any]]:
    """最單純的向量檢索:對查詢編碼後取前 k 名。"""
    collection = get_collection()
    result = collection.query(query_texts=[query], n_results=k)
    hits: list[dict[str, Any]] = []
    for doc_id, text, meta, dist in zip(
        result["ids"][0],
        result["documents"][0],
        result["metadatas"][0],
        result["distances"][0],
    ):
        hits.append({"id": doc_id, "text": text, "meta": meta, "score": 1.0 - dist})
    return hits

段落說明:query_texts 由 Chroma 的預設嵌入函式處理,所以在 AG Day 20 寫入時與現在查詢時用的必須是同一套嵌入邏輯,否則向量空間不一致、結果會毫無意義。回傳時我把距離換算成 1.0 - dist 的相似度分數,只是為了後續輸出可讀,不影響排序。這個 vector_search() 就是我們的 baseline。

第二步,定義模型呼叫的薄封裝。AG Day 3 建立了通用對話介面、AG Day 8 建立了結構化輸出契約,這裡沿用同一個專案內約定:吃 system/user 兩個字串、回傳 dict。若你前面幾篇的函式名稱不同,換掉這一個函式即可,其餘程式不需要動。沒有 API key 時走 --dry-run 的規則式替代:

DRY_RUN = os.environ.get("RESEARCH_AGENT_DRY_RUN") == "1"


def call_model_json(system: str, user: str, dry_run: bool | None = None) -> dict[str, Any]:
    """呼叫模型並取回 JSON;dry-run 時使用規則式替代,方便離線驗證流程。

    真實路徑的介面沿用 AG Day 8 的結構化輸出封裝;函式名稱不同時改這裡就好。
    """
    offline = DRY_RUN if dry_run is None else dry_run
    if offline:
        from .dryrun import mock_json_response

        return mock_json_response(system=system, user=user)

    from .llm import generate_json  # AG Day 3/Day 8 建立的共用封裝

    return generate_json(system=system, user=user)

段落說明:把「離線替代」集中在單一函式,是這個系列一路以來的紀律。任何需要 API key 的篇章,只要有一條規格確定的離線路徑,就能在 CI 或無網路環境驗證流程正確性。mock_json_response() 的內容是示範用規則,實際輸出會與真實模型不同,這一點必須在報告中標示。

第三步,實作多查詢展開。請模型把一句話變成三到五句不同說法的查詢,並要求固定的 JSON 結構:

REWRITE_SYSTEM = (
    "你是檢索查詢改寫器。把使用者的問題改寫成 3 到 5 個檢索用查詢。"
    "要求:保留原意、補上可能的同義詞或領域術語、不要加入問題沒有的具體數字。"
    '只回傳 JSON,格式為 {"queries": ["...", "..."]}。'
)


def rewrite_queries(question: str, dry_run: bool | None = None) -> list[str]:
    """多查詢展開:回傳包含原始問題在內的查詢清單。"""
    payload = call_model_json(REWRITE_SYSTEM, question, dry_run=dry_run)
    variants = [q.strip() for q in payload.get("queries", []) if q.strip()]
    # 去重但保留順序,原始問題永遠排第一
    seen: set[str] = set()
    ordered = [question]
    seen.add(question)
    for v in variants:
        if v not in seen:
            ordered.append(v)
            seen.add(v)
    return ordered[:5]

段落說明:把原始問題塞回清單第一位,是為了確保「改寫」是增益而不是取代。如果模型改寫得很糟,整體品質至少不會比 baseline 差。限制五個上限則是控制成本——每個查詢都是一次向量檢索,k 路並行雖然換來召回,但延遲與費用都會等比例上升。

第四步,實作 RRF 融合。這是本篇最值得背下來的一段小程式:

def rrf_fuse(rankings: list[list[str]], k: int = 60) -> list[tuple[str, float]]:
    """以名次為基礎融合多份排序清單;k 為平滑常數,慣例值 60。"""
    scores: dict[str, float] = {}
    for ranking in rankings:
        for rank, doc_id in enumerate(ranking, start=1):
            scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
    return sorted(scores.items(), key=lambda item: item[1], reverse=True)


def multi_query_search(question: str, per_query_k: int = 8, dry_run: bool | None = None) -> list[dict[str, Any]]:
    """多路檢索後用 RRF 融合,回傳重新排序後的候選。"""
    queries = rewrite_queries(question, dry_run=dry_run)
    rankings: list[list[str]] = []
    pool: dict[str, dict[str, Any]] = {}
    for q in queries:
        hits = vector_search(q, k=per_query_k)
        rankings.append([h["id"] for h in hits])
        for h in hits:
            pool.setdefault(h["id"], h)
    fused = rrf_fuse(rankings)
    return [{**pool[doc_id], "rrf": score} for doc_id, score in fused]

段落說明:pool 存放候選段落的全文與中介資料,rankings 只存放名次,兩者分開讓 RRF 保持單純。per_query_k=8 配上五路查詢,最多會有四十筆候選,這正是餵給精排的合理量級。注意 RRF 分數只在同一批融合內可比較,跨查詢、跨版本拿來當門檻值使用是錯誤的。

第五步,實作精排。為了同時示範兩種路線,我們讓精排函式可切換 reranker="llm" 或 reranker="cross-encoder";LLM 版用結構化輸出約束格式,cross-encoder 版走本機模型:

RERANK_SYSTEM = (
    "你是檢索精排器。根據問題,替候選段落評分並排序。"
    "只考慮『這段文字能否直接回答問題』,不要因為關鍵詞重疊就給高分。"
    '只回傳 JSON,格式為 {"ranking": [{"index": 1, "score": 0.9}, ...]}。'
)


def llm_rerank(question: str, candidates: list[dict[str, Any]], top_n: int = 5,
               dry_run: bool | None = None) -> list[dict[str, Any]]:
    """LLM 精排:把候選編號後交給模型排序。"""
    lines = [f"[{i}] {c['text'][:600]}" for i, c in enumerate(candidates, start=1)]
    user = f"問題:{question}\n\n候選段落:\n" + "\n\n".join(lines)
    payload = call_model_json(RERANK_SYSTEM, user, dry_run=dry_run)
    ranked: list[dict[str, Any]] = []
    for row in payload.get("ranking", []):
        idx = int(row["index"]) - 1
        if 0 <= idx < len(candidates):
            ranked.append({**candidates[idx], "rerank_score": float(row.get("score", 0.0))})
    # 模型漏掉的候選補在後面,避免檢索結果憑空消失
    ranked_ids = {c["id"] for c in ranked}
    ranked.extend(c for c in candidates if c["id"] not in ranked_ids)
    return ranked[:top_n]


def cross_encoder_rerank(question: str, candidates: list[dict[str, Any]],
                         model_name: str, top_n: int = 5) -> list[dict[str, Any]]:
    """本機 cross-encoder 精排;模型選擇以官方文件為準。"""
    from sentence_transformers import CrossEncoder

    model = CrossEncoder(model_name)
    pairs = [(question, c["text"]) for c in candidates]
    scores = model.predict(pairs)
    order = sorted(range(len(candidates)), key=lambda i: float(scores[i]), reverse=True)
    return [{**candidates[i], "rerank_score": float(scores[i])} for i in order][:top_n]

段落說明:LLM 版有兩個細節值得注意。第一,0 <= idx < len(candidates) 的邊界檢查不能省,模型給出超界編號是很常見的失敗模式。第二,模型漏答的候選要補回清單尾端,否則「精排」會變成「靜默丟資料」,讓召回率莫名其妙下降。cross-encoder 版本則是把「模型名稱」當參數傳入而不寫死在程式裡,選哪個模型請依官方文件與你的語言需求決定。

第六步,建立評估。黃金集放在 data/golden_retrieval.json,每筆記錄問題與正解段落 id;評估函式計算 Recall@k、MRR 與 nDCG@k:

import json
import math


def load_golden(path: str = "data/golden_retrieval.json") -> list[dict[str, Any]]:
    with open(path, encoding="utf-8") as fh:
        return json.load(fh)


def recall_at_k(retrieved: list[str], relevant: set[str], k: int) -> float:
    if not relevant:
        return 0.0
    hit = sum(1 for doc_id in retrieved[:k] if doc_id in relevant)
    return hit / len(relevant)


def reciprocal_rank(retrieved: list[str], relevant: set[str]) -> float:
    for rank, doc_id in enumerate(retrieved, start=1):
        if doc_id in relevant:
            return 1.0 / rank
    return 0.0


def ndcg_at_k(retrieved: list[str], relevant: set[str], k: int) -> float:
    dcg = sum(
        1.0 / math.log2(rank + 1)
        for rank, doc_id in enumerate(retrieved[:k], start=1)
        if doc_id in relevant
    )
    ideal = sum(1.0 / math.log2(rank + 1) for rank in range(1, min(k, len(relevant)) + 1))
    return dcg / ideal if ideal > 0 else 0.0

段落說明:三個函式都刻意寫成純函式(吃清單、回分數),方便在 pytest 裡用固定資料驗證。nDCG 的理想值(IDCG)以「正解全部排在最前面」為上限,因此正解數量少於 k 時不會被懲罰。要提醒的是,這些指標定義在不同文獻有細微差異,此處採用最常見的二元相關性版本。

第七步,把 baseline 與改進版放在一起比較,並接上命令列入口:

def evaluate(pipeline, golden: list[dict[str, Any]], k: int = 5) -> dict[str, float]:
    """pipeline 是可呼叫物件:吃問題、回傳候選清單。"""
    totals = {"recall": 0.0, "mrr": 0.0, "ndcg": 0.0}
    for item in golden:
        hits = pipeline(item["question"])
        ids = [h["id"] for h in hits]
        relevant = set(item["relevant_ids"])
        totals["recall"] += recall_at_k(ids, relevant, k)
        totals["mrr"] += reciprocal_rank(ids, relevant)
        totals["ndcg"] += ndcg_at_k(ids, relevant, k)
    n = max(len(golden), 1)
    return {name: value / n for name, value in totals.items()}


def retrieve(question: str, dry_run: bool | None = None, use_rerank: bool = True) -> list[dict[str, Any]]:
    """對外統一的檢索入口:多路召回 + 精排。"""
    candidates = multi_query_search(question, per_query_k=8, dry_run=dry_run)
    if not use_rerank:
        return candidates[:5]
    return llm_rerank(question, candidates, top_n=5, dry_run=dry_run)


if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser(description="檢索品質比較工具")
    parser.add_argument("--demo", action="store_true", help="比較 baseline 與改進版")
    parser.add_argument("--dry-run", action="store_true", help="離線模式,輸出為示範")
    args = parser.parse_args()

    if args.demo:
        golden = load_golden()
        base = evaluate(lambda q: vector_search(q, k=5), golden)
        improved = evaluate(lambda q: retrieve(q, dry_run=args.dry_run), golden)
        print("baseline :", {k: round(v, 3) for k, v in base.items()})
        print("改寫+精排:", {k: round(v, 3) for k, v in improved.items()})

段落說明:evaluate() 接受任何「問題到候選」的可呼叫物件,所以 baseline 與改進版可以用同一支評估程式比較,這是這整篇最重要的設計決定。執行方式與示範輸出如下(--dry-run 的數字由規則式 mock 產生,只是流程示範,不代表真實效果):

uv run python -m research_agent.retrieval --demo --dry-run
baseline : {'recall': 0.4, 'mrr': 0.32, 'ndcg': 0.36}
改寫+精排: {'recall': 0.6, 'mrr': 0.48, 'ndcg': 0.52}

段落說明:上面的數字是 示範輸出,由離線 mock 產生,不可以當成「查詢改寫能提升多少」的證據。真實數字取決於你的語料、嵌入模型與黃金集,請務必在自己的資料上跑一次,並固定黃金集版本,才有比較的意義。

常見錯誤與踩雷

第一個常見錯誤是對稱性假設。不少人在寫檢索時會拿「文本對文本的相似度」調參,覺得效果不錯就上線,卻沒發現真實查詢短得多、口語得多。除錯時請務必用真實的查詢樣本,而不是改寫過的整句話,否則你調的是一條不存在的分布。

第二個錯誤是改寫過度發散。多查詢展開若沒有約束,模型很容易把「這個方案的缺點」改寫成「這個方案的評估方法與替代方案比較」,語意飄走之後召回進來一堆無關段落,反而稀釋了精排的品質。控制方式有兩個:在 system prompt 明確要求「保留原意、不要加入問題沒有的具體數字」,以及限制改寫數量上限。

第三個錯誤是把 RRF 分數當成機率或門檻。RRF 分數是「名次倒數的加總」,最大值取決於有幾路清單、每份清單多長。同一批融合內可以比大小,但跨批次沒有絕對意義,寫成 if score > 0.5 這種門檻幾乎一定會在改參數後失效。

第四個錯誤是精排候選太少。有人直接把 baseline 的 top-5 送去精排,然後說「rerank 沒用」。精排的價值在於把「本來排在第 12 名但其實最相關」的段落救回來,候選只有五筆時它幾乎沒有發揮空間。合理的起始值是把召回開到 20 到 50 筆再精排。

第五個錯誤是評估集太小或有洩漏。只寫三個問題就宣稱改進,數字會晃動到沒有參考價值;更糟的是拿「已經調過參的問題」當黃金集,導致過度擬合。黃金集的建立方式與規模,我們會在 AG Day 33 專門談,今天先守住一個原則:測試用問題要跟開發時看到的不一樣。

第六個錯誤是快取沒清。若你在 AG Day 21 或更早加了檢索快取,改寫與精排上線後別忘了清掉舊快取,否則會出現「程式改了、結果沒變」的詭異現象。任何影響檢索內容的改動,都應該伴隨快取版本號的遞增。

效能與實務提醒

先談延遲預算。多查詢展開會把檢索次數乘上三到五倍,加上一次 LLM rerank 呼叫,整體檢索延遲很容易從數百毫秒變成數秒。實務上的分配方式是:改寫與多路檢索可以並行(查詢之間互不依賴),精排是序列瓶頸。若你的查詢改寫與檢索都在本機完成、只有精排需要模型,那把改寫改用規則或小型模型、把預算留給精排,通常是更好的選擇。本系列不給具體毫秒數字,因為那與硬體、網路與模型選擇高度相關,請以自己的壓測結果為準(AG Day 41 會做效能總檢)。

再談成本與降級。LLM rerank 最貴的部分是輸入 token 數——候選數乘以每段字數就是 prompt 長度。三個實用做法:一是限制每段送進精排的字數上限(例如 600 字,程式裡已示範);二是兩段式精排,先用便宜方法粗篩、再用模型細排;三是設計降級路徑,當模型呼叫失敗或超時,自動退回 RRF 排序結果而不是直接報錯。最後一點在正式服務裡尤其重要,因為檢索是整條研究流程的必經關卡,它一卡住後面全停。

第三個提醒是資料量與索引結構。候選數 k 開大時,向量檢索本身的成本也會上升。hnsw:space 與索引參數會影響召回與速度的取捨,細節依 Chroma 版本而異,請以官方文件為準。若語料成長到百萬段以上,請重新評估是否需要分散式向量庫,但在本系列的示範規模(數千到數萬段)裡,單機 Chroma 綽綽有餘。

最後是「要有觀測」。今天新增的每個階段都應該留下紀錄:改寫出幾個查詢、各路召回幾筆、融合後幾筆、精排後留下的 id 與分數。這些紀錄之後會寫進 SQLite 的 events 表,成為 AG Day 32 之後評估與除錯的原料。檢索品質之所以長期難以改善,多半不是演算法不夠好,而是沒有留下足以回答「為什麼這次查不到」的證據。

小結

今天我們把檢索從「單次向量查詢」升級成兩段式漏斗。整理成下表,方便你之後選用:

  • 向量 top-k(baseline):成本最低、無需模型、適合可接受的召回損失場景。缺點是對短查詢與詞彙落差敏感。
  • 多查詢展開+RRF:以較多檢索換召回,不需變更索引,實作十餘行。適合查詢短、使用者的說法不固定的產品。
  • HyDE:以假設答案當查詢,改善問題與答案的不對稱。適合文本為敘述性長文、查詢為簡短問句的知識庫,代價是多一次生成。
  • cross-encoder 精排:延遲低、可放進請求路徑,適合正式服務。
  • LLM 精排:可遵循額外準則、可解釋,適合離線批次或高價值查詢,成本最高。

另外請記得三個紀律:先有黃金集再談最佳化;同一支評估程式比較前後版本;所有示範輸出標示清楚。做到這三點,你才是在做工程而不是在做感覺。

結語

檢索品質提升之後,下一個問題立刻浮現:這些撈回來的段落,要怎麼變成「有出處的答案」?如果報告寫了一句結論,卻無法指出它來自哪一段文本、哪一個網址,那這份報告在實務上幾乎無法使用——讀者沒有辦法查核,你也沒有辦法在被質疑時自證。所以檢索層之後,必須緊接著處理引用與出處的機制。

明天,我們會進入「AG Day 23 引用與出處:答案要能追溯」,把段落編號、來源中介資料、引用標記與查核流程串成一條可追溯的鏈路,讓研究助理產出的每一句話都能回到原始文本。我們會沿用今天建立的候選結構,直接在上面長出引用能力。

延伸資源

  • Chroma 官方文件:https://docs.trychroma.com/。集合建立、query() 回傳結構與距離空間設定,以這份說明為準。
  • sentence-transformers 官方文件:https://sbert.net/。CrossEncoder 的載入與 predict() 用法,以及可用的預訓練模型清單。
  • Gao 等人,〈Precise Zero-Shot Dense Retrieval without Relevance Labels〉(2022),arXiv:2212.10496。HyDE 的原始論文。
  • Cormack、Clarke、Buettcher,〈Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods〉(SIGIR 2009)。RRF 的原始出處,含 k=60 的建議。
  • LangChain 檢索說明:https://python.langchain.com/。Embeddings、Retriever 與 rerank 相關元件的整合範例,可用來對照本篇的手刻版本。

留言

這個網誌中的熱門文章

Day 2 變數與資料型別

Day 2 變數與資料型別 引言 寫程式的過程中,變數與資料型別是處理資料的基礎。變數是存放資料的容器,資料型別則決定這筆資料有哪些特性、可以進行哪些操作。學會定義變數、認識各種資料型別,是學好 Python 的關鍵一步。 這篇文章會帶你了解 Python 中變數的觀念、如何定義變數,以及常見的資料型別,包括整數、浮點數、字串、布林值,還有串列、元組、字典與集合等容器型別。我們也會介紹變數的命名規則與撰寫風格建議,以及如何用 type() 檢查資料型別。 什麼是變數?如何在 Python 中定義變數 變數是在程式執行時用來存放資料的名稱。透過定義變數,我們可以給一筆資料一個名字,並在程式的其他地方用這個名字取用該筆資料。在 Python 中,變數不需要事先宣告型別,因為 Python 是動態型別語言,變數的型別由指定給它的值決定。 定義變數的基本語法 在 Python 中定義變數非常簡單,只要用賦值符號 = 把值指定給變數即可。例如: x = 5 # 定義變數 x,並把整數 5 賦值給它 name = "Alice" # 定義變數 name,並把字串 "Alice" 賦值給它 在這裡,x 是一個變數,被賦予整數 5;name 是另一個變數,被賦予字串 "Alice"。 變數的更新與覆寫 變數的值可以修改,也就是說,我們可以在程式的不同地方給同一個變數新的值。例如: x = 10 # x 最初被賦予 10 x = 15 # x 的值現在被更新為 15 這樣就能依照需求,在程式執行過程中靈活調整變數的值。 Python 的動態型別系統 Python 和某些靜態型別語言不同,定義變數時不需要宣告型別。賦值時,Python 會根據值自動判斷變數的型別。例如: x = 5 # x 是整數 x = 3.14 # x 變成浮點數 x = "Hi" # x 變成字串 同一個變數在程式執行過程中可以存放不同型別的值,這是 Python 的彈性之一。 常見資料型別 在 Python 中,資料型別決定我們可以對變數進行哪些操作...

Day 1 Python 簡介與環境設定

Day 1 Python 簡介與環境設定 引言 在現在的科技環境裡,程式設計已經是一項重要技能。無論你是對資料科學有興趣、想成為開發者,或是想踏入人工智慧(AI)領域,學會寫程式都能明顯提升你的競爭力。在眾多程式語言中,Python 因為語法簡單、功能強大、應用範圍廣泛,成為許多人進入程式世界的第一選擇。這篇文章會帶你認識 Python 的背景與優勢,並一步步教你在不同系統上安裝與設定 Python 開發環境,最後寫出第一支 Python 程式。 為什麼選擇 Python? Python 是一種高階程式語言,由 Guido van Rossum 在 1991 年發布。Python 的設計哲學強調程式碼的可讀性,並用縮排來定義程式區塊,這點和許多使用大括號的語言不同。簡潔的語法讓它成為初學者的理想選擇;就算是經驗豐富的開發者,也能用它完成複雜的專案。 Python 的優勢如下: 簡單易學 :Python 的語法清楚、結構簡潔,初學者很快就能上手。和其他語言相比,學習曲線相對平緩,不需要先弄懂一堆複雜觀念,就能開始寫程式。 應用範圍廣泛 :從資料科學、網頁開發、人工智慧、機器學習、自動化測試到網路爬蟲,Python 都有大量開源函式庫與工具支援,而且在這些領域都扮演關鍵角色。 豐富的函式庫與框架 :Python 的函式庫生態系非常龐大。做資料分析有 NumPy、Pandas;開發網站有 Django、Flask;做深度學習有 TensorFlow、PyTorch。各種需求幾乎都能找到對應的套件,讓開發更有效率。 跨平台支援 :Python 支援 Windows、macOS、Linux 等作業系統,程式通常不需要太多修改就能跨平台執行,讓開發與部署更有彈性。 活躍的社群 :Python 擁有龐大的開發者社群。學習或開發上遇到問題,幾乎都能在社群與論壇(例如 Stack Overflow)找到答案,對初學者來說是很強的後盾,也能減少卡關時的挫折感。 Python 的應用領域 Python 的流行與強大功能,讓許多領域都開始大量使用它。以下是幾個常見的應用方向: 資料科學 :隨著大數據與人工智慧興起,資料科學大量使用 Python。NumPy、Pandas 與 Matplotlib 等工具能處理和分析龐...

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門 隨著人工智慧(AI)與深度學習(Deep Learning)快速發展,越來越多人對這些技術產生興趣。不論你是想踏入 AI 領域的初學者,還是已經有程式基礎的開發者,學好 Python 與深度學習框架(例如 PyTorch),都能為你打開更多可能。 為什麼選擇 Python? Python 已經是資料科學與人工智慧領域的首選語言。它的語法簡潔、容易上手,而且擁有龐大的生態系與大量開源函式庫。無論是資料處理、資料視覺化,還是建立機器學習與深度學習模型,Python 都能勝任。對想進入 AI 或資料科學領域的人來說,它幾乎是必備工具。 PyTorch 是什麼? PyTorch 是由 Meta(原 Facebook)AI 研究團隊開發的開源深度學習框架,以易用、靈活和動態計算圖著稱,是許多 AI 研究人員與開發者的首選。相較於其他框架,PyTorch 的寫法更貼近原生 Python,對初學者相對友善。無論是簡單的實驗,還是複雜的深度學習模型,PyTorch 都能提供強大的支援。 這個系列能帶給你什麼? 這個系列會從 Python 的基礎開始,帶你一步一步學習,最後能自己用 PyTorch 建立深度學習模型。即使你完全沒有寫過程式,也能跟著文章的節奏累積技能,理解 AI 與深度學習的核心觀念。 本系列涵蓋的主題 Python 基礎:從變數、條件判斷到函式與模組。 資料處理工具:用 NumPy 與 Pandas 有效率地操作資料。 資料視覺化:用 Matplotlib 與 Seaborn 把資料畫成圖表。 深度學習的數學基礎:線性代數、微積分與機率。 PyTorch 入門:理解張量、模型建構與 GPU 加速。 基礎深度學習模型:CNN 與 RNN 的實作應用。 深度學習專案實戰:從資料前處理到模型部署的端到端流程。 誰適合這個系列? 程式初學者 :如果你對 AI 充滿好奇,卻還沒寫過程式,系列的第一部分會帶你快速上手 Python,並幫助你理解深度學習的基本觀念。 資料科學愛好者 :如果你已經熟悉一些資料處理方法,進階部分會教你如何用 PyTorch 建構深度學習模型。 開發者與研究人員 :想更深入了...