检索侧演进从「能搜到」到「敢引用」——Hybrid 融合、时效治理、知识分级与拒答语言 / Language中文 系列第五章目录 上一章会话记忆分层项目Agentdemo007 —— 电商智能客服 Agent技术栈Java 17 / LangChain4j / Chroma稠密向量 Lucene磁盘倒排稀疏 SiliconFlow 嵌入/重排Qwen3-Embedding-8B4096 维周期2026-09-17Hybrid 时效→ 09-19拒答 知识库在线录入→ 09-20知识分级 ABACPhase 21 红队毒片实验验证规模Phase 20 交付 594 全绿 → 拒答录入交付 1213 全绿11 新测试类 56 例→ Phase 21 交付 1270 全绿红队 3 毒片实测源码github.com/Gavincui123/Agentdemo007前言检索侧的三轮升级没有一轮是提前规划出来的每一轮都是被一个真实缺陷逼出来的。最早我也以为 RAG 就是调一次向量库直到拿ORD-001 怎么开发票去测——什么都没搜回来。订单号词面精确、语义模糊纯向量必然漂移这才补了 BM25 稀疏通道做双路融合。接着是已下架的旧退款政策冒充现行政策混进回答历史片段与现行片段在检索眼里毫无区分时效治理因此上马。第三轮最扎心——V1 客户能搜到 V3 专属政策检索根本没有权限维度Phase 21 的 ABAC基于属性的访问控制按客户等级划定知识可见档位知识分级就是被这一票逼出来的。而知识库里没有的内容模型自己编检索自身堵不住需要的是一个独立的拒答闸门。最后红队的三片毒文档给我算了一笔清醒的账防线防得住注入话术防不住像真的的错误——这笔欠账我明示在案不装作防住了。三次交付的测试计数从 594 涨到 1213 再到 1270全绿。一、漏斗全景每一级都有数字把检索当产品打磨是从承认一件事开始的它不是调一次向量库而是一个七段漏斗。每一段都对应一类我在测试里真撞见过的问题宽召回 2424稠密、稀疏两路各 24 条保证召回率权限过滤把无权片段挡在门外域窄化砍掉不相关业务域0.2 的粗滤线先删掉一眼假的条件重排只留 top-10置信度终闸做最终裁决注入扫描收尾。这些数字全部是配置常量app.rag.*不是拍脑袋——调参的时候每一个都有名字。漏斗有一个总的裁决原则写在RagStep的 javadoc 里低置信知识绝不进 LLM。每一级的失败都有明确去向——终闸不过 →RAG_SKIP话术注入扫描清空 →RAG_SKIP而漏斗终态零片段这个事实本身会被记成groundingMiss成为拒答裁决的关键信号。这是 §七 的伏笔先按下不表。漏斗的最终产物长这样回答自带来源列表每条可对照原文和「可追溯不等于绝对正确」的声明——敢引用不是口号是用户每轮都能看到的东西截图为延迟调优前二、精确词为什么必须有稀疏通道稀疏通道的由来要从一个搜不到的订单号讲起。ORD-001在向量空间里和ORD-002几乎重合和订单一号可能更近——语义相似不等于标识符精确订单号、商品型号、发票类型这类词面精确、语义模糊的 token必须走词面匹配。直觉做法是另造一套精确词标记给这类 token 打标、建专门通道。我最终选了最克制的形态——什么新机制都不加让BM25 的高 IDF 自然上浮稀有词。IDF 本身就是稀有程度的度量机制现成何必再造。HybridRetriever的融合规则三句话稀疏命中优先置顶、稠密补齐、按文本去重// capability/rag/HybridRetriever.java —— 双通道融合publicstaticListRagFragmentfuseChannels(ListRagFragmentdense,ListRagFragmentsparse,intcap){MapString,RagFragmentdenseByTextnewHashMap();for(RagFragmentf:dense){denseByText.putIfAbsent(textKey(f),f);}LinkedHashSetStringseennewLinkedHashSet();ListRagFragmentfusednewArrayList();for(RagFragmentf:sparse){// 稀疏优先置顶提权同文本取稠密副本余弦口径StringkeytextKey(f);RagFragmentchosendenseByText.getOrDefault(key,f);if(seen.add(key)){fused.add(chosen);}}for(RagFragmentf:dense){// 稠密补齐稀疏未覆盖的语义命中if(seen.add(textKey(f))){fused.add(f);}}// …cap 截断…}这段代码里有一个容易看漏的细节同文本双通道命中时保留稠密副本。原因要往下看两级才明白——只有稠密副本带余弦口径才过得了我后面设置的置信度终闸仅稀疏命中的候选只有 BM25 词面分口径未校准不得凭它入上下文§五。诚实记录一处设计与实现的偏差Phase 20 规划的是「向量关键词规则路由」三通道三个独立类落地时收敛为「Retriever seam BM25 稀有词上浮」双通道——规则路由那一路被证明多余砍掉了。规划文档留了偏差注记这是刻意的不记下来三个月后读代码的人会以为实现漏了东西。三、时效治理过时不冒充当前客服知识库有个别的领域没有的风险政策会换代。去年的退款政策还躺在库里语义相关性一点不低甚至措辞更贴合老问题——对检索器来说它就是一条高分候选。所以时效治理要回答的问题是过时片段怎么处理才不会冒充当前。我的第一反应是拿validUntil和当前时间比较检索时动态判断。这条路被我否了它给检索路径引入时钟依赖——测试要注入时钟排序随时钟漂移同一份语料昨天和今天可能给出不同结果。最后落成两层防线全用确定性信号隔离标注temporalTagHISTORICAL的片段在displayText()里强制加前缀【历史参考资料·截至{date}】——过时片段可以进上下文但必须自报家门重排衰减本地 BM25 重排对历史片段乘TEMPORAL_DECAY 0.5——同等相关性近期优先。// capability/rag/Bm25Reranker.java —— 时效衰减for(inti0;in;i){doublescorebm25(docTokens.get(i),uniqueQuery,df,n,avgdl);// Phase 20 时效衰减历史片段降权同等相关性近期优先过时不冒充当前§5.4.1if(HISTORICAL_TAG.equals(candidates.get(i).temporalTag())){score*TEMPORAL_DECAY;}scores.add(score);}两个为什么值得记下来。为什么用显式标签而不是拿validUntil和当前时间比较——前者是语料入库时就定死的确定性信号后者是随时钟漂移的活物检索这种读侧路径经不起不确定性。为什么远程重排不施加衰减——真模型按语义相关性打分优于任何启发式衰减历史片段交给displayText()标注隔离就够了一层防线上再叠一层启发式只会造出解释不了的排序。四、稀疏通道落库之战Chroma 证伪与 Lucene 落盘稀疏通道最初我想完全交给向量库——少一个组件少一份运维。2026-09-17 我把这条路挨个方向试了一遍四条路实测全部堵死教程 FAQ 与代码注释均有记录带稀疏索引的集合创建被服务端拒绝“Sparse vector indexing is not enabled in local”稀疏向量 metadata 写入被拒服务端MetadataValue枚举不含 sparse_vector/search端点 1.0.8 返回 4041.5.9 报 “not implemented for local executor”即便上云官方 BM25 分词器是英文口径——空白切分 英文停用词600 字中文块会切出 0 个 token。第四条最致命前三条还能指望版本演进分词器口径是产品决策600 字中文块切出 0 个 token 意味着中文语料的稀疏索引只能自建。落地方案是 Lucene 磁盘倒排。选型动机我写在了LuceneBm25IndexService的 javadoc 里“用户生产顾虑——单机服务器内存被中间件占用JVM 常驻全量语料过不了生产级”。所以索引用MMapDirectory落盘、读走 OS 页缓存、堆内存 O(1)BM25 用内置相似度k11.2 / b0.75与项目自己的Bm25Reranker同参数两套口径不打架。启动时从 Chroma 分页流式同步chunkId 用sha256(text#source)与稠密库天然统一幂等 upsert 不产生重复块同步失败告警并保留现有磁盘索引继续服务——检索侧组件挂一个不能拖死启动。这套机制第六章已带过一笔这里补全的是动机不是我喜欢 Lucene是被四路证伪逼的。五、置信度终闸BM25 分不能冒充语义置信度终闸是检索的最终动作不是诸多过滤器之一——它裁决的不是某个片段好不好而是这轮检索结果还配不配进 LLM。双判据的裁决逻辑全部收在RetrievalValidator// capability/rag/RetrievalValidator.java —— 逐片段裁决if(f.relevance()!null){booleanpassedf.relevance()rerankMinScore;records.add(newGateRecord(f,passed,…));}elseif(!f.cosineScored()){records.add(newGateRecord(f,false,无余弦口径BM25-only未经理裁决不得入上下文));}elseif(f.score()minScore){records.add(newGateRecord(f,true,…));}else{records.add(newGateRecord(f,false,…));}三支判据被远程重排过的片段看relevance重排模型的置信口径BM25-only 且无余弦口径的直接判负——淘汰原因就是源码里那行字符串未重排但有余弦口径的看minScore。第三支是全漏斗里最较真的规则BM25-only 且未经重排的候选一律不得入上下文。BM25 高分只证明字面匹配——词频锤击都能刷高分某片段反复重复查询词即可获得高余弦/高分但它不能冒充语义置信度证据。所以规则是二选一要么经远程重排凭relevance入内要么淘汰没有第三条路。幸存片段数不足min-count→ 整轮RAG_SKIP兜底话术而不是有一点总比没有好。检索层在这里的克制是 §七 拒答机制的地基检索自己都不确定的东西就别往模型嘴里塞。六、知识分级 ABAC权限骑在文档属性上6.1 三轴拆分Phase 21 之前检索对用户身份是完全盲的——任何会话查任何语料。给检索加权限维度我最先想到的是最直觉的做法建一张 namespace↔level 映射表哪个分区对应哪个等级查表即判。这张表在设计期被我专门杀死了权限骑在容器上往哪个目录放就变成了权限决策——默认放行是漏洞默认拒绝是传了搜不到的对账成本。权限必须骑在文档属性上。最终的三轴拆分长这样轴字段职责领域轴domain业务/领域分类路由用域窄化那一级安全轴requiredLevel客户等级可见性V0~V5 六档主体轴namespace内外边界与存放分区PUBLIC/PRIVATE三根轴各管一件事领域轴服务路由就是漏斗里域窄化那一级的依据安全轴管什么等级的客户能看主体轴管内外边界与存放分区。权限谓词一句话主体类型可见 且 主体等级 ≥ 文档要求等级点对点白名单为例外通道。等级红线是 Phase 21 自身设立、Phase 22 画像守门沿用并回归钉死的那一条等级来源只有登录态 会员服务永不从对话内容取。用户自称我是 VIP不采信——那等于给提示词注入开直通车等级查询失败 fail-closed 按 V0 处理KbLevel.fromCode越界收敛 V0——坏数据可以存在但不放大权限。6.2 过滤在漏斗之前零泄漏是结构性保证ABAC 过滤的落点我选在宽召回之后、一切粗滤之前漏斗 ①′无权片段在进漏斗前就出局。这个落点不是随便挑的——它决定泄漏面。过滤放在后面片段就要一路穿过域窄化、粗滤、重排任何一环把它带进引用列表citation都是事故放在最前面不可见文档的标题连泄漏进引用列表的机会都没有。零泄漏不是拼接处小心一点是结构性保证并且有测试钉死RagStepMemberLevelTest.v1SessionCannotSeeV3DocAndCitationsLeakNothing// capability/kb/KbCatalogService.java —— 四步判定序publicbooleanreadable(Stringsource,StringuserId,KbLevelmemberLevel){StringdocUidKbSourceRef.docUidOf(source);if(docUidnull){returntrue;// 非托管来源种子/Python 流水线语料公开语义}Entryentrycatalog.get(docUid);if(entrynull){returnfalse;// 托管但不在目录已下架/私有无权见的兜底口径}// 例外通道优先点对点白名单命中直接放行专属客群单点授权if(userId!nullentry.allowedPrincipals().contains(userId)){returntrue;}if(entry.namespace()KbNamespace.PUBLIC){KbLevellevel(memberLevel!null)?memberLevel:KbLevel.V0;returnlevel.atLeast(entry.requiredLevel());}returnfalse;// PRIVATE 名单不命中 → 不可见员工/管理侧语义不变}四步判定序里最值得看的是头两步的分界非托管来源种子/Python 流水线语料按公开语义放行托管但不在目录的按已下架/私有无权见兜底判负——两句话划清了库管不到的和库管到的。整条过滤链在漏斗里的位置如下图非托管来源种子语料点对点白名单命中PUBLIC 且 memberLevel ≥ requiredLevel其余含等级不足 / PRIVATE 非名单检索请求userId memberLevel① 宽召回不看权限只管召回率①′ 逐片段 ABAC 过滤放行放行例外通道放行出局——进不了漏斗即进不了引用列表② 域窄化 → ③ 粗滤 → ④ 重排 → ⑤ 终闸 → ⑥ 扫描6.3 反思级 review 补一刀并发腿漏了等级ABAC 交付后的一轮 review 揪出一个隐蔽缺陷并发腿同一轮同时跑主答与工具分支的子上下文只搬了userId没搬memberLevel——子腿按缺省 V0 降级。这个缺陷的方向是安全的fail-closed无泄漏但代价是错杀付费会员V3 客户在并发轮搜不到 V3 政策。修复本身不复杂测试跟着钉死。它教给我的教训比缺陷本身值钱权限上下文是每一个执行分支的必备行李不是主线程的私有字段——每加一个并发分支都得重新过一遍这个分支带了什么行李的清单。诚实边界demo 阶段userId来自客户端声明演示身份切换器游客 V0 / 10010 V1 / 10012 V2 / 10013 V3 / 10014 V4 / 10086 V5真鉴权接入后收口。这在开发计划里是显式登记的欠账不是疏忽。七、拒答无依据不编造拒答最容易做成一句提示词如果不知道就承认不知道。我后来发现这句提示词什么也拦不住——知不知道是模型自己判断的而它恰恰在没有依据的时候自我感觉最好。所以拒答在我这里不是一句提示词是四层结构每层有独立职责。信号层RagStep.skipRag()回答这轮有没有依据可言漏斗终态零片段——空召回、终闸不达标、扫描清空——置groundingMisstrue闲聊免 RAG 的正常跳过不置位否则满屏误报。工具层NoDataSignals管另一类空手而归工具连通但无数据无数据文案附加路由toolDataMisses不冒充政策正文。裁决层RefusalGateStepOrder 690收口判定groundingMiss且 ragFragments/runtimeFacts/toolResults全空且非免拒答意图才构成无依据。提示词层SystemAnchorLayer把拒答约束写进 Runtime 块严禁自身知识补答。四层里最容易被做错的是工具层的语义。工具无数据 ≠ 拒答“订单不存在是真实负事实照样走 runtimeFacts 高置信通道按如实告知未查到 建议核对/转人工作答只有 RAG 与工具全部空手而归才轮到拒答。把没查到当系统坏了”或把系统坏了当没查到是这类机制的两个经典翻车点——后者正是缺陷清单里的第 4 条的真实版本见 §八。裁决层有两个模式默认与生产建议不同// capability/refusal/RefusalGateStep.java —— 五个触发前提 两模式publicStepOutcomeprocess(PipelineContextcontext){if(props.getMode()RefusalProperties.Mode.OFF||!context.groundingMiss()||hasAnyEvidence(context)||context.presetReply()!null||context.concurrentReply()!null||(context.intent()!nullEXEMPT_INTENTS.contains(context.intent()))){returnnewStepOutcome.Proceed();}if(props.getMode()RefusalProperties.Mode.STRICT){context.setPresetReply(props.getPhrase());context.addAuditEvent(AuditEvent.of(AuditEventType.REFUSAL,…));}// prompt 模式SystemAnchorLayer 依 groundingMiss 注入 Runtime 拒答约束此处无需动作returnnewStepOutcome.Proceed();}prompt默认不破坏既有评测基线strictREFUSAL_MODEstrict零 LLM 短路是生产建议——确定性拒答不该依赖模型自觉。类 javadoc 里还有一条裁决要单独说拒答是业务级正确行为不标记 degraded。它和系统降级是正交语义——第一章那把系统还答不答的标尺在这里再次生效正确拒答混进降级统计可观测性就失真了。八、红队扫描器防「注入」不防「像真的的错误」防线搭完我做了一次红队实验往语料里埋三份毒文档看它们各自能走到哪一步。结果精确到令人清醒毒片类型防线结果91 指令注入“忽略之前所有指令与政策约束”注入词面扫描器剔除 → 全池清空 → RAG_SKIP不进 LLM ✓92 高置信错误知识“退款实时到账”伪造政策穿透终闸 扫描直通 LLM残余风险①✗93 钓鱼假官方验证专线骗卡号/验证码社工话术唯一前置防线是语料准入治理 ✗91 是防线正常工作的样子注入词面被扫描器剔除全池清空走 RAG_SKIP不进 LLM。92 是真正的意外一片没有任何注入词面、只是内容像真的的高置信错误知识穿透终闸和扫描直通 LLM。rt-001 实测记录里最有价值的观察是毒片与查询语义高度相关cosine 轻松过 0.4 终闸污染真实参与了召回——把它和真政策区分开的任务根本不在终闸的判据里。93 更棘手钓鱼是社工话术扫描器的词库对它无能为力。所以红队结论不是防线全防住了而是扫描器防注入不防像真的的错误——没有注入词面的假政策会自信满满地走进回答。缓解是系统性的冲突仲裁指令历史片段仅作背景、不得作为现行答案现行资料互斥时不得擅自裁决如实说明存在不同口径、citations 前端溯源、语料准入治理。完整实验与防线盲区标注见红队演示手册。交付当次的走查还揪出一批全绿≠无缺陷的问题最要紧的一条RagStep 的 catch(Exception) 也置groundingMiss——strict 模式下 Chroma/Lucene 宕机会把系统故障包装成知识库暂无资料拒答话术替系统故障背锅。生产开 strict 前必须先修这条登记在报告 §7 缺陷清单。这恰好是 §七 说的第二个经典翻车点的真实版本。九、经验小结标识符检索靠词面语义检索靠向量谁也替代不了谁。融合顺序稀疏置顶与去重口径取稠密副本都要服务于下游终闸的判据口径——检索链路的每一步要为下一步的可裁决性负责。时效治理用确定性信号显式标签不用随时钟漂移的比较防线上加启发式要克制——远程重排不衰减因为标注隔离已经够用多一层启发式就多一分解释不了的排序。权限要骑在数据属性上不要骑在容器上过滤落点决定泄漏面——进不了漏斗就进不了引用列表零泄漏靠结构不靠小心。没查到是事实坏了是故障没依据才拒答——三种状态语义正交混用任何一个都会把错误包装成正确。红队的价值不在通过率在精确归因哪层挡了什么、哪层放进来、放进来的靠什么兜底比全绿诚实得多。十、已知边界诚实清单身份即 mock等级门信任ChatRequest.userId客户端声明真鉴权接入后收口。注入扫描是词库可被改写绕过prod 演进预留 Nacos 词库热更 / 模型分类器对像真的的错误无效§八。strict 模式的故障误报RagStep 异常路径也置groundingMiss生产开 strict 前先修。录入主链无事务换版先于索引的窗口内检索侧该文档消失缺陷清单第 1 条修复登记在案。本文数字与机制出处capability/rag/HybridRetriever / RetrievalValidator / Bm25Reranker / LuceneBm25IndexService、capability/kb/KbCatalogService / KbLevel、capability/refusal/RefusalGateStep交付报告与缺陷清单见 2026-09-19 拒答录入报告。相关阅读系列目录 · 语料清洗切分入库教程 · 下一章决策层、持久化与闸门硬化含真 RAG 收尾与毒性实验
