◥ AI 互動教室 ‹ 生成式 AI 導論
下載 .py 單獨開啟實驗場 ↗ 留言回報
GENAI 進階補充 · A · 進階 RAG

進階 RAG:
父子檢索、混合搜尋與 Rerank

主線第 7 課的 RAG 是天真版:一句(段)一塊、只用向量、取前 3 名。 手冊一變大、問題一變刁,它就開始出包——而且出包的地方模型不會告訴你。 下面是同一個模型、同一份 90 句手冊的真實紀錄:選一題、撥三個開關, 看送進 prompt 的內容和模型的回答怎麼變。

問題
開關
實測紀錄(qwen3.5-2b,temperature=0、不開思考,2026-09);檢索用 jina-embed 向量+BM25+bge-reranker-v2-m3。 「拿鐵大師」手冊是本課虛構教材;綠色=標準答案句;關鍵事實是用字串比對自動檢查的,不是人工打分。

實驗場是真的 Python,在你的瀏覽器裡跑、不用安裝。首次載入約需 30–60 秒—— 正好夠你把上面的開關撥一輪、讀完第 1 節。

01 · 考卷

先出考卷,再談招式

一句話重點:進階 RAG 的每一招都有副作用——先準備一份有標準答案的問題集, 每撥一個開關就重考一次,用分數決定要不要留。

開場的開關板你撥過一輪就會發現:8 種組合裡沒有一種三題都拿滿。 父子檢索修好了除垢步驟,卻讓 E07 那題的模型說「手冊中沒有提到」; 混合搜尋把 E07 排到第一,卻把「摔到地上」那題的答案擠出前 3 名。 只看三題會被個案帶著走,所以這一課的實驗場配了一份考卷:41 題、每題標好答案在手冊哪幾句。

題型題數例子難在哪
步驟題8除垢的完整步驟是什麼?答案橫跨同一節好幾句,少一句就是錯的操作
單點題8單份濃縮要放幾公克咖啡粉?答案就一句——天真版也答得好,當對照組
型號/故障碼15螢幕顯示 E07 怎麼辦?E07、E17 差一個字就是另一回事
換句話說10摔到地上壞掉可以免費修嗎?使用者說「摔到地上」,手冊寫「摔落」

打分用三個檢索界的標準指標:第一名就對(hit@1)、recall@k(標準答案句有幾成進了前 k 名)、 MRR(第一個對的排第 r 名得 1/r 分,再平均)。天真版的成績其實不差—— 第一名就對 35/41、MRR 0.92。但換個問法就露餡:取前 3 名時, 8 題步驟題只有 1 題的步驟全部進了 prompt。「排對第一名」跟「答案完整」是兩回事。

02 · PARENT-CHILD

父子檢索:用小塊找、用大塊給

一句話重點:用小塊(一句)去比對,找到之後把它所在的大塊(整節)交給模型—— 小塊找得準、大塊講得完整,兩個都要。又叫 small-to-big retrieval。

切塊是 RAG 的第一個兩難。切小,向量只講一件事、對上了就很「尖」,但步驟題只撈到零碎幾句; 切大,步驟湊齊了,可是一塊裡混著好幾個主題,向量變成它們的平均,找單點答案反而失準,prompt 還一路變胖。 開場第 ① 題就是切小的代價——天真版撈到的 3 句都在除垢那一節,偏偏漏了第一步,模型的回答是:

1. 準備:除垢液流完後,先沖洗水箱並裝滿清水。 2. 啟動:按一次沖煮鍵,讓機器跑完清水循環。 …

整個流程沒叫你加除垢劑。語氣篤定、格式漂亮,照做卻等於白做。實驗場 2️⃣ 把考卷上的數字攤開(取前 3 名):

切法步驟題:步驟全進 promptprompt 平均長度
一句一塊(天真版)1/894 字
固定 300 字一塊7/8859 字
固定 600 字一塊7/81,734 字(全手冊 2,890 字的六成)
父子檢索8/8327 字

實測:jina-embed 向量、41 題考卷,2026-09。固定大小=整句依序裝箱、裝滿就換下一塊(常見的預設切法)。

做法樸素得驚人:子塊記住自己屬於哪一節,檢索照常用子塊,最後一步換成父塊、去重。 LangChain 的 ParentDocumentRetriever(1.x 版起移到 langchain-classic 套件)、 LlamaIndex 的 AutoMergingRetriever 都是這個概念。用 sentence-transformers 手寫只要十來行(參考程式,不在課內執行):

