:如何讓問答助手每句話都有出處)
1. 為什么“帶引用的回答”才是RAG落地的分水嶺做檢索增強生成RAG的同行大概都有過這種體驗Demo階段效果驚艷一旦交給業(yè)務方試用第一個被質(zhì)疑的問題永遠是——“這個結(jié)論你是從哪份文檔里看出來的”如果答不上來哪怕答案本身是對的用戶也不敢信。這不是用戶挑剔而是RAG系統(tǒng)從“玩具”走向“可用”必須跨過的一道坎。我最初搭第一版問答助手時走的是最樸素的路線把文檔切片、向量化、存進向量庫用戶提問就召回Top-K片段拼進Prompt讓大模型自由發(fā)揮。結(jié)果上線第二天就翻車了。有同事問“差旅報銷的標準是多少”模型洋洋灑灑寫了一段數(shù)字看著挺像那么回事但翻遍制度文檔根本找不到對應條款——典型的“睜眼說瞎話”。更麻煩的是這種幻覺往往裹著專業(yè)術語的外衣不逐字核對很難發(fā)現(xiàn)。問題的根子在于純生成模式下模型沒有“必須忠于原文”的硬約束。它只是在做概率續(xù)寫而不是在做信息檢索。RAG的價值恰恰在于把“生成”錨定在“檢索到的真實片段”上但如果輸出層不把引用關系顯式暴露出來這個錨定就是隱形的用戶無法驗證系統(tǒng)也無法自證。所以這篇要聊的就是怎么用Spring AI把RAG問答助手做成“每句話都有出處”的形態(tài)。核心目標有三個回答里的關鍵結(jié)論能對應到具體文檔片段引用來源可點擊、可追溯當檢索不到相關內(nèi)容時系統(tǒng)要老實說“不知道”而不是硬編。適合正在做企業(yè)知識庫、客服助手、文檔問答這類場景的開發(fā)者參考無論你是剛接觸Spring AI還是已經(jīng)跑通過基礎RAG鏈路都能從引用鏈路的實現(xiàn)細節(jié)里拿到可復用的東西。2. 引用機制的技術底座從向量召回 to 可溯源輸出2.1 RAG的“檢索-生成”兩段式結(jié)構(gòu)里引用信息在哪一環(huán)丟失先把鏈路拆開看。一個標準RAG流程大致是文檔加載 → 文本切分 → 向量化 → 存入向量庫 → 用戶提問向量化 → 相似度檢索 → 召回片段拼入Prompt → 大模型生成回答。引用信息天然存在于“召回片段”這一步——每個片段都帶著元數(shù)據(jù)來源文件、頁碼、段落序號。但在“拼入Prompt”和“生成回答”這兩步之間元數(shù)據(jù)往往被丟掉了。原因很直接傳統(tǒng)做法是把召回片段的純文本拼接成一個長字符串塞給模型模型看到的只是一堆沒有身份標識的文字。它生成回答時自然無法告訴你“這句話來自片段3”。要解決這個問題必須在Prompt構(gòu)造階段就給每個片段打上可識別的標記并在輸出解析階段把標記還原成引用。2.2 Spring AI的Document結(jié)構(gòu)與元數(shù)據(jù)承載能力Spring AI對RAG的支持體現(xiàn)在它的Document抽象上。一個Document對象不僅包含文本內(nèi)容還包含一個MapString, Object類型的元數(shù)據(jù)容器。這個設計是引用機制的基石——你可以在文檔加載階段就把來源信息寫進元數(shù)據(jù)比如Document doc new Document( chunkText, Map.of( source, 員工手冊_v3.pdf, page, 12, chunkIndex, 5, section, 差旅報銷 ) );向量化時Spring AI的VectorStore會把文本轉(zhuǎn)向量存儲同時保留元數(shù)據(jù)。檢索時返回的Document列表里元數(shù)據(jù)是完整帶回來的。這意味著引用所需的一切信息在檢索環(huán)節(jié)就已經(jīng)就緒關鍵是怎么把它傳遞到最終輸出。2.3 讓模型“學會引用”的Prompt工程要點模型不會天生就懂“引用”這件事你得在Prompt里明確教它。我的做法是在系統(tǒng)提示詞里定義一套引用標記規(guī)范比如要求模型在每句結(jié)論后面用[來源:片段編號]的格式標注。同時把召回片段按編號排列每個片段前加上【片段1】這樣的標識。這里有個容易踩的坑片段編號和元數(shù)據(jù)的映射關系必須由程序維護不能讓模型自己編。模型只負責在回答里寫[來源:1]程序拿到回答后根據(jù)編號去查對應的Document元數(shù)據(jù)渲染成真實的文件名和頁碼。如果讓模型直接輸出文件名它極有可能記錯或編造。提示Prompt里要明確告訴模型“如果召回片段中沒有相關信息直接回答‘根據(jù)現(xiàn)有資料無法回答’不要編造”。這句話對抑制幻覺的作用比任何后處理都大。2.4 引用粒度句子級、段落級還是文檔級引用粒度直接決定實現(xiàn)復雜度和用戶體驗。文檔級引用實現(xiàn)最簡單但用戶還得自己去文檔里翻段落級是折中方案能定位到具體章節(jié)句子級最精確但要求模型在生成時逐句標注對模型指令遵循能力要求高且容易因為標注過密影響可讀性。我的建議是段落級為主、句子級為輔對于關鍵結(jié)論數(shù)字、條款、定義要求句子級引用對于綜述性描述允許段落級引用。在Prompt里可以這樣區(qū)分“涉及具體數(shù)據(jù)或條款時必須在句末標注來源一般性總結(jié)可在段末統(tǒng)一標注?!?. 用Spring AI搭一條可溯源的問答鏈路3.1 文檔入庫階段就把“出處”焊死很多人做RAG時文檔切分完只存文本元數(shù)據(jù)隨手填個文件名了事。等到要做引用時才發(fā)現(xiàn)信息不夠。我的經(jīng)驗是入庫時多花十分鐘補元數(shù)據(jù)后面省十小時排查。具體做法是在DocumentReader和TextSplitter之間加一層元數(shù)據(jù)注入。以PDF為例用PagePdfDocumentReader讀取時每頁會生成一個Document此時就可以把頁碼寫進元數(shù)據(jù)。切分時Spring AI的TokenTextSplitter會保留原文檔的元數(shù)據(jù)并附加chunk序號。代碼大致如下ListDocument rawDocs new PagePdfDocumentReader(resource).get(); ListDocument enriched rawDocs.stream().map(doc - { MapString, Object meta new HashMap(doc.getMetadata()); meta.put(source, fileName); meta.put(ingestedAt, Instant.now().toString()); return new Document(doc.getContent(), meta); }).toList(); TokenTextSplitter splitter new TokenTextSplitter(500, 100, 5, 10000, true); ListDocument chunks splitter.apply(enriched); vectorStore.add(chunks);注意TokenTextSplitter的構(gòu)造參數(shù)第一個是目標chunk大小token數(shù)第二個是重疊量。重疊量設得太小會導致跨段落的語義被切斷引用時出現(xiàn)“半句話”的情況設得太大又會讓召回片段冗余。我實測下來500/100這個組合對中文技術文檔比較友好。3.2 檢索環(huán)節(jié)的元數(shù)據(jù)保留與重排序檢索本身用VectorStore.similaritySearch就能跑通但要做引用得關注返回的Document里元數(shù)據(jù)是否完整。Spring AI默認會保留但如果你用了自定義的EmbeddingModel或后處理邏輯要確認元數(shù)據(jù)沒被剝離。另一個提升引用準確率的技巧是加一層重排序。向量相似度召回有時會把語義相近但來源不相關的片段排前面導致引用錯位??梢栽谡倩豑op-10后用一個輕量級的交叉編碼器或簡單的關鍵詞匹配做二次排序把真正包含答案的片段頂上來。Spring AI目前沒有內(nèi)置重排序組件但可以自己實現(xiàn)一個DocumentPostProcessor接口在檢索后、拼Prompt前介入。public class KeywordBoostPostProcessor implements DocumentPostProcessor { Override public ListDocument process(ListDocument documents, String query) { // 對包含query關鍵詞的片段加權(quán) return documents.stream() .sorted((a, b) - Double.compare( score(b, query), score(a, query))) .limit(5) .toList(); } }3.3 Prompt模板里怎么安放引用標記Prompt模板是引用機制的“合同”。我用的模板結(jié)構(gòu)是這樣的你是一個嚴謹?shù)膯柎鹬?。請僅根據(jù)以下參考資料回答問題。 參考資料 【片段1】{片段1內(nèi)容} 【片段2】{片段2內(nèi)容} ... 回答要求 1. 每個具體結(jié)論后必須標注來源格式為[來源:片段編號] 2. 如果參考資料中沒有相關信息回答“根據(jù)現(xiàn)有資料無法回答該問題” 3. 不要編造任何未在參考資料中出現(xiàn)的信息 問題{用戶問題}這里的關鍵是片段編號從1開始連續(xù)排列且程序里維護一個MapInteger, Document的映射。模型輸出[來源:2]后程序查映射表拿到片段2的元數(shù)據(jù)渲染成[員工手冊_v3.pdf 第12頁]這樣的可讀引用。3.4 輸出解析把[來源:1]還原成可點擊的引用模型返回的原始文本里帶著[來源:1]標記需要用一個解析器把它替換成真實引用。我用正則匹配\[來源:(\d)\]然后查映射表替換。如果同一個片段被多次引用可以合并顯示。public String renderCitations(String rawAnswer, MapInteger, Document chunkMap) { Pattern pattern Pattern.compile(\\[來源:(\\d)\\]); Matcher matcher pattern.matcher(rawAnswer); StringBuilder sb new StringBuilder(); while (matcher.find()) { int idx Integer.parseInt(matcher.group(1)); Document doc chunkMap.get(idx); String citation doc ! null ? [ doc.getMetadata().get(source) 第 doc.getMetadata().get(page) 頁] : [來源未知]; matcher.appendReplacement(sb, citation); } matcher.appendTail(sb); return sb.toString(); }前端拿到渲染后的文本可以把引用部分做成可點擊的鏈接或懸浮卡片展示片段原文。這樣用戶既能看答案又能一鍵核對出處。4. 實測中暴露的五個坑與對應解法4.1 模型不按格式標注引用怎么辦這是最常見的問題。即使Prompt里寫了要求模型有時還是會漏標或標錯格式。我的解法是雙保險一是在Prompt里用few-shot示例強化格式給一兩個“問題-帶引用回答”的樣例二是在輸出解析時做容錯如果整段回答沒有任何引用標記就在末尾追加一句“本回答未標注具體來源請謹慎參考”同時記錄日志用于后續(xù)優(yōu)化。如果漏標率持續(xù)偏高可以考慮換指令遵循能力更強的模型或者降低單次召回片段數(shù)量——片段太多時模型容易“顧此失彼”。4.2 召回片段本身不相關引用就成了“偽證”引用機制有個前提召回的片段確實包含答案。如果檢索環(huán)節(jié)拉胯模型要么硬編要么引用一個不相關的片段后者更危險——因為它看起來“有出處”實則誤導。所以引用機制必須和檢索質(zhì)量一起優(yōu)化。我的做法是加一個相關性閾值向量相似度低于某個值的片段直接丟棄不進入Prompt。閾值需要根據(jù)你的embedding模型和語料特點調(diào)一般從0.7開始試。另外對于“根據(jù)現(xiàn)有資料無法回答”的情況要在前端明確區(qū)分展示不要和正常回答混在一起。4.3 中文文檔切分導致的引用錯位中文沒有天然的空格分隔按token切分時容易出現(xiàn)“斷句”問題。比如一個完整的條款被切成兩半召回時只命中后半段引用顯示出來就是半句話。解法是在切分時優(yōu)先按段落和標題切Spring AI的TokenTextSplitter支持按字符切分可以配合自定義的TextSplitter先按\n\n分段再對超長段落做token切分。另外中文的標點符號。應該作為切分的優(yōu)先邊界。我寫過一個簡單的后處理器在token切分后檢查chunk末尾是否是完整句子不是的話把最后一個標點之后的內(nèi)容挪到下一個chunk。4.4 多文檔來源時的引用去重與合并當知識庫里有多個版本文檔時同一個問題可能召回來自不同文檔的相似片段。如果模型分別引用了片段1和片段3而這兩個片段其實來自同一份文檔的相鄰頁引用顯示就會冗余??梢栽阡秩倦A段做合并如果兩個引用的source相同且頁碼相鄰合并成“第12-13頁”。更復雜的情況是同一事實在不同文檔中有不同表述這時引用反而要保留多個來源讓用戶看到差異。判斷邏輯可以交給模型在Prompt里要求“如果多個片段對同一問題有不同說法請分別列出并標注各自來源”。4.5 引用帶來的Token開銷與性能權(quán)衡把片段編號、引用標記、元數(shù)據(jù)都塞進Prompt和輸出會顯著增加token消耗。實測下來帶引用要求的Prompt比普通RAG Prompt長約30%-40%輸出也長20%左右。對于高頻調(diào)用的場景這個成本不能忽略。優(yōu)化方向有三個一是只對關鍵結(jié)論要求引用綜述性內(nèi)容不強制二是壓縮片段內(nèi)容召回后先用一個小模型做摘要再拼入Prompt三是緩存映射關系避免每次請求都重建MapInteger, Document。如果用的是按token計費的API建議先算一筆賬假設每次多消耗500 token日調(diào)用1萬次一個月就是1.5億token的增量值不值得取決于業(yè)務對可信度的要求。5. 讓“不知道”成為一等公民拒答策略的設計5.1 為什么拒答比胡答更難做好讓模型說“我不知道”聽起來簡單做起來難。模型的默認行為是“盡力回答”哪怕信息不足也會編一個看似合理的答案。要讓它老實拒答需要在Prompt、檢索閾值、輸出校驗三個層面同時施壓。Prompt層面已經(jīng)說了要明確寫“沒有相關信息就拒答”。但這還不夠因為模型對“沒有相關信息”的判斷標準很模糊。更可靠的做法是在檢索層面就做判斷如果Top-1片段的相似度低于閾值直接不走生成返回預設的拒答話術。這樣既省token又避免模型“強行發(fā)揮”。5.2 檢索置信度閾值怎么定閾值定高了很多能回答的問題被拒定低了不該答的也答了。我的經(jīng)驗是分場景設閾值事實型問題數(shù)字、日期、條款閾值設高一些比如0.75開放型問題解釋、總結(jié)可以低一些比如0.6。另外可以結(jié)合召回片段的數(shù)量做判斷如果Top-3片段的相似度都低于0.65基本可以判定知識庫覆蓋不到。這個閾值不是拍腦袋定的要用一批標注好的測試問題跑一遍畫一條“閾值-準確率/拒答率”曲線找平衡點。我一般會準備50-100個測試問題其中一半是知識庫能覆蓋的一半是不能覆蓋的用來自動化調(diào)參。5.3 拒答話術的設計與用戶體驗拒答不是冷冰冰地說“不知道”就完事。好的拒答應該告訴用戶為什么答不了、可以怎么調(diào)整。比如“根據(jù)現(xiàn)有資料無法回答該問題建議補充相關文檔或換個問法”。如果檢索到了一些弱相關片段可以附上“你可能想了解”的推薦把弱相關片段作為參考列出但不作為答案。前端展示上拒答回答要用不同的視覺樣式比如灰色背景、提示圖標和正?;卮饏^(qū)分開避免用戶誤以為這是正式答案。6. 從能用到好用引用體驗的進階優(yōu)化6.1 引用懸浮預覽與原文高亮基礎版引用只是文本標注進階版應該支持懸浮預覽鼠標移到引用上彈出片段原文并把命中的關鍵詞高亮。這需要前端配合但后端要把片段原文和命中位置一起返回。實現(xiàn)方式是在渲染引用時除了返回文件名和頁碼再附帶片段文本和query關鍵詞的位置信息。6.2 多輪對話中的引用繼承多輪對話里用戶追問“那第二條呢”系統(tǒng)需要知道“第二條”指的是上一輪引用的文檔里的第二條。這要求對話狀態(tài)里保存上一輪的引用上下文。Spring AI的ChatMemory可以存對話歷史但引用映射需要額外維護。我的做法是在ChatMemory之外再掛一個CitationContext記錄每輪回答的引用片段ID追問時把相關片段重新召回或直接從上下文取。6.3 引用準確率的自動化評估上線后怎么知道引用準不準人工抽查成本太高??梢宰鲆粋€自動化評估腳本用一批測試問題跑完RAG鏈路后用另一個模型判斷“回答中的每個引用是否真的支持對應結(jié)論”。雖然這個判斷本身也有誤差但能篩出明顯錯位的引用作為優(yōu)化線索。評估指標可以定兩個引用覆蓋率有引用的結(jié)論占比和引用準確率引用正確的占比。前者低說明模型不愛標注后者低說明檢索或映射有問題。兩個指標一起看能快速定位問題環(huán)節(jié)。7. 一些個人實踐中的體會這套帶引用的RAG方案我在幾個內(nèi)部知識庫場景里跑過最大的感受是引用機制的價值不只是“可信”更是“可調(diào)試”。以前模型答錯了你只能猜是檢索問題還是生成問題有了引用一眼就能看出是召回片段不對還是模型沒按片段說。排查效率提升非常明顯。另一個體會是不要追求100%的引用覆蓋率。有些綜述性、過渡性的句子本來就不需要引用強行標注反而讓回答變得支離破碎。把精力放在關鍵結(jié)論的引用準確性上比追求形式上的“句句有出處”更有價值。最后分享一個小技巧在Prompt里給模型一個“引用示例”比單純描述格式要求有效得多。我通常會在系統(tǒng)提示詞里放一個完整的問答示例展示“問題-召回片段-帶引用回答”的全過程模型模仿這個示例的格式準確率能到90%以上。這個示例不用長兩三句話就夠但一定要包含引用標記的正確位置和格式。