1. 先别急着调参搞懂相关性打分到底在算什么做搜索开发的人几乎都会遇到同一个困惑明明文档里都有我要的关键词为什么排在前面的不是我想要的那条这时候你翻文档、查资料满屏都是“TF-IDF”“BM25”“词频”“逆文档频率”看得头晕但搜出来的结果还是不对。我最早做 Elasticsearch下文简称ES搜索优化时也踩过这个坑。当时接手的是一个电商搜索项目用户搜“苹果手机”结果前几条全是标题里带“苹果”的MacBook配件真正的iPhone排在了第五页。试过各种 boost、各种 filter效果时好时坏后来花了一整个下午把 Lucene 的打分源码翻了一遍才意识到问题的根源不在查询写法而在于我一直没搞懂分数到底是怎么算出来的。这一篇我就把搜索相关性这块从打分原理到实战调优完整捋一遍重点讲清楚“分数来源”和“怎么干预分数”这两件事顺手把我会踩的坑、常用的排查手段也一并分享了。适合刚接触ES搜索开发、以及做了段时间但总被排序问题困扰的朋友。1.1 从TF-IDF到BM25Lucene默认打分模型的演变先聊一个最基础的概念相关性分数本质上是一个数学模型算出来的数值。它的目标只有一个——估算“这篇文档和这个查询有多匹配”。早期Lucene也就是ES底层的搜索引擎库用的是TF-IDF模型。TF指“词频”也就是一个词在文档里出现了多少次IDF是“逆文档频率”衡量一个词在整个索引里有多“稀有”。这个模型的逻辑很直观词出现得越多越相关词越稀有越能区分文档。但TF-IDF有个很明显的缺陷它只看词频和稀有度没考虑文档长度。一篇只有50个字的标题和一篇5000字的详情页如果都出现了同一个词TF-IDF会认为详情页更相关因为词频可能更高。可现实中往往是短文档更精准。所以Lucene从6.0版本开始默认模型换成了BM25。BM25在TF-IDF基础上引入了两个关键参数k1控制词频的饱和曲线b控制文档长度的影响力度。说通俗一点一个词在文档里出现10次和20次分数差距不会像TF-IDF那样翻倍因为词频到一定次数后对相关性的贡献会趋于饱和而文档越长单次匹配的“价值”会被适当压低。顺带说一个很多人没注意到的点ES从5.x开始默认就是BM25但很多老项目的索引是从早期版本滚过来的如果你用的ES版本很老并且没重建索引实际跑的可能还是TF-IDF。我见过有同事调了半天BM25参数结果索引压根用的不是BM25白忙一场。所以拿到一个老项目第一件事是确认索引的similarity配置。1.2 BM25的k1和b到底怎么调BM25的两个参数在索引级别的similarity配置里设置。k1默认是1.2b默认是0.75。这两个值的含义和影响方向我直接说人话k1越大词频对分数的影响越强也就是说同一篇文档里关键词出现次数越多分数拉得越高k1越小词频的边际效应衰减得越快。适合的场景是你的文档里关键词出现次数对“是不是相关内容”的指示性很强比如技术文档、说明书k1可以调高。b越大文档长度对分数的影响越大长文档被“惩罚”得越狠b越小文档长度的影响越弱。适合的场景是你的文档长度普遍比较均匀比如都是短标题、短描述可以调小b如果文档既有几十字的标题又有几千字的正文建议保持默认或者略降。我自己的经验是默认的1.2和0.75覆盖了绝大多数场景不用一上来就乱调。真正会动到这两个参数的场景通常是“短文本搜索”或者“正文为主的长文本搜索”这种两极分化的场景。比如你做的是新闻标题搜索标题普遍很短b就该往0.5左右降你做的是论文检索正文占大头k1可以升到1.5左右试试。改参数之后一定要重建索引才能生效这跟很多配置不一样改完并不会实时作用到已有数据上。这个我后面在常见问题里还会提因为真有人改完配置发现分数没变化跑来问为什么。1.3 除了BM25ES打分流程里还有哪些“隐形变量”光知道BM25还不够因为ES的实际打分从来不是只有一个模型在算。一个查询的整体分数是由评分公式算出来的里面包含了好几部分查询词在文档里的BM25得分核心部分查询协调因子coord一篇文档命中了查询里的几个词命中得越多分数越高一点字段长度归一化field length norm不同字段长度不同打分时会做平衡查询归一化query norm把查询本身的权重也纳入计算这几个变量叠加在一起就解释了一个常见现象为什么你只是加了或者去掉一个查询条件其他文档的排序也变了。因为每个文档的分数是相对计算出来的查询变了一个词整个打分上下文都变了。举个例子你搜“苹果 手机”A文档同时出现了“苹果”和“手机”B文档只出现了“苹果”。如果只搜“苹果”B可能排得很靠前但当你把“手机”也加进查询后A因为命中两个词协调因子把它的分数抬高了一截排序自然就变了。这不是BUG这是打分机制的正常表现。2. 查询怎么写分数才能按你的意思走原理搞清楚了接下来进入实操层。很多人在查询DSL上踩坑根源是没分清几个看起来很像的查询语法到底有什么差别写完也不知道分数会怎么算。这一节我把最常用的几种查询拆开讲清楚。2.1 match、term、match_phrase术语要分清先说term查询。term是最基础的精确匹配它不做分词直接把整个查询词当作一个完整的词条去倒排索引里找。适合用在对keyword类型字段的精确查找上比如按订单号、用户ID、状态做过滤。但很多人会把term用在文本字段上就会出问题——你搜“苹果手机”文本字段已经被分词器拆成了“苹果”“手机”term拿“苹果手机”整词去匹配肯定匹配不到。再说match查询。match是全文搜索的主力它会先对查询词做分词再用分出来的词去匹配。还是“苹果手机”match会把它拆成“苹果”和“手机”然后去匹配文档。这个过程中还会应用查询时的权重、算分逻辑是一个完整的相关性评分为主查询。match_phrase则是短语匹配它不仅要求词都出现还要求词与词的顺序和位置符合查询条件。比如搜“苹果手机”文档里是“……手机是苹果的……”就不会匹配因为顺序不对。如果要稍微放宽位置限制可以用slop参数。slop1表示允许词之间的位置差一格这在搜索长尾词、偏口语化表达时很实用。我个人的建议是能区分场景就一定要区分。需要搜索相关性排序的场景主力一定是match做精确过滤用term搜固定搭配、品牌名、专业名词用match_phrase。混用是相关性翻车的最大源头之一。2.2 bool查询的三个关键子句怎么组合实际项目中几乎没有单靠一个match就能满足需求的搜索。多数场景都是用户输入一堆关键词系统要匹配核心内容同时又要满足某些过滤条件比如价格区间、分类、上架状态。这时候bool查询就是主力。bool查询里最常用的三个子句是must、should、filter。must是“必须匹配”它参与打分。也就是说命中must条件的文档才有可能出现在结果里而且must的匹配情况会影响排序分数。should是“加分项”文档匹配任意一个should条件都会加分但不强制。它的使用场景是用户搜“苹果”你希望同时匹配“华为”“小米”这些相关品牌能获得额外加分但不匹配也不至于被排除。filter是“过滤条件”匹配结果必须满足但不参与打分。这个设计很人性化因为它解决了ES早期版本的一个痛点以前过滤也要算一遍分白白浪费性能。filter有缓存优化适合放价格、分类、状态这些不需要影响排序的条件。这里重点说一个容易误解的点filter不参与打分不代表加了filter之后排序不变。因为filter把一批不符合条件的文档过滤掉了剩下来的文档之间的相对排名跟过滤前有可能不一样。原因我前面讲过打分是相对计算的少了竞争对手部分文档的协调因子和归一化系数会变化。所以别看到结果排序变了就以为filter在“偷偷打分”实际上它只是改变了“参赛选手名单”。2.3 minimum_should_match这个参数经常被忽视should子句有个配套参数minimum_should_match它的作用是设置“至少匹配几个should条件才算数”。这个参数在很多场景下能救命。典型场景是搜索条件比较宽泛时比如用户搜“夏天 空调 便宜”match分词后是三个词bool里三个should条件。如果不用minimum_should_match那么只要匹配其中一个词的文档都会进来结果可能混杂大量只提到“便宜”的垃圾信息。这时候把minimum_should_match设为2就要求文档至少同时匹配两个词搜索结果的质量会直线上升。这个参数可以设成绝对值比如2也可以设成百分比比如75%。百分比模式会根据查询词的总数动态计算适合用户输入词数波动大的场景。我习惯的做法是对短查询1-2个词不设minimum_should_match因为词太少限制反而会造成漏召回对长查询3个词以上设为50%或者2保证召回质量和相关性之间的平衡。3. 默认打分不够用function_score让你自己定义“什么是相关”BM25再完善也只是处理“文本匹配程度”这一件事。但实际业务里相关性的定义往往要复杂得多。比如电商搜索用户搜“手机”你希望销量高的排前面新闻搜索你希望时间近的加权招聘搜索你希望匹配度高的同时薪资靠谱的排前面。这些都是BM25管不了的需要引入function_score来扩展打分逻辑。3.1 function_score的结构和score_modefunction_score的精髓是把“基础查询分”和“额外函数分”组合起来实现对最终分数的干预。整体结构是{ query: { function_score: { query: { match: { title: 手机 } }, functions: [ { filter: { term: { brand: apple } }, weight: 3 }, { field_value_factor: { field: sales, factor: 0.1 } } ], score_mode: multiply, boost_mode: sum } } }这里有两个关键参数需要理解score_mode表示“多个函数分之间怎么组合”。可选值有multiply相乘、sum相加、avg取平均、first取第一个等。默认是multiply但实际运用中sum更可控。为什么如果函数值是0multiply会把整个分数变成0很容易出问题而sum即使某个函数为0顶多是不加分不会把基础分清零。boost_mode表示“基础查询分和函数分之间怎么组合”。可选值有multiply、sum、replace等。默认是multiply我建议用sum因为multiply会让基础分很低但函数分很高的文档占优势时机上容易出现“内容不相关但因为销量高排最前”的荒诞结果。3.2 一个完整的电商搜索加权实战我做一个模拟的电商场景来说明。索引里有商品标题、描述、价格、销量、上架时间等字段需求是用户搜索时文本匹配为主的排序逻辑要保留但销量高的商品要适当加权新上架的也要有点优势。先写基础查询用bool把match和filter组合起来{ query: { bool: { must: [{ match: { title: 手机 } }], filter: [{ term: { status: on_sale } }] } } }这个查询只考虑文本匹配销量和时间的权重完全没加进来。接着用function_score改造{ query: { function_score: { query: { bool: { must: [{ match: { title: 手机 } }], filter: [{ term: { status: on_sale } }] } }, functions: [ { field_value_factor: { field: sales, factor: 0.05, modifier: log1p } }, { gauss: { create_time: { origin: 2024-01-01, scale: 30d, decay: 0.5 } } } ], score_mode: sum, boost_mode: sum } } }field_value_factor的作用是让销量字段参与打分。直接拿原始销量值乘factor会出问题——销量从100到1000的差距太大会导致分数被销量主导文本匹配形同虚设。所以这里用了modifier为log1p把销量值取对数后再乘factor这样销量差距的影响就被压缩了比如销量10和销量1000求log后是2.4和6.9差距就小多了。gauss是衰减函数的一种它让离指定时间点越近的文档加分越多越久远的加分越少。origin是基准时间scale是衰减的范围decay是衰减到一半时对应的比例。这个组合在新闻、内容平台、活动商品排序里很常用。这里还有一个小细节functions里的每个function都可以带自己的filter表示“只有满足这个过滤条件的文档才应用这个函数分”。比如想只对某个品牌加权可以给field_value_factor加一个term过滤。不写filter的话函数作用在所有文档上。3.3 script_score能做但别轻易用有时候前面的函数组合都满足不了业务需求就得用script_score它允许你写一段Painless脚本直接自定义打分逻辑。比如某个业务要求“标题命中加分10描述命中加分5销量乘以0.1”这个逻辑用函数组合很难表达但script_score几行就能搞定。我对script_score的态度是能不用就不用实在要用也得克制。原因有三性能差脚本运行在JVM里比内置函数慢一个数量级调优难脚本逻辑越复杂排查问题越费劲风险高一旦脚本写得有问题轻则拖慢查询重则OOM。如果确实要用一定要把脚本编译和缓存机制搞明白。Painless脚本默认会被编译成字节码然后缓存如果每次查询都动态拼接脚本内容比如把外部变量直接拼进脚本字符串缓存就没法命中性能直接爆炸。正确做法是用params传参让脚本源码保持一致这样编译结果能被反复使用。4. 相关性问题的“翻车现场”常见问题与排查技巧这块是我最想写的部分。因为相关性调优最难的从来不是理论而是遇到问题怎么定位。我把自己在项目里真正踩过的坑还有帮别人排查时见过的典型问题整理成一段段实录。4.1 为什么加了filter排序反而变了这个前面提过但值得单独拎出来再说一次因为它太常见了。有个兄弟跑过来跟我说“我在查询里加了一个status1的filter结果排序结果变了filter不是说好不参与打分的吗”问题不在于filter参与了打分而在于filter改变了整个查询的文档集合。ES打分的协调因子和部分归一化逻辑跟命中的文档数量、查询上下文有关系。当filter把大部分文档都过滤掉后剩下这批文档之间的打分基准就变了相对排名自然会变。这不是bug。如果业务上要求“无论怎么过滤排序都不变”唯一能做到的只有一种方式在查询前用顶层数据结构固定好排序规则。但现实中这种需求极少因为过滤本身就代表着搜索意图的收窄排序跟着变是正常的。4.2 为什么同样的词分数忽高忽低典型场景是在一个索引上反复执行同一个match查询每次分数都不一样。很多人第一反应是ES出bug了实际上最可能的原因是索引分片的影响。ES在计算IDF逆文档频率时是基于分片级别的统计而不是全局索引的统计。如果一个索引有10个分片每个分片包含的文档数不一样那么同一个词在不同分片上的IDF值就可能不同。一个文档被路由到哪个分片、搜索时请求分发到哪些分片都会影响最终分数。这个问题在ES 5.x之前挺严重后来通过DFS分布式词频搜索机制做了改进但默认并不开启。如果业务特别在意全局词频的准确性可以在查询时设置{ query: { match: { title: 手机 } }, search_type: dfs_query_then_fetch }不过这个操作有性能代价全局扫描词频会拖慢查询。我的建议是对排序精度要求很高的项目可以在小规模索引上用大规模索引优先考虑业务层面接受“分片级词频”这个小误差。4.3 学会用explain API看分数构成遇到任何排序异常我的第一反应永远是先跑explain看分数到底是怎么构成的。这一步能节省大量排查时间。explain的用法很简单在查询URL加上explaintrueGET /product/_explain/doc_id { query: { match: { title: 手机 } } }返回结果里会详细列出BM25算出来的词频得分、逆向文档频率得分、字段长度归一化得分以及boost和协调因子等各项的系数一层层往下展开。多数时候看到分数构成的那一刻问题原因就浮出水面了。比如某次排查我发现一个本来应该排在很前面的商品因为它的描述字段特别长被字段长度归一化狠狠扣了一截分导致排名靠后。这个原因光看分数表面是根本看不出来的只有explain能定位到。explain还有个不起眼但很有用的功能如果你给某个字段单独设置了boostexplain里会清晰展示boost的加成路径。这能帮你验证boost设置有没有真的作用到最终分数上。4.4 几个容易被忽略的坑说几个我在实际项目里反复见到的坑写出来给大家提个醒。第一个是索引的analyzer不一致。同一个字段写入时用的分词器和查询时用的分词器不一样会导致部分文档匹配不上或者匹配异常。常见场景是文字字段在写入时用了IK分词器查询时却走了standard分词器中文被拆成一个一个字相关性自然一塌糊涂。排查方法是查看索引的mapping确认search_analyzer和analyzer的配置。第二个是boost设得太狠。很多人在match查询里给某个字段加boost比如title的boost10觉得这样标题命中的文档会排前面。但boost是乘法因子直接放大基础分数如果boost太高其他字段再匹配也没用整个排序被单个字段绑架。我的经验是boost控制在1-3之间绝对值大了基本都是副作用。第三个是字段类型选错。有些字段如果是按keyword存的match查询默认对它做不了分词匹配只有term能精确命中。如果业务上要求对这个字段做全文搜索建索引时就得用text类型并配合必要的分词器而不是用keyword将就。第四个是对空值字段打分的影响。field_value_factor如果在某些文档上对应字段为空会报错或返回空值影响整个查询。常见做法是在mapping里设置null_value或者在函数里对空值做兜底处理。还有一个实战里非常容易踩的问题如果同一个字段要同时支持精确匹配和全文搜索需要在mapping里定义成fields子字段一个走keyword一个走text而不是在查询时拐弯抹角去塞。5. 关于相关性调优我的一些个人体会做搜索相关性的优化时间久了会发现一个道理没有万能的排序公式只有不断贴近业务诉求的取舍。你要先想清楚业务上最看重什么是新鲜度、热度、价格、还是纯文本匹配度然后围绕这个目标去组合查询和函数而不是一开始就想着“有没有一个完美的模板可以抄”。我通常的建议是先用基础match跑通再逐步叠加filter、should、function_score每次只改一个变量观察排序变化。改查询和调权重最忌讳一次动多个地方出了问题根本不知道是哪一步引起的。另外相关性的排查和优化是个持续过程上线之后也要收集真实用户行为数据比如点击率、转化率来验证排序策略是否合理。性能调优的时候也别忘记explain API和profile API这两个工具前者看分数怎么算出来的后者看查询慢在哪一步两个配合用基本能覆盖九成的排序问题排查场景。最后再分享一个小技巧如果你的搜索场景比较单一比如就是一个商品标题加描述的搜索可以考虑把排序结果缓存起来配合合理的索引刷新频率能大幅减少重复计算对性能的消耗。搜索相关性这块技术和业务的边界如果拿捏得好做出来的搜索体验会非常自然地融入产品用户不一定说得出来为什么好用但就是觉得“这个搜索挺懂我”。能做到这一步说明你的排序策略真的立住了。