import torch from sentence_transformers import SentenceTransformer model = SentenceTransformer("jinaai/jina-embeddings-v5-text-small-retrieval", trust_remote_code=True, model_kwargs={"dtype": torch.float32}) # CPU 上用 fp32:預設 bf16 實測慢 5 倍多 children, parent_of = [], [] # 子塊=一句;記住它屬於哪一節 for sec_id, (title, sentences) in enumerate(sections): for s in sentences: children.append(s) parent_of.append(sec_id) child_vecs = model.encode(children, prompt_name="document", normalize_embeddings=True) def small_to_big(question, k=3): q = model.encode([question], prompt_name="query", normalize_embeddings=True)[0] top = (child_vecs @ q).argsort()[::-1][:k] # 用子塊找 parents = list(dict.fromkeys(parent_of[i] for i in top)) # 換成父塊:去重、保留名次 return [section_text[p] for p in parents] # 用父塊給

但它不是萬靈丹。開場第 ② 題只開父子檢索,模型拿到的是三整節、十幾個長得很像的故障碼, 2B 小模型直接回「手冊中沒有提到。」——對單點題來說,展開的整節就是雜訊。 這也是考卷存在的意義:它會告訴你這招對哪一型題目划算。

03 · HYBRID SEARCH

混合搜尋:向量懂意思,BM25 認字

一句話重點:向量檢索懂「意思」、BM25 認「字」;兩張排名用 RRF 按名次融合—— 型號、錯誤碼、料號這種「差一個字就不同」的查詢,靠 BM25 撐住。

問「螢幕顯示 E07 怎麼辦?」,向量檢索的前 3 名是 E17(cosine 0.656)、E11(0.528)、 最後才是 E07(0.526)。在向量眼裡,E07 和 E17 幾乎是同一個東西。 這次模型還從第 3 句把答案撿了回來;可是只取第 1 名時,模型拿到的是 E17 那句,實測回答 「手冊中沒有提到。(註:您提供的段落僅針對 E17 錯誤…)」——同樣設定換成混合搜尋,第 1 名就是 E07,答對。

BM25 是搜尋引擎用了幾十年的關鍵字排序:一個詞在這句出現越多、在全手冊越稀有,分數越高。 e07 只出現在一句裡,所以一命中就排第一。中文沒有空格,常見做法是切字元 bigram (「除垢燈」→「除垢」「垢燈」,Lucene 的 CJK 分析器就是這樣切),英數代碼整個當一個詞。 兩張排名用 RRF(Reciprocal Rank Fusion)合併:每張清單的第 r 名得 1/(60+r) 分,加總再排—— 只看名次、不看分數,因為 cosine 落在 0–1、BM25 可以到十幾,直接相加會被 BM25 綁架。

import re import bm25s def tokenize(text): # 英數整詞 + 中文字元 bigram toks = [] for m in re.findall(r"[a-z0-9]+(?:[-.][a-z0-9]+)*|[一-鿿]+", text.lower()): if "一" <= m[0] <= "鿿" and len(m) > 1: toks += [m[i:i + 2] for i in range(len(m) - 1)] else: toks.append(m) return toks bm25 = bm25s.BM25(method="lucene") bm25.index([tokenize(c) for c in children]) scores = bm25.get_scores(tokenize(question)) bm25_rank = [i for i in scores.argsort()[::-1] if scores[i] > 0] # 沒命中任何詞的不算 def rrf(*ranked_lists, k=60): # Reciprocal Rank Fusion score = {} for lst in ranked_lists: for rank, doc in enumerate(lst, 1): score[doc] = score.get(doc, 0) + 1 / (k + rank) return sorted(score, key=score.get, reverse=True) hybrid_rank = rrf(dense_rank, bm25_rank)

考卷的結果很誠實(實驗場 3️⃣ 可以自己拉權重):型號題從 13/15 變成 15/15, 但換句話說題從 9/10 掉到 5/10——「摔到地上」跟手冊的「摔落」bigram 一個都對不上, BM25 只撈得到共用「可以」這種虛詞的句子,還把向量原本排對的答案往下拖。 全部 41 題:純向量 35、純 BM25 27、混合 33。把 BM25 的權重降到 0.25,型號題保住 15/15、總分回到 35—— 在這份手冊上,單靠混合搜尋並沒有比純向量好,只是把錯誤從一型搬到另一型。

