ChatPDF RAG 優化(一):語意切塊與混合檢索 Semantic Chunking + Hybrid Retrieval

多數 RAG 教學的做法:把文件每 1000 字切一刀,丟進向量資料庫,cosine 相似度 top-k 撈回來。 但實務上 80% 的爛答案,不是 LLM 不夠強,而是「切錯地方」或「撈錯段落」。 chatPDF 這次的優化,核心就兩件事:讓切塊跟著「語意」走,讓檢索同時懂「語意」和「字面」。


一、為什麼切塊與檢索是 RAG 的命門

一個 RAG 系統的流程很單純:

   PDF ──▶ 切塊(Chunking) ──▶ 向量化 ──▶ 檢索(Retrieval) ──▶ LLM 生成
              ▲                              ▲
              │                              │
         決定「知識的最小單位」        決定「撈回哪些單位」

LLM 生成是最後一步,但它能講什麼,完全取決於前面撈回了什麼。而撈回什麼,又取決於當初怎麼切。所以這條鏈裡,切塊與檢索才是真正的瓶頸——它們決定了 LLM 能「看到」的內容。

chatPDF 原本的問題很典型:

  1. 切塊是寫死的:不管 RAGConfig 設定什麼,都用固定字數硬切。一個句子、一張表、一段論證,常常被攔腰切斷。
  2. 檢索只有 dense 一種:純向量相似度。遇到「精確關鍵字」(產品型號、縮寫、法條編號)時,語意向量反而抓不準。

PR #1 就是針對這兩點:Semantic ChunkingHybrid Retrieval


二、固定切塊的問題:把意思切碎了

固定切塊(fixed-size chunking)是這樣的:

原文:
"本季營收成長 18%。主因是雲端業務擴張。│ 另一方面,匯率造成
                              切在這  ▲
3% 的逆風。展望下季,管理層預期..."

問題在於:它在「字數到了」就切,完全不管那裡是不是一個語意邊界。結果常常:

  • 把一個完整論點切成兩半 → 兩個 chunk 都殘缺,檢索時誰都撈不全
  • 把兩個無關主題塞進同一塊 → 向量被「平均」掉,語意模糊
固定切塊的失敗模式
──────────────────────────────────
chunk A: "...雲端業務擴張。另一方面,匯率"   ← 結尾被砍
chunk B: "造成 3% 的逆風。展望下季..."       ← 開頭沒頭沒尾

我們要的是:在「主題轉換」的地方切,而不是在「字數到了」的地方切。


三、Semantic Chunking:用 embedding 偵測語意轉折

核心想法

如果把每個句子向量化,相鄰句子的向量距離就反映了它們語意上的接近程度:

  • 距離小 → 兩句在講同一件事 → 不該切
  • 距離突然變大 → 主題轉換了 → 這裡該切
   句子向量距離(相鄰句之間)
   距離
    │           ╭─ 突波!主題轉換 → 切點
    │          ╱│
    │  ╲      ╱ │
    │   ╲────╯  │   ╲___
    └───────────────────▶ 句子序
       同主題    切    新主題

那「距離多大才算突波」?用百分位數動態決定:預設取第 90 百分位數當門檻——也就是「最劇烈的那 10% 轉折」才切。這樣不同文件能自動適應自己的語意節奏,不需要人工調絕對門檻。

實作

 1class SemanticChunker(BaseChunker):
 2    """在 embedding 偵測到的語意轉折處切塊,並有字數上限保護。"""
 3
 4    def __init__(
 5        self,
 6        chunk_size: int = 800,
 7        chunk_overlap: int = 0,
 8        breakpoint_percentile: int = 90,   # 取最劇烈的 10% 轉折當切點
 9        embedder: BaseEmbedder | None = None,
10    ) -> None:
11        self.chunk_size = chunk_size
12        self.breakpoint_percentile = breakpoint_percentile
13        self._embedder = embedder
14
15    def split(self, text: str) -> list[str]:
16        # 1. 先切成句子
17        sentences = [s.strip() for s in _SENTENCE_RE.split(text) if s.strip()]
18        if len(sentences) <= 1:
19            return sentences
20
21        import numpy as np
22
23        # 2. 每個句子各自向量化
24        vectors = [np.asarray(v, dtype=float)
25                   for v in self._get_embedder().embed(sentences)]
26
27        # 3. 計算相鄰句子的 cosine 距離(1 - cosine 相似度)
28        distances = [1.0 - _cosine(vectors[i], vectors[i + 1])
29                     for i in range(len(vectors) - 1)]
30
31        # 4. 用百分位數決定「主題轉換」的門檻
32        threshold = float(np.percentile(distances, self.breakpoint_percentile))
33
34        # 5. 逐句累積,遇到突波或超過字數上限就切
35        chunks: list[str] = []
36        current = [sentences[0]]
37        for i in range(1, len(sentences)):
38            topic_shift = distances[i - 1] > threshold
39            too_big = len(" ".join(current)) + 1 + len(sentences[i]) > self.chunk_size
40            if topic_shift or too_big:
41                chunks.append(" ".join(current))
42                current = [sentences[i]]
43            else:
44                current.append(sentences[i])
45        chunks.append(" ".join(current))
46        return chunks

