:從全量拼接巨坑到混合壓縮管線)
做RAG問答系統(tǒng)那段時間我栽過一個和Contextual Compression上下文壓縮有關(guān)的跟頭。最初我天真地以為只要檢索出來的文檔足夠多、足夠全把Top-K文檔全部拼進prompt里大模型就能給出高質(zhì)量回答。結(jié)果實測下來文檔拼得越多回答質(zhì)量反而越差有一輪問答甚至因為token超限直接報錯。后來我才搞清楚一個關(guān)鍵道理檢索只是把信息搬過來真正決定答案質(zhì)量的是“搬過來的東西怎么用”。而Contextual Compression解決的就是“怎么用”的問題——在把檢索結(jié)果喂給生成模型之前先做一輪基于當(dāng)前問題的針對性壓縮只留下真正有價值的證據(jù)。這篇文章我不會講太虛的概念而是把我實際踩過的坑、驗證過的路線、最后沉淀下來的可復(fù)現(xiàn)管線都寫出來。1. 檢索結(jié)果不能全塞進上下文一份來自實測的失敗記錄1.1 我的原始做法與它為什么翻車第一版系統(tǒng)非常樸素向量檢索召回Top-K個文檔塊我圖省事直接把所有塊按相關(guān)性分?jǐn)?shù)從高到低拼成一個超長上下文一口氣塞給生成模型。一開始我以為“信息越全答案越準(zhǔn)”但很快就連續(xù)吃了幾個教訓(xùn)。第一個教訓(xùn)是token超限。文檔知識庫里的內(nèi)容長短不一我設(shè)的Top-K是12每個塊平均250到400個token拼起來動輒4000 token起步。遇到長文檔切分比較碎的5000、6000 token也是常事。對上下文窗口有限制的模型來說這已經(jīng)逼近甚至超過上限。為了兼容我的配置我一度被迫反復(fù)調(diào)低K值結(jié)果檢索召回不足很多正確答案在源頭就被丟掉了。第二個教訓(xùn)更隱蔽噪聲直接干擾判斷。我把一堆相關(guān)性只有0.3、0.4的文檔塊也拼了進去結(jié)果模型生成答案時明顯被那些弱相關(guān)的段落帶偏。最典型的一次用戶問“某功能的異?;謴?fù)機制”系統(tǒng)拼進去了一段講同模塊歷史版本迭代的內(nèi)容生成模型硬生生把“異?;謴?fù)”回答成了“歷史版本遷移”錯得離譜。這不是模型能力問題是我把未經(jīng)篩選的垃圾也當(dāng)成了證據(jù)。第三個教訓(xùn)是成本。上下文越長每次調(diào)用的token費用越高而且這個開銷是線性增長的。如果你的系統(tǒng)要面對高頻調(diào)用哪怕每次多出2000個token累積起來也是一筆不小的賬單。1.2 不要混淆壓縮與截斷很多人的第一反應(yīng)是既然塞不下那就截斷唄。這是我在組里反復(fù)糾正過的誤區(qū)。截斷是從頭部或尾部硬切完全不考慮被切掉的內(nèi)容里有沒有答案壓縮則是以用戶問題為參照把文檔里“與問題無關(guān)”的部分拿掉把“與問題相關(guān)”的部分保留下來。用買菜來類比截斷相當(dāng)于“不管今天要做什么菜進了菜市場只逛前三家攤位剩下的攤位一律不看”壓縮相當(dāng)于“手里拿著購物清單在菜市場里只挑清單上需要的食材”。前者省事但經(jīng)常買不到真正需要的東西后者要多做一步篩選但買回來的基本都是能用的。這也是Contextual Compression名字里的Contextual上下文相關(guān)最關(guān)鍵的地方——壓縮不是做通用摘要而是“針對當(dāng)前這個問題”來壓縮。同一個文檔塊面對不同問題時被保留的內(nèi)容應(yīng)該完全不同。2. Contextual Compression的兩種主流實現(xiàn)路線我在項目中試過兩種主流的壓縮實現(xiàn)方式它們原理不同優(yōu)劣也完全不同。理解了這兩條路才能在你自己的系統(tǒng)里選對方案。2.1 路線一以LLM抽取為核心的壓縮器這條路線是讓一個語言模型通常是通用大模型API扮演壓縮器的角色。輸入是“用戶問題檢索到的原始文檔塊”輸出是“從原始文檔中抽取出來的相關(guān)句子集合”。我在實際使用中會要求壓縮結(jié)果嚴(yán)格滿足幾個約束只從原文中抽取不自己改寫按原文順序輸出保留數(shù)字、日期、專有名詞等關(guān)鍵實體與問題無關(guān)的內(nèi)容直接丟棄。下面是一個我常用的Prompt模板你是一個上下文壓縮器。下面的內(nèi)容是從文檔庫中檢索到的原始片段。 你的任務(wù)不是改寫而是從這些片段中挑出與用戶問題直接相關(guān)的句子 按原文順序輸出。必須保留所有數(shù)字、日期、專有名詞和否定關(guān)系。 如果片段與問題無關(guān)直接丟棄。 用戶問題{question} 檢索片段 {context} 壓縮結(jié)果這種方式的優(yōu)點是壓縮質(zhì)量上限很高因為模型能理解語義能判斷哪些句子真正支撐答案缺點是壓縮本身要消耗一次LLM調(diào)用會引入額外延遲和token費用。后面我會專門聊這個成本問題。2.2 路線二以Embedding相似度為核心的過濾器第二條路線不調(diào)用LLM而是用Embedding模型把文檔塊按句子或更細(xì)粒度分塊然后計算每個小塊與用戶問題的向量相似度低于設(shè)定閾值的直接丟棄高于閾值的保留。這條路線本質(zhì)上是在做“相關(guān)度過濾”工程上很好落地。我用它做過一個很實用的中間版本把文檔按句子切分計算每個句子和用戶問題的余弦相似度再按原順序把過線的句子拼起來。但它的局限也很明顯向量相似度是一種表層語義匹配遇到需要跨句推理、存在指代詞、或者隱含否定關(guān)系的內(nèi)容過濾結(jié)果經(jīng)常會“漏”。比如“該功能不支持批量刪除”這句話如果只算相似度它和“如何批量刪除”可能非常相似反而會被錯誤保留或錯誤丟棄。所以我后來更傾向把它當(dāng)作粗篩手段而不是唯一方案。2.3 兩條路線的取舍一張對比表對比維度LLM抽取式壓縮Embedding相似度過濾壓縮質(zhì)量上限高能理解語義關(guān)聯(lián)中低依賴向量匹配額外延遲高需要一次LLM調(diào)用低基本可忽略額外成本每次壓縮都會吃token幾乎為零部署復(fù)雜度依賴LLM API穩(wěn)定性只依賴Embedding模型對長文多跳問題的表現(xiàn)較好較差適合場景對回答質(zhì)量要求高的正式環(huán)境高頻、低延遲、成本敏感的環(huán)境我實測下來的最終選擇是混合路徑先用Embedding過濾把明顯無關(guān)的塊丟掉把候選從12個降到4到5個再對這4到5個塊做LLM抽取壓縮。這樣既控住了壓縮成本又保證了壓縮質(zhì)量。這也是我第三個章節(jié)要講的實際管線。3. 一條可直接落地的壓縮管線從召回、切分、打分到拼裝3.1 完整管線長什么樣我現(xiàn)在搭建的系統(tǒng)里Contextual Compression不是單點操作而是一條完整鏈路。核心流程如下用戶問題輸入向量檢索召回Top-K文檔塊。對每個文檔塊做句子級切分記錄每個句子所屬的原始塊ID和原始順序。用Embedding模型計算每個句子和用戶問題的相似度做一次粗篩過濾。如果過濾后仍超過預(yù)設(shè)條數(shù)對候選句子做一次重排序。將候選句子連同用戶問題交給LLM做抽取式壓縮。按原始文檔順序拼接壓縮結(jié)果作為最終上下文喂給生成模型。每一步看起來都不難但我在前幾步里踩過的細(xì)節(jié)坑值得單獨展開。3.2 分段與打分過濾器的小細(xì)節(jié)先說切分。最初我直接按文檔原有的段落邊界切分后來發(fā)現(xiàn)檢索結(jié)果里經(jīng)常出現(xiàn)“整段不相關(guān)但段中一句相關(guān)”的情況。比如一份用戶手冊里有一段講的是常見錯誤碼列表大部分內(nèi)容和你問的“超時重試機制”無關(guān)但列表最后一行恰好提到超時錯誤碼的處理建議。按整段去過濾這段很可能會被整體丟掉。所以我后來統(tǒng)一改成句子級切分必要時合并成不超過3句話的小片段。這個粒度不會丟掉關(guān)鍵的單句證據(jù)又比單句粒度更穩(wěn)——有些信息要前后兩句才能讀懂。再說閾值。閾值不能拍腦袋定要根據(jù)你的Embedding模型的分?jǐn)?shù)分布來調(diào)。我在自己的數(shù)據(jù)集上統(tǒng)計過人工標(biāo)注為“相關(guān)”的句子相似度分?jǐn)?shù)大都在0.42以上而明顯無關(guān)的句子通常在0.3以下。所以我最初把閾值定在0.45過濾后每輪問答平均留下5到7個句子。這個值可以先用一批帶標(biāo)注的數(shù)據(jù)測出來而不是憑感覺。還有一個小細(xì)節(jié)過濾后我堅持按原文順序排序而不是按相關(guān)度分?jǐn)?shù)降序排列。原因是自然語言里的因果鏈和時間線往往依賴語序如果按分?jǐn)?shù)從高到低拼接會把“因為”和“所以”拆散反而讓壓縮器看不懂上下文。這個坑我實際遇到過按分?jǐn)?shù)排序后LLM壓縮器把后置的結(jié)果句子保留了前置的原因句子被丟棄生成模型給出的答案方向就錯了。3.3 抽取壓縮的Prompt模板與代碼骨架下面的代碼骨架是我目前在用的一條完整壓縮鏈路的簡化版本你可以直接參考復(fù)現(xiàn)def compress_documents(query, docs, filter_model, llm, threshold0.45): # 1. 句子級切分保留原順序和原塊ID segments [] for doc in docs: for sentence in split_sentences(doc): segments.append(sentence) # 2. Embedding粗篩 scores filter_model.calculate_scores(query, segments) kept [seg for seg, s in zip(segments, scores) if s threshold] # 3. 粗篩后還原為原文順序 kept.sort(keylambda x: x.original_order) # 4. 如果候選仍然過多做一次重排只保留最相關(guān)的N條 if len(kept) max_segments: kept rerank(query, kept)[:max_segments] # 5. LLM抽取式壓縮 prompt build_compress_prompt(query, kept) compressed llm.chat(prompt) return parse_compressed_output(compressed)注意一個關(guān)鍵點我把LLM抽取壓縮放在過濾之后而不是檢索之后立刻做。這樣做的原因是LLM壓縮的延遲和token成本都不低把有限的資源花在已經(jīng)被粗篩過的候選內(nèi)容上性價比才最高。這也是我前面說的混合路線相對純LLM抽取式壓縮成本更低的核心原因。4. 壓多狠才算合適壓縮率、信息失真與多跳問題4.1 壓縮率不是越大越好剛開始調(diào)參時我追求極致壓縮希望把上下文壓到原來的10%左右。結(jié)果在單跳問題上效果還行一碰到多跳問題就露餡。所謂多跳問題就是答案需要從兩個或多個文檔塊里綜合推理而不是從一個塊里直接找到。舉個例子某個知識庫里有兩條記錄一條寫“某系統(tǒng)在2021年上線”另一條寫“該系統(tǒng)次年引入了自動回滾機制”。用戶問“自動回滾機制是哪一年引入的”答案需要先推出“次年”指2022年再把兩條信息拼起來。如果壓縮率過大第二條記錄里“該系統(tǒng)”這個主語很可能因為和問題相似度不夠高而被丟棄或者壓縮器為了精簡把“次年引入了自動回滾機制”壓成“引入了自動回滾機制”丟掉了“次年”這個關(guān)鍵時間線索。結(jié)果就是答案缺失或者錯誤。所以我后來把壓縮目標(biāo)從“保留盡量少的內(nèi)容”改成了“保留回答這個問題所需的最小但完整證據(jù)集合”。壓縮率是一個需要反復(fù)調(diào)參的權(quán)衡值不是越小越好。同時對于可能涉及多跳信息的問題我會在壓縮前做一次分組合并把相似度高、可能共同支撐答案的句子組交給LLM聯(lián)合壓縮避免逐句獨立壓縮把邏輯鏈切斷。4.2 LLM壓縮器會帶頭“改寫原文”這條是我吃過虧之后總結(jié)出來的。很多壓縮模型在生成輸出時為了讓語句更通順會不自覺地替換原文用詞這在大模型時代尤為常見。我遇到過最典型的一個真實案例原始文檔寫的是“該接口在2021年之前僅支持XML格式2021年之后支持JSON格式”。壓縮器輸出的結(jié)果是“該接口僅支持XML格式”直接把后半句的時間限定給丟了。用戶問“該接口支持JSON嗎”系統(tǒng)給出了否定回答完全說反了。就是因為壓縮器在“精簡”時把關(guān)于JSON格式支持的那半句當(dāng)作次要信息刪除了。從此以后我在Prompt里強制加了“只做抽取不做改寫保留所有數(shù)字、日期、專有名詞和否定關(guān)系”這幾條硬約束。同時如果條件允許建議對壓縮結(jié)果做一次原文一致性校驗至少把核心數(shù)字和專有名詞抽出來和原文比一遍。做知識庫問答時數(shù)據(jù)準(zhǔn)確性永遠是第一優(yōu)先級寧可上下文長幾百個token也不能讓壓縮器把關(guān)鍵實體改錯。4.3 檢索錯誤的放大效應(yīng)與混合策略還有一個容易被忽略的問題壓縮器默認(rèn)輸入是“與問題相關(guān)的”但檢索階段可能本身就有噪聲。如果你的向量檢索召回了一批相關(guān)度不高的塊壓縮器依然會“盡職盡責(zé)”地從里面抽取內(nèi)容結(jié)果就是把無關(guān)噪聲包裝成了“精心篩選過的證據(jù)”誤導(dǎo)生成模型。這個問題的解法不是讓壓縮器更聰明而是要讓壓縮器別接觸到噪聲。我采用的辦法是在進入壓縮器之前先加一道硬性的相關(guān)度門檻把明顯無關(guān)的塊直接擋在外面。寧可漏掉一些邊緣性相關(guān)內(nèi)容也不能讓大量噪聲進入壓縮環(huán)節(jié)。這個思路和“輸入決定輸出”的理念一致——壓縮器只是在有限輸入里做篩選輸入臟了輸出不可能干凈。5. 壓縮結(jié)果怎么驗收延遲變化、成本賬與質(zhì)量檢查單5.1 端到端延遲與成本變化加了壓縮之后系統(tǒng)肯定比原來慢。我在一套模擬環(huán)境里分別測了三種方案截圖了相對數(shù)據(jù)拆開來看會更直觀方案平均端到端延遲單輪輸入token用量我對這個方案的判斷不壓縮全量拼接1.0x基準(zhǔn)3000質(zhì)量不穩(wěn)定成本高僅Embedding過濾1.05x基準(zhǔn)800~1200性價比最高過濾LLM抽取壓縮1.3x~1.8x基準(zhǔn)400~700質(zhì)量上限最高如果你做的是實時交互類產(chǎn)品用戶等不了超過3秒的響應(yīng)那LLM抽取壓縮這筆額外的延遲就要慎重。這時我建議先用“僅Embedding過濾”的輕量版把上下文降到合適的范圍犧牲一點質(zhì)量上限換取響應(yīng)速度。如果是離線批量處理或者對質(zhì)量要求很高的正式場景再把LLM抽取壓縮打開。5.2 質(zhì)量驗收清單我每次調(diào)參都在跑的項目壓縮做得好不好不能只看上下文少了多少關(guān)鍵看壓縮后信息是否還能支撐正確答案。我會用下面這張清單來驗收每一次調(diào)參結(jié)果查詢里的關(guān)鍵實體是否在壓縮結(jié)果中保留核心數(shù)字、時間、價格是否原樣出現(xiàn)且沒有被拆散否定關(guān)系和排除條件是否保留完整跨句子的邏輯連接因果、時序、轉(zhuǎn)折是否還在專用名詞是否被改寫成同義表達這個清單配合一個小型評測集效果很好。我在系統(tǒng)里維護了30條左右覆蓋單跳、多跳、否定條件、時間限定等類型的典型問題每次調(diào)完閾值或者改完P(guān)rompt都跑一遍這批問題拿壓縮后的上下文和真實答案對照。這樣做比憑感覺看一兩個例子靠譜得多。5.3 一個很實用但容易被忽略的起步建議如果你現(xiàn)在正要給RAG系統(tǒng)加入Contextual Compression我給的建議是不要一上來就直接上“過濾LLM抽取”的全部鏈路而是先只做Embedding相似度過濾用“僅過濾”的版本跑通全流程確認(rèn)檢索鏈路沒有明顯問題再根據(jù)實際質(zhì)量瓶頸決定要不要上LLM抽取壓縮。我現(xiàn)在做任何新項目時都是這個順序確認(rèn)瓶頸在上下文冗余再上重方案。早點把這個順序定下來后面能省下很多調(diào)試時間。