那為什麼業界還是標配混合?因為它真正的角色是第一階段「撈得全」,排序交給下一節的 reranker。 Anthropic 在 2024-09 的 Contextual Retrieval 報告裡量過:在他們的評測語料上,(加了上下文的)向量+BM25 讓前 20 名的漏撈率降了 49%、再加 rerank 降了 67%。你的語料會落在哪一邊,只有你的考卷知道。 Qdrant、Elasticsearch 都內建混合搜尋與 RRF——想在真的向量庫上做,接本站的 Qdrant 課。

04 · RERANK

Rerank:讓 cross-encoder 把前 N 名重排

一句話重點:第一階段(向量/混合)快速撈前 N 名,cross-encoder 把「問題+每一句」一起讀、 重新排序——慢但準,所以只對少數候選做。

到目前為止的向量檢索都是 bi-encoder:問題和句子各自變成向量再比,句子向量可以事先算好,查詢只算一次問題——快, 代價是兩邊從頭到尾沒「見過面」。cross-encoder 把問題和一句話拼在一起丟進模型,直接輸出相關度, 能看出「問的是 WF-21、這句講的是 WF-12」這種細節。

bi-encoder(向量檢索)cross-encoder(reranker)
怎麼讀問題、文件各自編碼成向量問題+文件一起讀
能不能事先算能——文件向量存進向量庫不能——每個問題都要重跑每一對
速度百萬筆也是毫秒級本機 CPU 實測約 45 ms 一對
用在哪第一階段:從全部文件撈前 N 名第二階段:只重排這 N 個

考卷結果(bge-reranker-v2-m3,實驗場 4️⃣):向量 → rerank 前 20 名,第一名就對 35 → 37, E07、WF-21 兩題型號題都救回來了;但「出國兩個月不在家」原本排第一,被 reranker 排到第 5——它也會犯錯。 有趣的是 N 不是越大越好:只重排前 3 名反而拿到 38/41(MRR 0.955), 候選越多,reranker 越有機會把干擾句排上來。還有一條硬限制:它只能重排撈到的 N 個, 答案不在前 N 名,它無能為力——這正是第一階段要混合搜尋「撈得全」的理由。

from sentence_transformers import CrossEncoder reranker = CrossEncoder("BAAI/bge-reranker-v2-m3") # 約 5.7 億參數,CPU 跑得動 cands = hybrid_rank[:20] # 第一階段的前 N 名 scores = reranker.predict([(question, children[i]) for i in cands]) reranked = [cands[j] for j in scores.argsort()[::-1]]

時間成本(本機 CPU、14 執行緒實測):一對約 45 ms,20 個候選約 0.9 秒/每次查詢;有 GPU 會快一兩個數量級。 reranker 還有別的選擇,例如 jina-reranker-v3、Qwen3-Reranker——一樣先用考卷量過再換。

05 · 組起來

三招疊起來,再認識幾個兄弟

一句話重點:檢索撈候選 → rerank 排序 → 父子展開,用同一份考卷量每一個開關; 分數沒變好的招式就別留。

實驗場 5️⃣ 讓你把所有開關組起來、跟天真版並排比。spike 對 2,050 種組合做了格點搜尋, 這份手冊上最好的一組長這樣(怎麼組?那是 LEVEL 3 的挑戰):

管線第一名就對步驟題全進其他題全進prompt 長度
天真版:向量、一句一塊、k=335/411/831/3394 字
格點搜尋的最佳組合(LEVEL 3 的答案)38/418/832/33237 字

多花約 140 字,換來每一題的步驟都完整、第一名多對 3 題。先透露一點:最佳組合裡沒有 BM25—— 在這份 90 句的手冊上,向量的前 20 名已經撈到 98% 的標準答案句,BM25 沒東西可以救; 換成幾萬句、型號滿天飛的真實文件,結論可能反過來。招式是固定的,最佳組合是量出來的。

還有幾招常跟這三招一起出現,知道有就好:

手法做什麼本課實測
子塊加章節標題算向量前把「除垢|」這種章節名貼在每句前面,讓「最後裝回濾水芯」知道自己在講除垢 有測(5️⃣ 的開關):第一名就對 35 → 36,步驟題 5 → 7,但型號題 13 → 12——有得有失
Contextual RetrievalAnthropic 2024-09 提出:請 LLM 替每一塊寫 50–100 token 的上下文再貼上去,向量與 BM25 都用 沒測(每塊要呼叫一次 LLM);上面那招是它最省的簡化版
Query 改寫/Multi-query讓 LLM 把問題改寫成幾種說法分別檢索,再用 RRF 合併沒測
HyDE讓 LLM 先「假裝回答」,拿假答案的向量去找真段落——假答案的用詞更像手冊沒測
Query/Document 前綴jina v5、e5、bge 這類模型要求問題與文件加不同前綴(sentence-transformers 的 prompt_name) 有測:忘了加,第一名就對 35 → 30、換句話說 9 → 6——最便宜的一招,最常被漏掉
06 · 帶回家

自己跑一遍:免費 CPU 就夠

本課每個數字都來自同一支腳本: spike_genai_rag_advanced.py。 它的 --local 模式不需要任何服務:在 CPU 上載入跟本課一樣的開源模型—— 向量用 jina-embeddings-v5-text-small-retrieval(CC BY-NC 4.0,非商用免費,商用要找 Jina 授權)、 rerank 用 bge-reranker-v2-m3(Apache-2.0)——把切塊、BM25、融合、rerank、考卷全部重跑一次。 Colab 或 Kaggle 的免費 CPU notebook 裡三格就好:

!pip install -q bm25s==0.3.11 sentence-transformers==6.1.0 !wget -q https://raw.githubusercontent.com/skmygo/agentclass/main/content/genai-intro/_spikes/spike_genai_rag_advanced.py !python spike_genai_rag_advanced.py --local --part embed,bm25,rerank,eval,grid --out out

驗證範圍(2026-09):這三步用同樣的套件、在本機 CPU 限 2 執行緒(模擬免費機器)實跑過—— 不含第一次下載模型(約 3.5 GB)全程約 7.5 分鐘、記憶體峰值 4.7 GB,考卷分數跟本課完全一致 (35/41、33/41、38/41、最佳組合 237 字)。Colab/Kaggle 本身沒有實跑。 開場的 LLM 回答需要一個 OpenAI 相容的端點(設 LLM_URL,例如本機 Ollama), 那段只用 qwen3.5-2b 驗證過,換模型回答會不同。

07 · 速查

本課名詞速查卡

父子檢索 Parent-child/small-to-big:用小塊(一句)找、把它所在的大塊(整節)交給模型;記得去重。
BM25 關鍵字排序:詞在這篇出現越多、在全部文件越稀有,分數越高;認字不認意思,型號/錯誤碼的救星。
混合搜尋+RRF 向量+BM25 兩張排名,用 Reciprocal Rank Fusion 按名次融合(第 r 名得 1/(k+r),k 常用 60)。
bi-encoder 問題、文件各自編碼成向量再比——可以事先算、快,是第一階段檢索的主力。
cross-encoder/rerank 問題+文件一起讀、直接打相關度——慢但準,只重排第一階段的前 N 名。
hit@1/recall@k/MRR 第一名就對嗎/答案有幾成進了前 k 名/第一個對的排第 r 名得 1/r 分——考卷的三把尺。
08 · 實戰

換你動手

LEVEL 1

在實驗場 2️⃣ 把 top-k 拉到 1:「其他題:答案全進 prompt」哪一種切法最高?固定 300 字掉到多少?為什麼切大塊反而讓單點的答案找不準?

LEVEL 2

在 3️⃣ 把 BM25 權重從 1 往下拉,找出「型號題還保得住 15/15」的最低權重。這時 41 題第一名就對幾題?跟純向量比贏了嗎?

LEVEL 3

在 5️⃣ 組一條管線:第一名就對 ≥ 38/41、步驟題 8/8 全進、其他題 ≥ 31/33,而且 prompt 平均字數越少越好。你能壓到幾個字?

三題在實驗場最後一格都有折疊解答——先自己拉,再打開對照。

09 · 驗收

情境測驗

離開前試試看:下面的情境都真的會遇到。每題選一個你認為的最佳做法,選了馬上看得到解釋。

Q1 情境題

你的客服 RAG 一句一塊、取前 3 名。使用者抱怨:問「怎麼更換濾芯」「怎麼除垢」時,回答常常少一兩個步驟。最該先做的是?