設計重點

  1. 百分位數而非絕對值:不同文件的語意密度差很多。技術手冊可能句句轉折,小說可能整段同調。用百分位數讓門檻隨文件自適應
  2. 字數上限當保險:too_big 這個條件確保即使一整段都不轉折,也不會切出一個超長 chunk 撐爆 context window。語意優先,但有上限兜底。
  3. 句子邊界為基礎:先用 regex 切句,再以句子為最小單位累積——保證不會把一個句子切兩半。

四、純向量檢索的盲點:它讀不懂「字面」

Semantic Chunking 解決了「切」,接著是「撈」。

純 dense(向量)檢索的原理是:把 query 和每個 chunk 都向量化,算 cosine 相似度,取最近的 top-k。它很擅長語意近似——你問「公司賺錢嗎」,它能撈到講「營收成長」「獲利率」的段落,即使沒出現「賺錢」二字。

但它有個致命盲點:精確字面匹配

   query: "GPT-4o 的 context window 多大?"

   dense 檢索:把整句變成一個語意向量,
              "GPT-4o" 這種專有名詞被稀釋成一般語意
              → 可能撈回一堆講「模型能力」的泛泛段落,
                卻錯過真正寫著 "GPT-4o ... 128k" 的那一行

對於型號、縮寫、代碼、法條編號、人名這類「字面就是一切」的查詢,你需要的是老派但精準的關鍵字檢索——這正是 BM25 的強項。


五、BM25:無依賴的關鍵字檢索

BM25(Okapi BM25)是資訊檢索界的經典演算法。直覺是:

  • 一個詞在某文件出現越多次 → 該文件越相關(但有飽和,出現 100 次不會比 10 次強 10 倍)
  • 一個詞越罕見(IDF 高)→ 它命中時越有鑑別力(「the」命中沒意義,「GPT-4o」命中很有意義)
  • 越短的文件命中同樣的詞 → 越相關(長度正規化)
 1class BM25:
 2    """無外部依賴的 BM25 關鍵字相關度評分器。"""
 3
 4    def __init__(self, corpus_tokens: list[list[str]], k1: float = 1.5, b: float = 0.75):
 5        self.k1 = k1   # 詞頻飽和參數
 6        self.b = b     # 文件長度正規化強度
 7        self.N = len(corpus_tokens)
 8        self.doc_len = [len(d) for d in corpus_tokens]
 9        self.avgdl = sum(self.doc_len) / self.N if self.N else 0.0
10
11        # 建立詞頻表(tf)與文件頻率表(df)
12        self.tf, self.df = [], {}
13        for doc in corpus_tokens:
14            freqs = {}
15            for term in doc:
16                freqs[term] = freqs.get(term, 0) + 1
17            self.tf.append(freqs)
18            for term in freqs:
19                self.df[term] = self.df.get(term, 0) + 1
20
21    def idf(self, term: str) -> float:
22        """逆文件頻率:越罕見的詞,分數越高。"""
23        n = self.df.get(term, 0)
24        return math.log(1 + (self.N - n + 0.5) / (n + 0.5))
25
26    def scores(self, query: str) -> list[float]:
27        q_terms = tokenize(query)
28        out = []
29        for i in range(self.N):
30            freqs, dl = self.tf[i], self.doc_len[i]
31            norm = self.k1 * (1 - self.b + self.b * dl / self.avgdl) if self.avgdl else self.k1
32            score = 0.0
33            for term in q_terms:
34                f = freqs.get(term, 0)
35                if f:
36                    # IDF 權重 × 飽和後的詞頻貢獻
37                    score += self.idf(term) * (f * (self.k1 + 1)) / (f + norm)
38            out.append(score)
39        return out

為什麼自己實作而不裝套件? BM25 的核心邏輯只有幾十行,且不需要任何外部依賴。自己寫一份,部署時少一個套件、少一個版本衝突風險,對一個輕量服務來說很划算。


六、Hybrid Retrieval:把語意與字面融合

有了 dense 和 sparse(BM25)兩套分數,接下來要融合。直接相加不行——兩者的分數量級完全不同(cosine 在 0~1,BM25 可能是 0~20)。所以先各自min-max 正規化到 [0,1],再用權重 alpha 加權混合。

   融合公式:
   fused = alpha × dense_norm + (1 - alpha) × sparse_norm

   alpha = 1.0  →  純語意(dense)
   alpha = 0.0  →  純字面(BM25)
   alpha = 0.5  →  各半(預設)
 1class HybridRetriever(BaseRetriever):
 2    """以 min-max 正規化後的加權混合,融合 dense 向量與 BM25。"""
 3
 4    def __init__(self, vs: VectorStore, alpha: float = 0.5):
 5        self._vs = vs
 6        self._alpha = alpha   # 1.0=純 dense,0.0=純 sparse
 7
 8    def search(self, query: str, top_k: int, doc_ids: list[str]) -> list[dict]:
 9        from .sparse import BM25, tokenize
