Enterprise RAG Assistant:為什麼中文的詞彙檢索不能用 tsvector
PostgreSQL 的全文搜尋切不開中文,整句話只會變成一個 token。所以詞彙臂改用字元三連詞、用 RRF 跟向量檢索融合——然後我量了一下,發現贏得沒有想像中多。
一句中文,一個 token
混合檢索需要一條詞彙臂。最順手的選擇是 PostgreSQL 內建的 tsvector 加 ts_rank,幾行 SQL 就有。
問題是它切不開中文:
SELECT to_tsvector('simple', '國內出差住宿費核實報支上限為新臺幣二千八百元');
-- → '國內出差住宿費核實報支上限為新臺幣二千八百元':1 ← 一整句變成一個 token
SELECT to_tsvector('english', 'lodging expenses are reimbursed up to 2800 dollars');
-- → '2800':7 'dollar':8 'expens':2 'lodg':1 'reimburs':4 ← 正常運作
英文那行是全文搜尋該有的樣子:斷詞、去字尾、可以比對。中文那行則是把一個長度四十的字串當成一個詞——除非使用者一字不差地打出整句話,否則永遠不會命中。
這不是 PostgreSQL 的疏失,是「中文不用空白斷詞」這件事的必然結果。要修得先接一個中文斷詞器(zhparser、pg_jieba 之類),那代表資料庫要裝擴充、要維護詞庫,而且斷詞錯誤會直接變成檢索錯誤。
換成字元三連詞
pg_trgm 走完全不同的路:不管語言,把字串切成每三個字元一組,比相似度。「不管語言」在這裡就是全部的價值——它對中文跟英文一視同仁,因為它根本不知道什麼叫詞。
代價是它在中文上的行為非常接近「精確子字串比對」。我實測過:拿一個問題去比對真的含有答案的那個 chunk,word_similarity 落在 0.50 到 0.71;比對其他每一個 chunk,是剛好 0.0。
不是很小,是零。
稀疏帶來的兩個副作用
那種「零或有」的分布,直接產生兩個必須處理的問題。
第一,融合前必須先濾掉零分。 一堆分數都是 0 的列,彼此之間的排序是任意的。把這種任意順序餵進排名融合,等於注入純雜訊——它會很有自信地告訴你第一名是誰,而那個第一名只是碰巧被排在前面。
第二,預設門檻要調低。 pg_trgm 的 word_similarity_threshold 預設是 0.6,而我量到的正確命中裡有一個是 0.50——用預設值,那一題會被安靜地丟掉。所以 search_chunks_hybrid 用 SET LOCAL 把門檻降到 0.25,作用範圍鎖在那個交易裡,不影響資料庫的其他地方。
會踩到這個,是因為 0.6 是為英文的模糊比對調出來的。換一種語言,同一個常數的意義就變了。
兩條路怎麼合
向量臂給一個排名,詞彙臂給一個排名,要合成一個。
我用 Reciprocal Rank Fusion:score = Σ 1/(60 + rank),k = 60 沿用原論文。
為什麼不直接加權分數?因為餘弦距離跟三連詞相似度是兩個沒有關係的尺度,分布也不一樣。要相加就得先正規化,而任何一種正規化都是拍腦袋決定的,並且任何一邊換了模型或參數都得重調。RRF 只看名次,尺度問題根本不會出現。
這個選擇有一個要注意的後果:距離上限只能套在向量臂上。 一個純粹靠字面命中的 chunk,在嵌入空間裡可以離問題很遠——那很正常,它本來就不是靠語意找到的。如果拿距離去過濾融合後的結果,等於把詞彙臂的貢獻整批刪掉。
這條臂精準,而且脆
詞彙臂找得到嵌入會糊掉的東西:表單代號、金額、法條編號、專有名詞。它也完全找不到換句話說的東西——拿「特休天數」去比「特別休假」,分數是零。
而那正好是它該有的樣子。這兩條臂不是互相備援,是互補:一條負責字面,一條負責語意,各自在對方的盲區裡工作。
然後我量了一下
做了就要量。評測集是 20 份文件、36 個問題:其中 6 份含有答案,另外 14 份是刻意挑的干擾文件——同一個主題、共用詞彙,但答案不在裡面。問題分三種:換句話說的、共用字面的、以及靠一個數字或表單代號決勝負的(最後一種是稠密檢索的經典弱項)。
正確答案用「必須包含某個子字串」定義,而不是指定某個 chunk id,這樣改了切塊策略評測集也不會作廢。
| 模式 | recall@1 | recall@3 | recall@5 | MRR | ms/query |
|---|---|---|---|---|---|
| vector | 94.4% | 100% | 100% | 0.972 | 7 |
| hybrid | 97.2% | 100% | 100% | 0.986 | 9 |
誠實地讀這張表:贏很小,而且評測集已經飽和了。
bge-m3 是很強的多語言檢索模型,本來就處理得了精確識別碼——我直接拿 IR-001、千分之一、百分之四十 去試,光靠向量就落在第 1 到第 2 名。混合檢索實際做到的事情是:把其中一題從第 2 名推到第 1 名。整個語料庫只有 31 個 chunk,recall@3 跟 recall@5 兩種模式都已經頂到天花板,所以真正還帶訊號的只剩 recall@1 跟 MRR。
那為什麼還留著
留著,是因為它沒有比較差、成本大約 2 毫秒,而且它蓋住的是稠密檢索一個已知的失效模式。
不是因為評測證明了它厲害——它沒有,而且這份評測集目前也沒有能力證明任何一邊。要讓它真的能分辨,語料庫得再大一個數量級。那件事在 roadmap 上。
這才是 benchmark 現在的職責:當回歸守衛,不是當論據。一份會告訴你「這裡看不出差別」的評測,比一份總是能生出漂亮數字的評測有用得多。
原始碼
這是 Enterprise RAG Assistant 的一部分——FastAPI、PostgreSQL + pgvector、任何 OpenAI 相容端點,多租戶隔離做在 SQL 層而不是 handler 裡。這篇沒展開的其他決策(租戶為什麼只能來自金鑰、為什麼用 SHA-256 而不是 bcrypt、遷移為什麼要繞開連線池)都記在 docs/architecture-decisions.md。