FDEAI 工程實戰課AI Engineering for FDEs
第 5 章Chapter 5

檢索與 RAGRetrieval and RAG

RAG 的品質上限由檢索決定:沒被找出來的文件,模型不可能引用。這一章談怎麼把檢索做對、怎麼在權限之內做,以及怎麼量測它到底做得好不好。

Retrieval sets the ceiling on RAG quality: a document that isn't found can't be cited. This chapter covers getting retrieval right, doing it within permissions, and measuring whether it actually works.

⏱ 35 分鐘min🧪 labs/core/retrieval.py

讀完這章你能夠After this chapter you can

  • 判斷什麼時候需要檢索,什麼時候把文件直接放進 context 就好。
  • 說明詞彙檢索與向量檢索各自會漏掉哪些問題,並用 RRF 融合兩者。
  • 在查詢階段就套用權限過濾,而不是查完再刪。
  • 用 recall@k 與 MRR 量測檢索品質,並和生成品質分開評估。
  • Decide when you need retrieval and when putting documents straight into context is enough.
  • Explain which queries lexical and vector search each miss, and fuse them with RRF.
  • Apply permission filters inside the query, not after it.
  • Measure retrieval with recall@k and MRR, separately from generation quality.

先問:需要檢索嗎?First: do you need retrieval?

現在的模型 context window 很大,如果資料只有幾十份文件、不常更新、所有使用者都能看,直接放進 prompt(搭配 prompt caching 降低成本)往往更簡單、品質也更好。以下情況才需要檢索:

Context windows are now large. If the data is a few dozen documents, rarely updated and visible to every user, putting it straight into the prompt (with prompt caching to cut cost) is often simpler and better. You need retrieval when:

  • 資料量超過 context 能放的範圍,或每次都放進去成本太高。
  • 權限:不同使用者能看的文件不同。
  • 新鮮度:資料持續更新,需要增量索引。
  • 可追溯:需要精確指出答案出自哪一段。
  • Volume exceeds what fits in context, or including it every time costs too much.
  • Permissions differ between users.
  • Freshness: data keeps changing and needs incremental indexing.
  • Traceability: you must point to the exact passage behind an answer.

整體流程The pipeline

切段Chunking

  • 依文件結構切(標題、條款、段落),不要只按固定字數切。條款被切成兩半,兩半都沒用。
  • 每段帶上下文:文件標題、章節標題。單獨拿出來看,要知道它在講什麼。lab 的 runbook 切段就是這樣做的。
  • 保留來源:文件 ID、段落編號、頁碼或位移,引用與除錯都需要。
  • 表格與 PDF 要特別處理。解析品質常常比 embedding 模型的選擇更影響結果。先人工檢查幾份解析後的文字。
  • Split by document structure (headings, clauses, paragraphs), not just fixed character counts. A clause split in half makes both halves useless.
  • Give each chunk context: document title and section heading. Read on its own, a chunk should make sense. The lab's runbook chunker does this.
  • Keep provenance: document ID, section number, page or offset. Citations and debugging need it.
  • Tables and PDFs need care. Parsing quality often matters more than the choice of embedding model. Inspect some parsed output by hand first.

詞彙檢索與向量檢索Lexical vs vector search

詞彙(BM25)Lexical (BM25)向量(embedding)Vector (embeddings)
擅長Good at 錯誤碼、料號、設定名稱、人名、罕見詞Error codes, part numbers, setting names, names, rare words 換句話說、同義詞、口語描述Paraphrases, synonyms, casual descriptions
會漏掉Misses 用詞不同但意思相同的查詢Queries that use different words for the same idea 精確代號(PAY-4102 和 PAY-4103 在向量上幾乎一樣)Exact identifiers (PAY-4102 and PAY-4103 look almost identical as vectors)
分數Scores 無上限,依語料而變Unbounded, corpus-dependent 通常 −1 到 1Typically −1 to 1

兩者的盲點剛好互補,所以實務上常同時跑兩種,再把結果融合。

Their blind spots complement each other, so in practice you often run both and fuse the results.

Reciprocal Rank FusionReciprocal Rank Fusion

兩種分數的尺度不同,直接相加沒有意義。RRF(Cormack 等人,2009)只看名次:

The two scores live on different scales, so adding them is meaningless. RRF (Cormack et al., 2009) uses only ranks:

RRF
RRF(d) = Σ  1 / (k + rank_r(d))      rank 從 1 開始 / ranks start at 1
         r

k 控制「前幾名比後幾名重要多少」。k 越小,第一名的優勢越大;k 越大,各名次的貢獻越平均。60 是原論文的經驗值,通常不用調。在兩份清單都出現的文件,分數會比只在一份清單拿第一名的文件高,這正是融合想要的效果。

k controls how much more top ranks count than lower ones. Smaller k gives first place a bigger edge; larger k flattens the contributions. 60 comes from the original paper and rarely needs tuning. A document appearing in both lists usually outscores one that tops only a single list, which is exactly the point of fusion.

動手試:混合檢索Try it: hybrid retrieval