10
11        corpus = self._vs.get_chunks(doc_ids)
12        if not corpus:
13            return []
14
15        # dense:全 chunk 的向量相似度
16        dense = self._vs.query(doc_ids, query, len(corpus))
17        dense_by_key = {_key(d["metadata"]): d["score"] for d in dense}
18
19        # sparse:全 chunk 的 BM25 分數
20        sparse = BM25([tokenize(c["text"]) for c in corpus]).scores(query)
21        sparse_by_key = {_key(corpus[i]["metadata"]): sparse[i]
22                         for i in range(len(corpus))}
23
24        # 兩套分數各自正規化到 [0,1]
25        dnorm = _minmax(dense_by_key)
26        snorm = _minmax(sparse_by_key)
27
28        # 加權融合
29        fused = [
30            {
31                "text": c["text"],
32                "metadata": c["metadata"],
33                "score": self._alpha * dnorm.get(_key(c["metadata"]), 0.0)
34                       + (1 - self._alpha) * snorm.get(_key(c["metadata"]), 0.0),
35            }
36            for c in corpus
37        ]
38        fused.sort(key=lambda x: x["score"], reverse=True)
39        return fused[:top_k]
40
41
42def _minmax(by_key: dict) -> dict:
43    """把分數正規化到 [0,1]。"""
44    if not by_key:
45        return {}
46    lo, hi = min(by_key.values()), max(by_key.values())
47    rng = hi - lo
48    if rng == 0:
49        return {k: 0.0 for k in by_key}
50    return {k: (v - lo) / rng for k, v in by_key.items()}

設計重點

  1. 為什麼要 min-max 正規化? dense 與 sparse 的分數量級天差地遠,不正規化的話,加權等於只看其中一個。正規化後兩者站在同一個 [0,1] 的起跑線,alpha 才真正控制得了權重。
  2. alpha 是可調參數:不同類型的查詢適合不同 alpha。FAQ 型語意查詢調高 alpha,規格/型號查詢調低 alpha。這次也把它做進前端 UI,使用者能在「Retrieval settings」面板即時調。

七、為什麼選 X 不選 Y

決策選的方案為什麼不選另一個
切塊策略Semantic(語意轉折)不選固定字數:固定切會把論點切碎、把無關主題混塊
切點門檻百分位數(自適應)不選絕對門檻:不同文件語意密度差太多,絕對值無法通用
切塊保險語意 + 字數上限不選純語意:整段不轉折時會切出撐爆 context 的超長塊
關鍵字檢索自寫 BM25不選裝套件:核心僅數十行,自寫省依賴、省版本風險
檢索方式Hybrid(dense+sparse)不選純 dense:精確型號/縮寫/代碼,語意向量會稀釋掉
分數融合min-max 正規化後加權不選直接相加:兩者量級不同,直接加等於忽略其一

Flip condition(什麼時候反過來選):

  • 若文件本身就是均勻短段落(如 FAQ 條目),固定切塊反而簡單可靠,不必上 Semantic。
  • 若查詢幾乎全是自然語言問句、沒有專有名詞,純 dense(alpha=1.0)就夠,BM25 是多餘成本。
  • 若語料極大(百萬級 chunk),每次查詢都重算全語料 BM25 會太慢——需要預建倒排索引(這正是 PR #2 加 LRU cache 要解的問題,見下篇)。

八、系統效應:改動前 vs 改動後

面向改動前改動後
切塊固定字數,忽略 RAGConfigSemantic 語意轉折 + 字數上限,可配置
檢索純 dense 向量Hybrid(dense + BM25),alpha 可調
精確關鍵字查詢常被語意向量稀釋撈不準BM25 補上字面精準命中
可調性寫死前端 UI 即時調 chunker / alpha / top_k
測試+16 semantic chunker + 18 hybrid retrieval 測試,128 測試全綠
評估指南新增 rag_tuning.mdrag_evaluation.md(Hit@k、MRR、nDCG)

九、小結

這篇的核心心法,可以濃縮成兩句:

  1. 切塊要跟著「意思」走,不要跟著「字數」走——用 embedding 偵測語意轉折,百分位數自適應門檻,字數上限兜底。
  2. 檢索要同時懂「語意」與「字面」——dense 抓近似、BM25 抓精準,正規化後用 alpha 融合,讓使用者按查詢類型調權重。

一句話總結:RAG 的品質不是靠更強的 LLM 堆出來的,而是靠「切對地方、撈對段落」省出來的。

下一篇(第二部分)會進入後端強化與進階 RAG:上傳安全驗證、資源邊界、多查詢擴展、檢索評分過濾、頁碼引用、LRU 快取——把這套 pipeline 從「能跑」變成「能上線」。


系列導覽

參考連結

Yen

Yen

Yen