步驟題的問題是「答案散在同一節好幾句」,父子檢索正好對症:比對用小塊(找得準),交出去用整節(步驟齊)。本課考卷上它把步驟全進的比例從 1/8 拉到 8/8,prompt 平均只要 327 字。A 也能湊齊步驟(7/8),但 prompt 膨脹到 1,734 字、單點題準度下降——大塊的向量被多個主題稀釋。B 會塞進一堆不相關的句子,而且 top-10 仍可能漏掉同一節的某一步。D 最危險:手冊沒給的步驟,更大的模型只會「補」得更像真的——開場第 ① 題的回答就是自信地漏掉加除垢劑那一步。

Q2 錯誤診斷

同事實作了父子檢索,印出送給模型的 prompt 開頭(實測輸出)如下,prompt 長度 573 字。最可能的問題是?

手冊段落: 【段落1】【除垢】有裝濾水芯時,除垢週期可以從 3 個… 【段落2】【除垢】有裝濾水芯時,除垢週期可以從 3 個… 【段落3】【除垢】有裝濾水芯時,除垢週期可以從 3 個…

這段是實測輸出:問「除垢的完整步驟」時,向量檢索的前 3 名是除垢那一節的三句不同的話(所以 A 不對,子塊層級是正常的),但每一句都被換成同一個父塊。去重之後只剩一段、191 字——現在是 573 字,三倍的 token 費用、零新資訊,還把其他可能有用的節擠掉了。修法是換成父塊時按名次去重(例如 list(dict.fromkeys(...))),LangChain 的 ParentDocumentRetriever 也是這樣做的。C 是因噎廢食;D 是常見誤解——重複內容不會讓模型更準,只會更貴、更擠。

Q3 情境題

維修 App 的使用者常只打故障碼,例如「E07」。你發現向量檢索常把 E17、E11 排在 E07 前面。下列做法哪個最好?

代碼是「認字」的問題,BM25 天生擅長:本課考卷上它讓型號題從 13/15 變 15/15。但只加 BM25 會把換句話說題從 9/10 拖到 5/10,所以要配 reranker(向量或混合 → rerank 後型號題 15/15、換句話說 8/10),並且用同時含兩型題目的考卷把關。B 不保證有用——向量本來就是在比「意思」,E07 與 E17 的意思本來就很像。C 把換句話說題整個丟掉(純 BM25 在考卷上只有 3/10)。D 救不了檢索:E07 那句根本沒進 prompt 時,提醒模型也生不出來。

Q4 錯誤診斷

同事這樣做混合搜尋:hybrid = cos_scores + bm25_scores。上線後發現「混合」的排名跟純 BM25 幾乎一樣,換句話說題大量答錯。下面是實測輸出,根本原因是?

cosine range 0.00–0.89;BM25 range 0.00–16.95 BM25 有命中的 40 題裡:直接相加的第一名與純 BM25 相同 36/40 第一名就對:向量 35/41 → 直接相加 30/41(RRF 33/41)

cosine 最多 0.89、BM25 可以到 17,相加等於「BM25 排序、cosine 只當小數點後的 tie-breaker」——實測 40 題裡 36 題的第一名跟純 BM25 一模一樣,總分從向量的 35 掉到 30,比兩者都差。RRF 只用名次,天生不怕尺度不同;要用分數就得先各自正規化(例如 min-max)再加權。A 調 BM25 參數改不了尺度差;B 看錯方向,向量本身是 35/41 的最好單項;C 兩邊同乘 100,比例完全不變。

Q5 情境題

公司知識庫有 5,000 段。同事說:「cross-encoder 最準,那就每次查詢都把 5,000 段全部 rerank 一遍。」在 CPU 上(實測約 45 ms 一對),你的建議是?

5,000 × 45 ms ≈ 225 秒——每問一題要等快四分鐘,A 不可行。兩段式是標準做法:bi-encoder/BM25 快速縮小範圍,cross-encoder 只精排少數候選(本課 20 個候選約 0.9 秒)。而且 N 大不一定好:本課考卷上只重排前 3 名拿到 38/41,前 20 名反而 37/41。D 做不到——cross-encoder 的分數取決於「問題+文件」這一對,問題事先不知道就沒得算,這正是它跟 bi-encoder 的根本差別。C 把排序責任丟給 LLM,prompt 變長變貴,干擾句也更多。

Python 環境載入中(首次約 30–60 秒)…讀完第 1 節它就好了