demo

BM25 + 向量 + RRF,含權限過濾BM25 + vector + RRF, with permission filtering

與 Python lab 相同的語料與演算法(JS 版,結果一致)。⚠ 這裡的「向量」是字元 3-gram,能處理詞形變化(migrate/migration),不懂同義詞。試試最後一個查詢:它在 lab 的測試集裡是已知的失敗案例。

Same corpus and algorithms as the Python lab (JS port, identical results). ⚠ The "vector" side here is character 3-grams: it handles word forms (migrate/migration) but not synonyms. Try the last query: it is a known failure in the lab's test set.

使用者群組:User groups:
查詢 token(已去除停用詞):Query tokens (stop words removed):

BM25

向量(3-gram)Vector (3-gram)

RRF top 3

可以觀察到:PAY-4102 由 BM25 精準命中;「migrate a big postgres table」BM25 完全找不到(migrate ≠ migration、postgres ≠ postgresql),3-gram 找得到;勾選「忽略權限」再查 personal data,會看到無權限的 CLAUSE-002 出現在結果中。

Things to notice: BM25 nails PAY-4102; for "migrate a big postgres table" BM25 finds nothing (migrate ≠ migration, postgres ≠ postgresql) while 3-grams do; tick "ignore ACL" and search for personal data to see the restricted CLAUSE-002 appear.

權限:在查詢裡過濾Permissions: filter inside the query

常見的錯誤是先取前 k 筆,再把使用者無權看的刪掉。這有兩個問題:結果可能被刪到剩零筆;更糟的是,只要中間有任何一層(快取、log、重排序服務)看到未過濾的結果,就已經外洩了。權限條件要放在查詢本身的 WHERE 裡,而且要用發出請求的使用者身分,不是服務帳號的身分。

A common mistake is to take the top k and then drop what the user can't see. Two problems: the result can shrink to zero, and worse, any layer that sees the unfiltered list (a cache, a log, a reranking service) has already leaked it. Put the permission condition in the query's WHERE clause, using the requesting user's identity, not a service account's.

PostgreSQL + pgvector 範例PostgreSQL + pgvector example

如果客戶已經在用 PostgreSQL,混合檢索可以在同一個資料庫裡完成,權限規則和資料放在一起。注意內層子查詢先 ORDER BY ... LIMIT,才能用到 HNSW 索引。

If the customer already runs PostgreSQL, hybrid retrieval can live in the same database, with access rules next to the data. Note the inner subqueries do ORDER BY ... LIMIT first so the HNSW index can be used.

hybrid_search.sql
CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE chunks (
    id             bigserial PRIMARY KEY,
    doc_id         text   NOT NULL,
    title          text   NOT NULL,
    body           text   NOT NULL,
    allowed_groups text[] NOT NULL DEFAULT '{everyone}',
    embedding      vector(1024) NOT NULL,     -- 維度依 embedding 模型 / depends on the model
    tsv            tsvector GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || body)) STORED
);
CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops);   -- 預設 m=16, ef_construction=64
CREATE INDEX ON chunks USING gin (tsv);
CREATE INDEX ON chunks USING gin (allowed_groups);

-- $1 = query embedding, $2 = query text, $3 = requesting user's groups (text[])
WITH lexical AS (
    SELECT id, row_number() OVER (ORDER BY rank DESC) AS rnk
    FROM (
        SELECT id, ts_rank_cd(tsv, q) AS rank
        FROM chunks, websearch_to_tsquery('english', $2) AS q
        WHERE tsv @@ q AND allowed_groups && $3::text[]
        ORDER BY rank DESC
        LIMIT 30
    ) s
),
semantic AS (
    SELECT id, row_number() OVER (ORDER BY dist) AS rnk
    FROM (
        SELECT id, embedding <=> $1::vector AS dist
        FROM chunks
        WHERE allowed_groups && $3::text[]
        ORDER BY embedding <=> $1::vector
        LIMIT 30
    ) s
)
SELECT c.id, c.doc_id, c.title, c.body, sum(1.0 / (60 + r.rnk)) AS rrf
FROM (SELECT * FROM lexical UNION ALL SELECT * FROM semantic) r
JOIN chunks c USING (id)
GROUP BY c.id
ORDER BY rrf DESC
LIMIT 8;
過濾與近似索引Filters and approximate indexes

HNSW 先找近鄰再套用 WHERE,如果使用者能看的文件很少,可能回傳不到 30 筆。pgvector 0.8 以後可以開啟 iterative index scan(SET hnsw.iterative_scan = relaxed_order;)緩解;權限群組很少時,也可以考慮依群組建立部分索引。上線前要用「權限很少的使用者」實測。

HNSW finds neighbours first and applies WHERE afterwards, so for users who can see few documents you may get fewer than 30 rows. pgvector 0.8+ offers iterative index scans (SET hnsw.iterative_scan = relaxed_order;) to mitigate this; with few permission groups, partial indexes per group are another option. Test with a low-privilege user before launch.

中文內容Chinese text

