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