员工问“出差发票抬头填什么”,知识库返回旧制度中的研发中心名称。财务同事换一个账号搜索,又看到内部复核附则。两个结果都与票据相关,但第一个版本过期,第二个是否能展示取决于当前身份。把检索相似度调高,解决不了这两个错误。
下面用五份虚构制度与故障文档、七个切片、六个标注问题完成一次检索演示。程序执行排名融合、重复文档处理、版本过滤和撤权后的检查。两路排名由人工指定,没有运行嵌入模型、近似向量索引或重排模型。实验回答的是候选如何进入结果,以及在哪一步可能泄露内容。
先把语料和答案写出来
差旅票据第三版规定:员工在国内出差后十五日内提交报销。发票抬头填写示例科技有限公司,税号为示例税号。发票日期应处于出差期间。抬头错误时请供应商重开,不得手工涂改。机票行程单与付款记录一并提交。所有员工可以阅读这份当前制度。
第二版同样允许所有员工阅读,但标记为已替代。它要求出差后三十日内提交,抬头填写示例研发中心。这份旧制度保留在语料中,专门检验检索器会不会把历史答案带入当前问题。业务若需要查历史申请,应该通过明确的历史查询入口选择有效日期,而不是在当前答案中混用两个版本。
财务复核附则只允许财务角色阅读:复核人员检查税号和发票查验结果,高风险供应商编号保存在财务名册;普通员工只提交票据,不能读取风险名册;异常由财务负责人复核后登记。实验没有真实供应商信息,这段权限设定用于观察一个相关但不可见的候选如何处理。
住宿制度规定:普通城市每晚上限四百元,一类城市每晚上限六百元,超额由申请人承担;会务指定酒店超额时,在预订前取得主管书面批准,申请中附会务通知。标准和例外共同决定答案,只返回“四百元”不足以处理会议场景。
最后一份是远程访问故障文档:出现错误码 ERR_CONN_104 时,先确认设备时间,再刷新登录凭证;重复失败时向服务台提交客户端版本和时间戳,不提交密码。它允许所有员工阅读,用来检验精确术语查询,不与报销答案混合。
语料由工程师明确编写,可以逐句判断相关性,没有真实企业的制度效力。文末完整代码包含全部正文,运行后直接输出文档、切片、标注和检查结果,复现不需要企业数据或模型账户。
六个问题分别检查什么
| 问题 | 身份 | 当前相关文档 |
|---|---|---|
| 发票抬头填什么 | 普通员工 | 差旅第三版 |
| 普通城市住五百元怎么报 | 普通员工 | 住宿制度 |
| 会议指定酒店超标怎么办 | 普通员工 | 住宿制度 |
| ERR_CONN_104 | 普通员工 | 远程访问故障 |
| 财务如何复核异常票据 | 财务角色 | 财务复核附则 |
| 海外长期派驻补贴标准 | 普通员工 | 无 |
标注时把身份写进问题,是因为相关且无权的文档不能计入用户应获得的答案。普通员工问财务内部复核细节,系统即使知道附则存在,也不能把它当作应该召回而没召回的公开资料。否则质量指标会推动工程师放松权限来提高分数。
住宿的两个问题指向同一文档,却检查不同段落。第一个问题需要标准金额和超额处理,第二个问题需要会务例外与事先批准条件。如果只标注文档相关,切片漏掉例外时仍可能得到文档级命中。可以在文档相关性之外再标注“能够支持答案的段落”,把找对文档与拿到完整依据分开。
无答案问题也要人工确认语料范围。程序返回空列表可能因为资料不存在,也可能因为授权服务故障或检索服务失败。用户界面应该分别表达“当前可见资料中没有依据”和“检索暂时不可用”。将后者显示成没有制度,会引导员工依据错误的缺失结论办理业务。
六个问题是回归用小样本,不足以估计企业整体效果。它们适合固定一个错误后防止再次出现。要比较模型或切片方案,还需要从真实问题中抽样,保留部门分层和查询频率,并留出未参与调参的检查集。本轮没有使用六个样例编造平均准确率。
切片的边界决定能不能回答
实验把差旅第三版复制为三个切片,分别标为抬头、提交和票据,但正文保留整份内容。这是故意构造的重复夹具,用来观察重叠内容如何占满结果。它不代表推荐的切片方式,也不证明某个自动切片器会产生这些结果。
正式切片宜保留文档 ID、内容版本、章节路径和源文档定位。文档更新后,接入任务按照内容摘要判断哪些片段失效;用户点击引用时,前端按同一版本定位原文。若索引保存旧文本、链接却打开覆盖后的新文本,读者看到的出处可能无法支持生成的答案。
住宿例子说明了固定字符窗口的风险。切片只包含“普通城市四百元”,相邻切片保存会务例外,召回器可能只拿到前者。工程师可以让章节标题和例外条款保持在同一片段,或在召回命中后补取同章节的相邻内容。补取的邻居也要经过权限和版本检查,不能绕过入口过滤。
表格也有同样的问题。金额没有币种、周期与适用城市,就不能支撑报销判断。提取器最好把表头复制到对应行的文本表示,再保留表格定位,方便读者复核。单靠增加重叠长度,无法保证跨页表格的单位和例外都能一起进入候选。
切片长度需要结合所用模型的 tokenizer 和输入限制选择,中文字符数不能直接充当 token 数。这里没有选择嵌入模型,所以没有写一个适用于所有语料的长度。先保留制度章节的语义边界,再用真实问题检查丢失的上下文,比拿一个固定数字切完全部文档更容易解释错误。
把两路名次算到结果里
对同时涉及差旅和住宿的查询,人工设置关键词排名为 t1、t2、t3、h1,语义通道夹具排名为 t2、t1、t3、h1。前三个切片都来自差旅第三版,h1 来自住宿制度。两路只交换前两名,让读者可以手算名次融合,而不需要解释模型分数。
RRF 对每一路中出现的切片加上一个名次倒数,常数取六十,名次从一开始。一路中相同切片先去重,没有出现的切片贡献零。两个通道都把 t3 排第三,因而它得到两次六十三分之一;h1 得到两次六十四分之一。
t1 = 1/(60+1) + 1/(60+2) = 0.03252247488101534
t2 = 1/(60+2) + 1/(60+1) = 0.03252247488101534
t3 = 1/(60+3) + 1/(60+3) = 0.031746031746031744
h1 = 1/(60+4) + 1/(60+4) = 0.03125
t1 和 t2 同分时按 ID 排序,得到可重复的结果。常数六十是这个实验固定的参数;它让相邻名次的差异较小,并不表示已经为制度检索选到了最优值。Elasticsearch 8.19 的 RRF 文档给出了对应的基于名次融合方式,本文只在 Node 中实现公式,没有执行 Elasticsearch 的查询接口。
直接取融合后的前三个切片,结果里只有差旅第三版,一份文档占了三个名额。对这三个已截断结果去重,只会剩下差旅第三版,不能凭空找回住宿制度。脚本采用另一种顺序:先遍历完整融合候选,按文档保留最前一个切片,再截取前三个文档,才得到差旅第三版和住宿制度。算法没有提高任何嵌入相似度,而是避免重复片段消耗最终展示名额。
文档限额也会损失信息。差旅第三版如果很长,抬头和报销期限位于不同章节,只留一个切片可能丢掉另一个答案条件。因此,搜索列表可以按文档分组展示,展开后保留若干段落;供回答模型使用的上下文则按问题需要选择支持段落。不能把页面去重规则原封不动当成上下文截断规则。
融合窗口同样影响结果。两路只给前两名时,h1 根本不会进入合并列表,后续去重也找不回住宿制度。扩大窗口会增加候选合并、授权检查和重排的成本。调整之前先检查相关片段是在召回前缺失,还是在融合后被截断,才能把计算花在发生错误的位置。
直接相加关键词分数与向量相似度,需要处理两路量纲和分布。RRF 避开了这项分数校准,却也丢弃了分差信息:第一名遥遥领先和第一名略胜第二名,在名次公式里没有区别。它适合作为可解释起点,是否继续使用要由标注问题上的变化决定。
先限制搜索空间,再判断候选可见性
再看一个更短的反例:全库候选依次是财务附则、旧差旅制度、住宿制度,截取前两个后才做权限和当前版本过滤。对普通员工,前两条全部被删,结果为空;住宿制度本来可见,却没有进入截取窗口。
实验在同一列表上先过滤再截取,得到住宿制度。这个结果证明的是列表操作顺序的影响,不是在测量真实向量索引的过滤召回率。近似索引面对低选择性或高选择性过滤时如何寻找候选,还需要在目标引擎和数据分布中测量。
Elasticsearch 8.19 的 kNN 文档说明了检索过程中的过滤路径。实施时要核对具体 API 上过滤的位置,不能把外层后置过滤当作搜索阶段过滤。本轮引用固定的八点十九文档,仅用于确认接口语义边界,没有宣称验证了该版本的延迟或召回表现。
关键词通道和向量通道都要使用当前租户与权限范围。若关键词通道只返回公开内容,向量通道却搜索全库,融合时仍会把受限片段带回来。授权条件从身份系统取得,模型对用户部门的猜测以及用户在问题里自称的角色都不构成凭据。
缓存也要遵守同一范围。按问题文本共享一个包含正文的结果缓存,会把财务角色的命中交给普通员工。可以把租户、授权范围与权限代号纳入缓存键,或只缓存候选 ID,在使用时再检查。即使缓存的只是 ID,也要避免把受限文档标题和存在性暴露给无权用户。
撤权发生在召回之后
撤权演示从财务用户开始。权限代号为八时,召回列表包含财务附则 f1 与公开差旅片段 t1。程序接着撤销财务访问,把代号改为九,再检查即将交给重排器的候选。实际输出只剩 t1,f1 没有进入下一步。
这里的“下一步”是本地数组,实验没有调用外部重排器,也没有真实权限服务。它验证了检查点的位置和过滤结果,没有验证权限消息传播延迟。模拟授权服务超时时,脚本明确选择空列表,表示拒绝输出;这只是拒绝路径夹具,没有执行网络超时注入。
真实服务可以先批量提交候选文档 ID、版本与调用者身份,请权威授权服务返回逐项许可及权限代号。比起每个切片调用一次,文档级批量校验减少重复工作。如果一批中的个别文档无法确认,就剔除这些文档;若整批确认失败,当前路径不向重排器发送正文。
权限检查之后还可能发生撤销。二次检查缩短了过期窗口,但无法凭一个普通请求消灭“检查结束到发送内容”之间的竞争。产品要明确使用哪个时点的权限结果。若要求撤销提交后禁止任何新的披露,授权提交与出口需要共享更强的同步或短期许可协议,还要处理已在途的数据。
对已经发送给模型的内容,系统不能通过删除索引把它收回。因此外部重排器能否接收企业数据,必须在接入之前确定,包括供应商保留策略和数据处理约定。没有获准的服务就不接收正文,可以使用本地排序或停止该路径。权限正确和外部处理被允许是两项独立前提。
生成回答阶段也有出口。重排通过的片段进入上下文后,若权限变更,流式回答可能还在发送。需要严格撤权的场景,可以在生成前再次确认,并采用能够在输出前完成检查的缓冲方式;这会牺牲首字延迟。本文没有实现流式输出,所以只把这个边界列作工程选择,未声称演示已经覆盖。
一个零召回和一个空答案
低召回夹具把“普通城市住五百元怎么报”的候选故意限定为 t1、t2。它们都来自差旅票据,人工标注的相关文档只有住宿制度。按去重文档计算,找到零份相关文档,分母为一,召回率为零。换一个更贵的重排器也找不到没进入候选的 h1。
这个零分只属于指定的候选夹具。脚本没有从自然语言运行向量搜索,因此不能写成某个模型漏掉了住宿制度。工程师在真实系统看到类似失败时,可以沿原文、提取文本、有效版本、检索过滤、两路候选逐步定位:正文缺失与候选截断需要不同修复。
精确错误码查询采用字面包含检索,实际命中 v1。它提供一个可复现的术语路径,不涉及中文分词或 BM25。上线语料存在代码格式差异时,还要规定大小写、连字符和空白的规范化;没有这层协议,用户输入看似相同的错误码可能进入不同检索分支。
无答案问题采用“海外长期派驻”的字面查找,当前可见语料没有匹配,所以返回空列表。这个测试只能说明本夹具在字面路径上没有结果,不能证明语义检索能够可靠拒答。向量最近邻即使返回一个最高分片段,也没有提供“资料里存在答案”的证明。
业务评测可以把相关文档召回与答案充分性分开记录。前者检查前若干个去重文档里找到了哪些标注资料,后者由人工核对证据能否支持问题中的条件。无答案问题单列误答率,不把空相关集合硬塞进有答案召回率的分母,避免用不统一的约定美化平均值。
近似向量搜索的召回率又是另一项指标:它把近似结果与相同向量空间里的精确近邻比较。即使近似索引找齐了精确近邻,这些邻居仍可能是旧版本或没有答案的段落。评估索引参数时固定向量与查询,评估业务质量时固定语料、权限与人工标注,两组比较才各有可解释的基准。
内容版本与权限版本分开发布
差旅第三版替代第二版时,接入任务先构建新片段及两个检索通道的数据,确认完整后切换当前版本。若关键词索引已经是第三版,向量索引还是第二版,融合器要根据权威版本信息剔除旧片段。暂时少一条结果,比把两个抬头合成一个答案更容易解释。
内容更新需要重新提取或嵌入,撤权则应先停止访问,再做后台索引清理。把权限绑定到嵌入重建队列上,会让大文档更新积压拖延撤权。实验中内容版本未变,权限代号从八变九,正是为了说明两个状态需要独立推进。
换嵌入模型时,查询向量和文档向量要使用兼容的模型版本及预处理。维度相同只保证能够计算距离,不保证两个空间的坐标含义一致。工程师可以建立新索引旁路评估,用相同标注问题比较,然后切换查询端与索引引用;不把两个模型的向量混进一个未说明的距离排序。
这一步还会增加存储与更新成本。旧索引尚未退出,新索引已经写入时,要同时保存两份向量、元数据和构建过程中的工作空间。本文的小语料没有测索引内存,因此没有给出每百万文档的容量数字。预算应按切片数、维度、数值格式及目标引擎开销计算,并把重复切片算进去。
把每次错误留成可重放样例
运行环境为 Node v25.5.0,脚本不依赖第三方包。首次执行时,人工排名让住宿进入了前三名,与“重复切片占满前三”的预期不符,断言失败;修正夹具为本文两路排名后,演示通过。保留这条过程说明,避免把预设预期当成运行结果。
node vector-lab.mjs
本轮结果包括四项可核对变化:完整融合候选先按文档去重再截取后出现住宿文档;后过滤为空而先过滤得到住宿;撤销财务权限后只向下一步传递公开片段;精确错误码命中故障文档。六个问题的标签同时留在输出中,只有正文点明的夹具路径执行了断言,没有完整的模型评测成绩。
后续接入真实检索器时,可以保留这套语料作为权限和融合回归,再另建真实查询评测。发生漏检就记录问题、身份、语料版本和各阶段候选;发生越权则记录检查代号与内容出口,避免把受限正文直接写进共享日志。读者需要的是能够重走一次错误的证据,而不是一个脱离身份和版本的相似度分数。
完整语料与融合演示代码
把下面代码放入 vector-lab.mjs,用前面的命令运行。关键词列表和语义列表都是指定的测试输入,程序不会请求模型。输出中的语料与标签用于审查本例;排名、权限代号和断言结果都来自这段本地程序。
import assert from 'node:assert/strict';
const docs=[
{id:'travel-v3',version:3,current:true,acl:['all'],title:'差旅票据制度',text:'员工在国内出差后十五日内提交报销。发票抬头填写示例科技有限公司,税号为示例税号。发票日期应处于出差期间。抬头错误时请供应商重开,不得手工涂改。机票行程单与付款记录一并提交。'},
{id:'travel-v2',version:2,current:false,acl:['all'],title:'差旅票据旧制度',text:'员工在国内出差后三十日内提交报销。发票抬头填写示例研发中心。本版已由第三版替代,不再用于新申请。'},
{id:'finance',version:1,current:true,acl:['finance'],title:'财务复核附则',text:'财务复核人员检查税号和发票查验结果。高风险供应商编号保存在财务名册。普通员工只提交票据,不能读取风险名册。异常由财务负责人复核后登记。'},
{id:'hotel',version:1,current:true,acl:['all'],title:'国内住宿制度',text:'国内出差住宿按城市分类管理。普通城市每晚报销上限四百元,一类城市每晚上限六百元。超额部分由申请人承担。参加会务且指定酒店超额时,在预订前取得主管书面批准,并在申请中附会务通知。'},
{id:'vpn',version:1,current:true,acl:['all'],title:'远程访问故障',text:'出现错误码 ERR_CONN_104 时,先确认设备时间,再刷新登录凭证。重复失败时向服务台提交客户端版本和时间戳,不要提交密码。此流程不处理报销问题。'}];
const chunks=[{id:'t1',doc:'travel-v3',section:'抬头',text:docs[0].text},{id:'t2',doc:'travel-v3',section:'提交',text:docs[0].text},{id:'t3',doc:'travel-v3',section:'票据',text:docs[0].text},{id:'old',doc:'travel-v2',section:'旧版',text:docs[1].text},{id:'f1',doc:'finance',section:'复核',text:docs[2].text},{id:'h1',doc:'hotel',section:'标准与例外',text:docs[3].text},{id:'v1',doc:'vpn',section:'错误码',text:docs[4].text}];
const labels=[{q:'发票抬头填什么',user:'employee',relevant:['travel-v3']},{q:'普通城市住五百元怎么报',user:'employee',relevant:['hotel']},{q:'会议指定酒店超标怎么办',user:'employee',relevant:['hotel']},{q:'ERR_CONN_104',user:'employee',relevant:['vpn']},{q:'财务如何复核异常票据',user:'finance',relevant:['finance']},{q:'海外长期派驻补贴标准',user:'employee',relevant:[]}];
const byChunk=new Map(chunks.map(c=>[c.id,c])),byDoc=new Map(docs.map(d=>[d.id,d]));let aclEpoch=8,financeAllowed=true;
function allowed(id,user){const d=byDoc.get(byChunk.get(id).doc);return d.current&&(d.acl.includes('all')||(user==='finance'&&financeAllowed));}
function rrf(lists){const scores=new Map();for(const list of lists){[...new Set(list)].forEach((id,i)=>scores.set(id,(scores.get(id)??0)+1/(60+i+1)));}return [...scores].sort((a,b)=>b[1]-a[1]||a[0].localeCompare(b[0]));}
// Rankings are fixtures, not embedding model outputs.
const keyword=['t1','t2','t3','h1'],semanticFixture=['t2','t1','t3','h1'];const fused=rrf([keyword,semanticFixture]);
assert.equal(fused[0][0],'t1');
const top3=fused.slice(0,3).map(([id])=>byChunk.get(id).doc);const seen=new Set();const diverse=fused.filter(([id])=>{const d=byChunk.get(id).doc;if(seen.has(d))return false;seen.add(d);return true;}).slice(0,3).map(([id])=>byChunk.get(id).doc);
assert.equal(new Set(top3).size,1);assert.deepEqual(diverse,['travel-v3','hotel']);
const badGlobal=['f1','old','h1'];const post=badGlobal.slice(0,2).filter(id=>allowed(id,'employee'));const pre=badGlobal.filter(id=>allowed(id,'employee')).slice(0,2);assert.deepEqual(post,[]);assert.deepEqual(pre,['h1']);
const recalled=['f1','t1'].filter(id=>allowed(id,'finance'));const epochAtRecall=aclEpoch;financeAllowed=false;aclEpoch++;
const toReranker=recalled.filter(id=>allowed(id,'finance'));assert.deepEqual(toReranker,['t1']);const authorityTimeout=[];assert.equal(authorityTimeout.length,0);
const exactTermResults=chunks.filter(c=>allowed(c.id,'employee')&&c.text.includes('ERR_CONN_104')).map(c=>c.id);assert.deepEqual(exactTermResults,['v1']);
const lowRecallCandidates=['t1','t2'];const recall=lowRecallCandidates.some(id=>byChunk.get(id).doc==='hotel')?1:0;assert.equal(recall,0);
const noAnswer=chunks.filter(c=>allowed(c.id,'employee')&&c.text.includes('海外长期派驻')).map(c=>c.id);assert.deepEqual(noAnswer,[]);
console.log(JSON.stringify({node:process.version,docs,chunks,labels,rankingProvenance:'manually specified fixtures; no embedding/ANN/reranker model run',keyword,semanticFixture,fused,top3,diverse,postFilter:post,preFilter:pre,revocation:{epochAtRecall,currentEpoch:aclEpoch,recalled,toReranker,authorityTimeout},exactTermResults,lowRecall:{relevant:['hotel'],candidates:lowRecallCandidates,recall},noAnswer,assertions:'all passed'},null,2));











