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