ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

第 08 篇:检索——相似度阈值、元数据过滤与混合检索

第 08 篇:检索——相似度阈值、元数据过滤与混合检索 第 08 篇检索——相似度阈值、元数据过滤与混合检索第 07 篇收尾留了四个“换库前先自问”的问题ID 形状、元数据类型、过滤语义、写入可见性。其中“过滤语义”最悬——当时只证明了 ES 把Filter.Expression翻成查询串文本、or会翻出语义问题还没真正用它检索过一次。这一篇把读路径彻底打开similaritySearch的三个参数各自把结果切成什么形状、元数据过滤在两库上到底一不一致、以及向量这一路为什么需要全文那一路来救。第 03 篇的最小闭环里检索只有一个q参数一切默认。真实系统里检索效果好不好九成取决于这一步怎么调——而它有三个参数每个都会显著改变结果。一、检索入口三个参数三种生效时机1.1 接口没变参数变多了检索的实现仍然只依赖VectorStore接口第 07 篇数过的那五个方法一个没多用。变化全在SearchRequest的构造上publicListDocumentsearch(Stringquery,inttopK,doublethreshold,Filter.Expressionfilter){SearchRequest.BuilderbuilderSearchRequest.builder().query(query).topK(topK).similarityThreshold(threshold);if(filter!null){builder.filterExpression(filter);}longstartedAtSystem.currentTimeMillis();ListDocumenthitsvectorStore.similaritySearch(builder.build());longelapsedSystem.currentTimeMillis()-startedAt;// ……日志与空结果告警见 4.4三个参数三种生效时机——这一点决定了它们各自的调法用户问题过滤 filterExpression检索前生效裁剪候选集向量检索按相似度取 topK 个阈值 similarityThreshold检索后生效筛 topK 的结果最终名单过滤在检索之前生效候选集从一开始就只有符合条件的那部分。它既能提精度也能把正确答案挡在门外topK决定取多少个候选阈值在检索之后生效先取足topK个再把低于阈值的剔除。三者叠加的次序是“先砍候选集 → 取 topK → 再筛一遍”。记住这个次序第二节的实验结果才解释得通。1.2 对外的三个入口RagController上新加了三个只读接口本篇所有实验都从它们发出接口参数用途GET /rag/retrieveq、topK、threshold、filter单次检索GET /rag/retrieve/sweepq、topK、thresholds、filter阈值扫描一次跑多档GET /rag/hybridq、topK、candidates、mode、k、alpha混合检索见第五节filter参数收的是一行短文本如source:eq:01-员工手册.md由一个迷你 DSL 翻成Filter.Expression第三节讲它。二、阈值它筛的不是候选集是 topK 的结果2.1 先扫一遍别拍脑袋阈值最难调的地方在于调高之后接口返回空列表而“返回空”有两种完全不同的原因——“库里没有”和“阈值把仅有的几条全筛掉了”。两者在接口眼里一模一样。所以先写了个扫描接口把同一问题的命中数随阈值变化扫出来publicListThresholdRowsweep(Stringquery,inttopK,ListDoublethresholds,Filter.Expressionfilter){ListThresholdRowrowsnewArrayList(thresholds.size());for(Doublethreshold:thresholds){longstartedAtSystem.currentTimeMillis();ListDocumenthitssearch(query,topK,threshold,filter);// ……记录命中数与本档最高分rows.add(newThresholdRow(threshold,hits.size(),topScore,elapsed));}returnrows;}注意它是每档真跑一次检索不是拿一次分数列表自己过滤——如果自己过滤验证的是我们的假设真跑验证的才是后端的行为。2.2 实测五个问题 × 十档阈值EStopK5每个问题、每档阈值各真跑一次单位命中数问题0.00.10.20.30.40.50.60.7~0.9年假有多少天55521100数据订正超过一万行由谁执行55531110云枢知识库支持哪些文件格式55553210180 天55520000v3.2.155510000同一套实验在 Qdrant 上复跑只有一处不同年假有多少天在 0.3 档命中3个ES 是 2。其余四行逐档一致。这一个块的出入正是第 07 篇量过的分数噪声——同一个块在两库的分数差 0.0019~0.0147恰好骑在 0.3 这条线上两边就落到了不同的侧。怎么读这张表命中数从 5 掉到 0 的那一段就是这个问题“有效分数”的分布区间。v3.2.1在 0.3 档只剩 1 个、0.4 档归零说明正确块含v3.2.1的那块分数就在 0.38 附近配置阈值若设 0.5这个问题就查不出东西了。2.3 验证“阈值筛的是 topK 的结果”教科书说法容易让人以为阈值限制了候选集。设计了一组对照库里有 6 个块把topK和阈值交叉跑ES年假有多少天topK阈值命中数本档最高分20.020.567920.320.568820.510.568860.060.568860.330.57360.510.5679三个事实topK2时哪怕库里有 6 个块命中最多也就 2——先取 topK再筛阈值 0.3 时topK2命中 2、topK6命中 3——topK 越大阈值筛掉的越多因为候选池更深本档最高分在 0.5679~0.573 之间漂——同一次实验里同一个块分数并不恒定第 07 篇的结论在这里再次显形连续两次查询embedding 本身就有抖动。Qdrant 同表复跑命中数六行完全一致最高分漂在 0.5669~0.5718。阈值语义两库一致分数数值有噪声。2.4 默认配置的实测配置里rag.top-k5、rag.similarity-threshold0.5。不带任何参数调用/rag/retrieve?q年假有多少天命中 1 个01-员工手册#0分数 0.5688。第二名01-员工手册#1分数 0.3777被 0.5 的阈值筛掉了——与 2.3 的结论吻合。三、元数据过滤从 Filter.Expression 到一行 DSL3.1 为什么需要一层 DSLFilter.Expression是给程序用的对象树。调试检索时需要的是“从浏览器地址栏当场改一个条件”——没有这一层每换一次过滤条件都要改代码、重编译、重启。DSL 语法刻意做小字段:运算符:值 单个条件如 source:eq:01-员工手册.md 条件;条件;条件 多个条件之间是「并且」 字段:in:值1,值2 集合判断值用逗号分隔支持eq/ne/gt/gte/lt/lte/in/nin/isNull/isNotNull十个运算符。实现核心只有一段publicstaticFilter.Expressionparse(Stringspec){if(specnull||spec.isBlank()){returnnull;// 空串 不过滤不是过滤掉全部}FilterExpressionBuilderbuildernewFilterExpressionBuilder();FilterExpressionBuilder.Opaccnull;for(Stringraw:spec.split(;)){FilterExpressionBuilder.Opconditioncondition(builder,raw.trim());accaccnull?condition:builder.and(acc,condition);}returnaccnull?null:acc.build();}null的语义必须分清解析结果是null表示“不施加过滤”如果把它误当成“什么都不要”一个笔误就会让检索永远返回空。RetrievalService里专门为这种情况留了一条告警日志if(size0filter!null){log.warn(施加过滤条件后一个块都没命中过滤条件为 [{}]请确认字段名与取值都对得上,filter);}少了这一行“过滤条件写错”和“库里没有”这两件事排查起来分不开。3.2 类型强转静默失败的根源值不能永远当字符串。coerce把纯数字转成Long、true/false转成BooleanprivatestaticObjectcoerce(Stringvalue){if(value.matches(-?\\d)){try{returnLong.parseLong(value);}catch(NumberFormatExceptione){returnvalue;// 超出 long 范围的长数字串保留原样}}if(true.equalsIgnoreCase(value))returnBoolean.TRUE;if(false.equalsIgnoreCase(value))returnBoolean.FALSE;returnvalue;}这一步不能省的原因在第 07 篇埋过写进元数据的chunkIndex是数值类型拿字符串1去比不同后端表现不一致——有的判不相等、有的把整个条件判假而两者都不报错。这是“元数据类型”那一问的答案落到了代码上。另外注意split(:, 3)只切三段值里可能自带冒号Windows 盘符切多了会把值切断。3.3 为什么不做顶层的“或者”第 07 篇实测过把or交给ElasticsearchVectorStore它生成的查询串没给子条件加括号优先级会让结果与预期不同。与其暴露一个语义不可靠的运算符不如只做“并且”——需要“或者”的场合用同一字段上的in表达。要用跨字段的“或”就绕不开那个坑得先确认后端行为再说。3.4 实测六种过滤条件两库对照问题固定年假有多少天阈值 0、topK5只看过滤的作用ES 结果过滤条件命中返回的块不过滤501-员工手册#0, 01-员工手册#1, 03-运维值班规范#1, 02-产品说明#0, 03-运维值班规范#0source:eq:01-员工手册.md201-员工手册#0, 01-员工手册#1source:eq:02-产品说明-云枢知识库.md202-产品说明#0, 02-产品说明#1source:in:01-员工手册.md,03-运维值班规范.md401#0, 01#1, 03#1, 03#0chunkIndex:gte:1301#1, 03#1, 02#1charCount:gt:500301#0, 02#0, 03#0charCount:lt:200301#1, 03#1, 02#1同一套条件在 Qdrant 上复跑六种条件的命中块集合逐条一致。唯一的差异是“不过滤”时第 3、4 名的顺序ES 把03#1排在02#0前Qdrant 相反——又是分数噪声两个块的分数本来就咬得很近0.294 附近顺序互换不改变集合。这是第 07 篇“过滤语义”那一问的最终答案本项目用到的十种运算符两库语义一致。不一致的是ne和or第 07 篇第五节的两个真实缺陷DSL 干脆把or挡在了外面。四、过滤 × 阈值叠加两把刀一起砍两个参数各自都会砍结果叠起来是相乘的效果。实测ES年假有多少天过滤条件阈值命中数返回的块不过滤0.0501#0, 01#1, 03#1, 02#0, 03#0不过滤0.5101#0source:eq:03-运维值班规范.md0.0203#1, 03#0source:eq:03-运维值班规范.md0.50无最后一行是本节的重点过滤先把候选砍到 2 个阈值再把仅有的 2 个筛掉返回空——而库里明明有这两个块它们只是相似度不够 0.5运维规范讲数据订正跟“年假”本来就远。接口表现与“数据没入库”一模一样。调参规则由此而来过滤和阈值不要同时收紧。想限定来源就把阈值放 0想卡质量就别过滤两者都要先扫一遍 sweep 确认叠加后还有剩。五、混合检索向量一路不够的地方5.1 向量检索的盲区精确字面先摆一个实测反例。问题就是四个字母Redis含它的块只有 1 个02-知识库#0分支第一名分数真正含 Redis 的块纯向量✘ 01-员工手册#10.2281✔ 02-知识库#0第二名0.2190纯全文BM25✔ 02-知识库#01.244—Redis在全文里只出现一次却稳稳被 BM25 拎到第一向量检索反而让它输给了“压根不含 Redis”的员工手册块——差 0.0091名次就翻了。7 个探测词里其余 6 个v3.2.1、180 天、xlsx、DELETE、18:00、DBA向量都答对了但盲区不需要高频出现一次就够把答案挤出去。原理不玄向量把“Redis 出现在知识库块里”这个事实压进了一个语义坐标而员工手册块里“缓存、系统”之类的词跟Redis的语义邻居高度重叠。向量擅长“换个说法问同一件事”恰好不擅长“一字不差地找它”。5.2 全文那一路在算什么全文检索走的是倒排索引 BM25 词频打分。它不认识同义词但字面命中就是命中。有一个必须知道的细节——ES 把查询切成了什么查询分析后的词年假有多少天年 / 假 / 有 / 多 / 少 / 天数据订正数 / 据 / 订 / 正180 天180 / 天v3.2.1v3.2.1原样Redisredis转小写ES 默认分析器把中文切成单字。所以“年假有多少天”的全文检索本质是六个单字的词袋匹配——它不知道“年假”是一个词。即便如此字面命中依然是硬道理这个问题的全文第一名还是含“年”“假”最多的员工手册块BM25 分数 7.423远高于第三名的 2.244。单字切分对中文全文检索是粗糙的但作为向量检索的补集已经够用它的职责不是理解语义而是守住字面。5.3 HybridRetriever抽象之外的第一个能力设计混合检索时先撞上一个接口问题VectorStore只有similaritySearch一个查询入口没有“全文检索”这个动作。混合检索属于抽象没有覆盖的能力拿到它的唯一路径是第 07 篇提过的逃生舱getNativeClient()publicbooleansupported(){returnvectorStore.getNativeClient().orElse(null)instanceofElasticsearchClient;}publicHybridResultsearch(Stringquery,inttopK,intcandidates,Stringmode,doublek,doublealpha){ObjectnativeClientvectorStore.getNativeClient().orElse(null);if(!(nativeClientinstanceofElasticsearchClientclient)){Stringnote当前向量库是 vectorStore.getClass().getSimpleName()拿不到 ElasticsearchClient全文这一路无法执行。混合检索要求后端同时具备向量检索与全文检索两种能力。;log.warn(混合检索未执行{},note);returnnewHybridResult(mode,candidates,k,alpha,false,note,List.of(),List.of(),List.of(),0,0);}// 向量分支走抽象不设阈值候选阶段要完整名单ListDocumentvectorHitsvectorStore.similaritySearch(SearchRequest.builder().query(query).topK(candidates).similarityThreshold(0.0).build());// 全文分支走原生客户端TextHitstextHitsfullTextSearch(client,query,candidates);ListHybridHitfusedfuse(vectorHits,textHits,topK,mode,k,alpha);// ……}三个刻意的决定后端不支持时明确拒绝不悄悄退化。悄悄退化成纯向量检索的后果是“混合检索跑了但没效果”这种失效方式最难发现全文分支写死依赖 ES 的content字段名。这是框架实现细节不在任何接口和配置里——逃生舱换来的能力代价就是可移植性候选阶段不设阈值。融合之前要的是两个分支的完整名单阈值是融合之后才该考虑的事。实测 Qdrant 下调用/rag/hybrid五个问题全部返回supportedfalsenote 原文如上——这正是“能力跟着后端走”的实物证据。5.4 融合RRF 与加权两种都实现两份名单怎么合成一份两个分支的分数不可比余弦相似度在 0~1 之间BM25 上不封顶本组实测最高 11.9直接相加没有意义。所以实现了两种融合**RRF倒数排名融合**只用名次不用分数每个分支贡献1/(k名次)k 取 60privatestaticdoublerrfScore(IntegervectorRank,IntegertextRank,doublek){doublescore0;if(vectorRank!null)score1.0/(kvectorRank);if(textRank!null)score1.0/(ktextRank);returnscore;}加权求和把两路分数各自 min-max 归一化到 0~1 后按 α 加权。它能体现“分数有多高”但归一化在候选集内做换一批候选同一个文档的分数就变。实测ES年假有多少天topK3、每路候选 6向量 148 ms / 全文 5 ms名次纯向量余弦纯全文BM25RRF 融合加权 α0.5101-员工手册#0 0.568801-员工手册#0 7.42301#0 0.0327901#0 1.0000201-员工手册#1 0.377701-员工手册#1 2.81201#1 0.0322601#1 0.2869303-运维值班规范#1 0.29402-产品说明#0 2.24402#0 0.0315002#0 0.1022RRF 的数字可以逐个回代验证这也是它敢用在生产里的原因——每一步都可核查块向量名次全文名次计算结果01#0111/61 1/61 2/610.0327901#1221/62 1/62 2/620.0322602#0431/64 1/630.0315003#13不在候选1/630.01587注意第三名融合把03#1换成了02#0。纯向量的第三名是运维规范块语义沾边但产品说明块在向量分支排第 4、全文分支排第 3——两路都认可RRF 就把它顶了上来。这就是融合的价值单路名次有偶然性两路共识更可靠。v3.2.1那一组是另一个方向的例子全文只有 1 条命中含编号的块向量补足其余两名。全文负责把“一字不差”的钉在前面向量负责铺语义上的邻近面。加权融合的第一名恒为 1它是两路各自的最大值归一化后 0.5×1 0.5×1第二、三名依赖候选集里所有 6 条的分数分布——对归一口径敏感这正是“换一批候选分数就变”的实证。5.5 想用 ES 自带的 RRF先看 licenseES 8.x 原生支持rank.rrf理论上 Java 侧一行都不用写。实测直接 403{error:{type:security_exception,reason:current license is non-compliant for [Reciprocal Rank Fusion (RRF)]},status:403}五个问题全部失败。免费 license 不含 RRF。所以自己实现——几十行代码换来的是不依赖 license 的确定性。这也是“逃生舱 自己融合”组合的额外好处融合逻辑在我们手里可以按业务调整比如给某一路加权、过滤掉低质候选而不是被数据库厂商的实现框死。5.6 耗时混合检索 向量一路 全文一路实测耗时ES问题向量全文年假有多少天148 ms5 ms数据订正超过一万行由谁执行142 ms5 ms云枢知识库支持哪些文件格式127 ms5 ms180 天131 ms3 msv3.2.1135 ms4 ms向量的 100 多毫秒大头在现算查询向量——每次检索都要调一次 embedding 接口第 06 篇讲过的那一步全文检索是本地倒排索引毫秒级。混合检索的总耗时 ≈ 向量耗时 全文耗时本例约 150 ms可接受。六、两库对照哪些过了抽象哪些没有第 07 篇结尾的四问本篇给“过滤语义”补上了实测答案。汇总能力ESQdrant过了抽象吗阈值语义筛 topK 结果✔✔✔ 两库一致阈值分数数值有抖动0.5679~0.573有抖动0.5669~0.5718✔ 抖动在既有噪声内十种过滤运算符✔✔✔ 集合级一致ne/or✘ 语义缺陷第 07 篇✘ne类型陷阱✘ DSL 层规避过滤 × 阈值叠加先过滤后筛可为 0同✔混合检索✔✘ 拿不到全文能力✘抽象之外混合检索是本系列第一次正面撞上“抽象不覆盖的能力”。第 07 篇只是从接口签名推断“漏了什么”这一篇是实打实要用了、发现 Qdrant 路线上做不了。getNativeClient()逃生舱给了出路但拿回来的类型跟着后端走——代码从这一行起可移植性归零。工程上的结论不是“别用混合检索”而是把不可移植的部分圈在一个类里本项目就是HybridRetriever一个文件入口处用supported()显式声明能力其余代码继续只依赖抽象。哪天后端换了失效面被圈死在一个文件里。七、工程规则先扫阈值再定配置。/rag/retrieve/sweep把每个问题的分数分布摸清阈值取“正确块稳定通过、噪声块稳定被筛”的位置——本库实测 0.5 能守住三个语义问题、放走一个180 天是个可接受的折中“返回空”必须先分因。命中 0 个时先跑 sweep某档突然归零 阈值筛掉了从 0 起一直空 真没数据。两种空的处理完全不同过滤条件必须带类型。chunkIndex:gte:1里的1是Long不是1字符串比较在有的后端是静默假DSL 只暴露 and 和 in不暴露顶层 or。跨字段的“或”要先验证后端转译语义第 07 篇的教训过滤与阈值不同时收紧。叠加是相乘容易把候选砍穿归零混合检索显式判supported()不支持就报原因不悄悄退化融合优先 RRF。分数不可比是常态余弦 0~1、BM25 不封顶、两库分数还有噪声名次是唯一稳定的共同语言不可移植的代码圈进一个类。逃生舱的能力用instanceof显式认领失效面圈死在一个文件。小结这一篇把检索从“一个q参数”扩成了完整可调的三个参数并补上了向量检索的补集阈值筛的是 topK 的结果不是候选集调高会“先变少再变空”实测年假有多少天从 0.3 档的 2 个掉到 0.6 档的 0 个过滤在检索前裁剪候选集。自建的迷你 DSL 十个运算符两库实测集合级一致类型强转不是洁癖是防静默失败过滤 × 阈值叠加会把候选砍穿限定到运维规范再加 0.5 阈值 0 条而数据明明在库里向量检索守不住精确字面Redis实测被不含它的块挤到第二名全文BM25恰好相反单字切分粗糙但字面命中就是命中混合检索在VectorStore抽象之外只能走getNativeClient()逃生舱Qdrant 路线上明确不可用代码里显式拒绝而非悄悄退化RRF用名次融合绕开分数不可比每个分数可回代验证2/61 0.032791/64 1/63 0.03150ES 原生 RRF 免费版 403自己实现耗时向量 127~148 ms大头是现算查询向量全文 3~5 ms。到这里检索交出的已经是一份“两路共识、按融合分排好”的候选名单。名单再多最后能塞进提示词的也就几条——怎么从名单里挑、怎么压是拿到名单以后的事按下不表。
返回列表