PostgreSQL 內建的 to_tsvector 不會斷中文詞,lab 的 tokenizer 也只處理英數字。中文全文檢索需要斷詞擴充(例如 zhparser、pg_jieba)或以字元 n-gram 為基礎的索引(例如 pg_trgm、pg_bigm)。這是在台灣客戶專案中很常被忽略的一點,建議早期就用真實的中文查詢測試。

PostgreSQL's built-in to_tsvector doesn't segment Chinese, and the lab tokenizer only handles alphanumerics. Chinese full-text search needs a segmentation extension (e.g. zhparser, pg_jieba) or character n-gram indexes (e.g. pg_trgm, pg_bigm). This is often overlooked on projects with Chinese-language data; test with real Chinese queries early.

重排序Reranking

cross-encoder 重排序模型會同時讀查詢與候選段落,判斷相關性,通常比單純的向量相似度準,但每個候選都要算一次,會增加延遲與成本。做法是先用便宜的方法取 20 到 50 個候選,再重排序取前幾名。要不要加,用測試集量過再決定:如果 RRF 的 recall@5 已經很高,重排序的效益可能很小。

A cross-encoder reranker reads the query and each candidate together to judge relevance. It is usually more accurate than vector similarity alone, but it runs once per candidate, adding latency and cost. Take 20–50 candidates cheaply, then rerank to the top few. Decide whether to add it by measuring on your test set: if RRF's recall@5 is already high, the gain may be small.

量測檢索品質Measuring retrieval

把檢索和生成分開評估。答案錯了,你需要知道是「沒找到對的文件」還是「找到了但模型用錯」,兩者的修法完全不同。

Evaluate retrieval separately from generation. When an answer is wrong you need to know whether the right document wasn't found or was found and misused; the fixes are completely different.

  • recall@k:相關文件中,有多少出現在前 k 筆。RAG 最重要的指標,因為沒進前 k 筆的文件模型看不到。
  • MRR:第一個相關文件名次的倒數平均。衡量「對的東西排得夠不夠前面」。
  • 權限外洩數:權限測試案例中,回傳了無權限文件的次數。這個必須是 0。
  • recall@k: the share of relevant documents appearing in the top k. The key RAG metric, because the model never sees anything outside the top k.
  • MRR: mean reciprocal rank of the first relevant document; how high the right thing ranks.
  • Permission leaks: how often permission test cases return unauthorised documents. Must be zero.
labs/core/retrieval.py
def recall_at_k(retrieved: list[str], relevant: set[str], k: int) -> float:
    if not relevant:
        return 1.0
    return len(set(retrieved[:k]) & relevant) / 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

第 7 章會把這些指標放進一個可以在 CI 執行的評測流程。

Chapter 7 puts these metrics into an evaluation run you can execute in CI.

常見錯誤Common mistakes

  • 換了 embedding 模型卻沒重建索引。不同模型的向量不能混用,要整批重算。
  • 索引過期。文件更新或刪除後,舊段落仍被檢索到。建立增量更新與刪除的流程,並監控索引延遲。
  • 只用向量檢索。精確代號類的查詢會大量失敗,而這類查詢在企業環境非常多。
  • 只看最終答案評分。分不出是檢索還是生成出問題。
  • 相信 demo 的語料。真實文件有掃描檔、表格、重複版本、過時內容,先看資料再設計。
  • Changing the embedding model without re-indexing. Vectors from different models don't mix; recompute everything.
  • Stale indexes. Updated or deleted documents still get retrieved. Build incremental update and delete paths and monitor index lag.
  • Vector-only search. Exact-identifier queries fail en masse, and enterprises are full of them.
  • Scoring only final answers. You can't tell whether retrieval or generation broke.
  • Trusting a demo corpus. Real documents include scans, tables, duplicate versions and stale content; look at the data before designing.

小測驗Quiz

1. 使用者查「PAY-4102 是什麼意思」,純向量檢索回傳了 PAY-4103 的文件。最合適的改善是?1. A user asks "what does PAY-4102 mean" and vector-only search returns the PAY-4103 document. Best fix?
精確代號是向量檢索的已知盲點;換模型通常改善有限。prompt 救不了沒被檢索到的文件。
Exact identifiers are a known blind spot of vector search; a different model rarely fixes it. No prompt can rescue a document that was never retrieved.
2. 為什麼 RRF 用名次而不是把 BM25 分數和 cosine 相加?2. Why does RRF use ranks instead of adding BM25 and cosine scores?
BM25 沒有上限且依語料而變,cosine 大致在 −1 到 1。用名次就不需要正規化。
BM25 is unbounded and corpus-dependent while cosine sits roughly in −1..1. Ranks avoid normalisation entirely.
3. 權限過濾應該放在哪裡?3. Where should permission filtering happen?
prompt 不是安全邊界;事後過濾會讓結果變少,而且中間任何一層都可能已經看到未授權的內容。
A prompt is not a security boundary; post-filtering shrinks results, and any intermediate layer may already have seen unauthorised content.