ElasticSearch(三):查询、聚合与相关性
ElasticSearch(三):查询、聚合与相关性
导语:写入对了还不够,查询写法错了会直接把集群打挂。本篇讲透 Query Then Fetch 两阶段、query 与 filter 的本质差异、
term/match系列的选择陷阱、深翻页的三种方案,以及聚合的内存风险、熔断器与相关性调优,共 13 题。
一、查询执行与分页
1. 详细描述 ES 的搜索过程(Query Then Fetch)
答: ES 的搜索是两阶段的,设计目标是避免把完整文档在网络中来回搬。
Query 阶段(只传 ID,定位数据):
① 协调节点把查询【广播】到涉及的所有分片(每个分片在「主分片或其副本」中轮询选一个)
② 每个分片在【本地】执行查询:
· 用本分片的倒排索引找到匹配的 docId
· 用本地优先队列(大小为 from + size)按排序值/分数取 Top N
· 只返回给协调节点:【docId + 排序值 + score】(不含文档内容)
③ 协调节点合并所有分片结果,做【全局归并排序】,定位出真正需要返回的那 from+size 条Fetch 阶段(按 ID 取回文档):
④ 协调节点向持有这些文档的分片发起【multi-get】请求,取回 _source
(顺带做富化:高亮、脚本字段、docvalue 字段)
⑤ 汇总组装后返回客户端为什么分两阶段:如果 Query 阶段就传输完整文档,那么「10 个分片各返回 10000 条文档」会产生十倍于必要量的网络传输。先只比 ID 与排序值,能大幅省带宽与内存。
三个容易被追问的细节:
| 细节 | 说明 |
|---|---|
| 打分是「分片本地」的 | 每个分片只知道自己分片内的文档频率(DF),所以分片间 _score 不可直接比较。要更准就用 DFS Query Then Fetch(先收集全局词频再打分),代价是多一轮通信、更慢 |
preference 参数 | 可指定分片偏好(_local 优先本节点、_only_nodes:xxx、或自定义字符串)。用固定字符串能让同一用户的查询总落在同一批分片上,提升缓存命中 |
| 搜索的可扩展性 | 查询阶段各分片并行,扩展性好;但结果合并是协调节点的单点,分片越多、合并开销越大(这也是「分片别太多」的原因) |
2. query context 与 filter context 有什么区别?
答: 这是 ES 查询里性价比最高的一个知识点——理解它能让查询快好几倍。
| 维度 | query context | filter context |
|---|---|---|
| 回答的问题 | 「有多匹配」 | 「是否匹配」 |
是否计算 _score | 计算(BM25) | 不计算(分数恒为 0,最终 _score 为 0 或由外层决定) |
| 是否可缓存 | 不缓存分数 | 结果可缓存(位图结果集,可跨请求复用) |
| 性能 | 相对慢 | 更快(跳过打分 + 可缓存) |
| 写法 | bool.must、bool.should、match 等 | bool.filter、constant_score、filter 聚合 |
为什么要区分:打分开销不小(需要读词频、算 IDF、做长度归一化);而绝大多数业务条件(状态、时间范围、类目 ID、是否删除)根本不需要相关性,只要"筛出来"。用 filter 就跳过了算分,并且能吃到 request cache(索引级、默认开启)。
用法示例:
{
"query": {
"bool": {
"must": [ { "match": { "title": "elasticsearch 教程" } } ], // 需要相关度 -> query
"filter": [ // 只是筛选 -> filter
{ "term": { "status": "published" } },
{ "range": { "publish_date": { "gte": "2026-01-01" } } }
]
}
}
}一个关键推论:bool.filter 中的子查询不参与打分,所以把条件从 must 挪到 filter 不改变命中文档集合,但会改变 _score——如果业务按相关性排序,这个改动会直接影响排序结果。这也是「为什么我把条件改成 filter 后排序变了」的答案。
实践口诀:需要排序的相关性条件放 query,其余全部放 filter。
3. bool 查询的 must、should、must_not、filter 有什么区别?
答:
| 子句 | 是否必须匹配 | 是否算分 | 执行上下文 | 说明 |
|---|---|---|---|---|
must | ✅ 必须 | ✅ 算分 | query | 多个 must 之间是 AND,分数累加 |
filter | ✅ 必须 | ❌ 不算分 | filter | 与 must 命中的集合相同,但更快、可缓存 |
should | ⚠️ 视情况 | ✅ 算分 | query | 默认是 OR 且"加分项";命中越多分越高 |
must_not | ❌ 必须不匹配 | ❌ 不算分 | filter | 排除条件,不参与打分 |
should 的默认行为(最容易答错的一点):
- 如果
bool里存在must或filter→should默认是可选的,只起"加分"作用(minimum_should_match默认为 0); - 如果
bool里没有must/filter(只有should)→should默认为至少命中一个(minimum_should_match默认为 1); - 想强制"至少命中 N 个"就显式写
minimum_should_match: 2或百分比"75%"。
打分与权重控制:
boost:给某个子句加权({"match": {"title": {"query": "...", "boost": 2}}});注意 boost 是乘性的,值域建议 1~10;constant_score:把一个 filter 包装成固定分数(默认 1.0),常用于"只需要按固定权重排序"的场景;dis_max/best_fields:多字段查询时避免"分数累加导致字段多的文档占优",取最匹配字段的分数(或用tie_breaker混入其他字段分数);function_score:在 BM25 之上叠加业务权重(销量、时间衰减、距离、随机性),是做"业务排序"的主力(见第 5 题)。
4. 深翻页有什么问题?怎么解决?
答: from + size 的深翻页在 ES 中是一个结构性性能问题。
问题在哪:
想取第 10000~10010 条(from=10000, size=10):
每个分片都要返回前 10010 条(docId + 排序值)给协调节点
协调节点再全局排序 10010 × 分片数 条,最后丢掉前 10000 条- 开销随
from线性增长,from越大越慢、越吃内存与网络; - ES 有硬限制:
index.max_result_window默认 10000,超过直接报错Result window is too large。
三种解决方案(按场景选):
| 方案 | 机制 | 适合 | 限制 |
|---|---|---|---|
search_after | 用上一页最后一条的排序值作为游标,只取"排在它之后"的数据 | 实时无限下拉(App 信息流) | 不能跳页(只能一页页往后);必须有确定性排序(推荐用 _shard_doc(配合 PIT)或 _id 做 tiebreaker,否则排序值相同会导致数据重复/丢失) |
scroll | 创建快照上下文,一次查出大量数据并缓存,后续按 scroll_id 分批取 | 离线全量导出 | 有状态(占资源,必须用 CLEAR_SCROLL 释放);快照期间新写入不可见;不实时 |
PIT + search_after(推荐) | Point In Time 提供一致性时间点快照,配合 search_after 做深分页 | 需要一致视图的深分页 / 大数据导出 | 需要手动 close_point_in_time 释放;7.10+ 才有 |
scroll 与 search_after/PIT 的核心区别:
scroll是"抓取快照":适合一次性遍历全量,资源占用高、不能反映最新数据,官方也建议 7.10 后用 PIT +search_after替代 scroll 做深分页;search_after是"游标分页":无状态(除 PIT 外),实时性好,但不支持随机跳页。
工程上的做法:业务层限制可翻页深度(如只允许翻到 500 条),再深就要求用户加筛选条件缩小范围——这是最省事也最有效的方案;能不用深分页就不用。
5. 如何控制相关性打分(boost / multi_match / function_score)?
答: 纯 BM25 只能回答"文本有多匹配",业务排序往往需要叠加其他因素。三层递进:
1)boost / dis_max:调整字段与子句权重
- 给字段加权:标题命中比正文命中更重要 → 给
title加boost: 3; - 多字段查询用
multi_match的best_fields(取最佳字段,默认)或most_fields(多字段分累加):best_fields适合「不同字段是同一份内容的不同表达」(如title与title.standard);most_fields适合「同一实体在不同字段有不同信息」(如title+tags+author);
- 用
dis_max+tie_breaker避免「字段拼接后再匹配」造成的分数失真。
2)function_score:叠加业务权重(最常用的实战手段)
{
"query": {
"function_score": {
"query": { "match": { "title": "耳机" } },
"functions": [
{ "field_value_factor": { "field": "sales", "factor": 0.5, "modifier": "log1p", "missing": 0 } },
{ "gauss": { "create_time": { "origin": "now", "scale": "30d", "decay": 0.5 } } }
],
"score_mode": "sum",
"boost_mode": "multiply"
}
}
}field_value_factor:按销量/热度加权(务必用log1p/sqrt等 modifier 做压缩,否则销量会碾压相关性);- 衰减函数(
gauss/linear/exp):越新/越近/越近期的数据得分越高,是做「时间衰减」的标准做法; random_score:做「随机推荐/加权抽样」;script_score:完全自定义打分(灵活但性能差,慎用);score_mode(多个函数之间如何合并)与boost_mode(与原始 BM25 分如何融合)是调参的两个关键旋钮。
3)更进阶:向量检索做语义召回 + 混合排序
当"同义词/近义表达"导致 BM25 召回不足时,引入向量检索(kNN)做语义召回,并用 RRF(Reciprocal Rank Fusion)或加权把 BM25 与 kNN 的结果融合(见第 13 题)。
一句话:BM25 管"文本相关性",
function_score管"业务相关性",向量检索管"语义相关性"。 面试能把这三层分开说,深度就够了。
二、查询写法与陷阱
6. match、term、terms、match_phrase、wildcard 有什么区别?
答: 关键分界是「要不要分词」——这决定了它能不能用在你那个字段上。
| 查询 | 是否分词 | 用在哪 | 语义 |
|---|---|---|---|
match | ✅ 会用字段的 search_analyzer 分词 | text | 全文检索主力;多个词默认 OR,用 operator: and 或 minimum_should_match 收紧 |
match_phrase | ✅ 分词 + 要求词项位置相邻/有序 | text | 短语匹配("搜索引擎 原理"必须连在一起);slop 允许中间夹词 |
term | ❌ 不分词,整体作为一个词项匹配 | keyword、数值、日期、布尔 | 精确匹配(区分大小写) |
terms | ❌ 不分词 | keyword、数值 | 多值 OR(status in (...));列表过大会触发 too_many_clauses |
range | — | 数值、日期、keyword | 范围(gt/gte/lt/lte、format、relation) |
exists | — | 任意 | 字段存在且非 null |
wildcard / prefix / regexp | ❌ | keyword | 词项级通配,性能差(要扫词典/倒排),见下方警告 |
ids | — | 任意 | 按 _id 批量取文档 |
multi_match | ✅ | 多字段 | 一次在多字段上匹配(见第 5 题) |
query_string / simple_query_string | ✅ | — | 支持 Lucene 查询语法(AND/OR/*/字段前缀),用户输入直接透传会有语法注入与性能风险,慎用 |
关于 wildcard / prefix 的性能警告(高频追问):
- 它们在词项层面做匹配,
*abc(前置通配)或.*(regexp)会扫描整个词典,词典越大越慢,能吃掉整个集群的 CPU; - 替代方案:
- 做前缀搜索(自动补全)→ 索引期用
edge_ngram分词,或使用search_as_you_type字段类型,把"前缀匹配"变成"倒排命中"; - 做后缀/任意位置模糊 → 用
ngram分词器; - 做拼写容错 → 用
fuzzy(基于编辑距离,也只适合短词); - 兜底方案 → 把这类需求交给外部检索系统(如借助 ES 的
completionsuggester 做自动补全)。
- 做前缀搜索(自动补全)→ 索引期用
7. 为什么 term 查询查不到分过词的字段?
答: 因为 text 字段在写入时已经被 Analyzer 切分并归一化了,倒排索引里存的不是原文,而是分词后的词项。
一个具体例子:
文档:{ "title": "ElasticSearch 实战" }
standard 分词器的处理:切词 + 转小写 -> ["elasticsearch", "实战"]
倒排索引里存的是:elasticsearch、实战 (注意不是 "ElasticSearch")
❌ { "term": { "title": "ElasticSearch" } } -> 查不到(大小写不一致,且词项是切分后的)
❌ { "term": { "title": "ElasticSearch 实战" } } -> 更查不到(整串不是一个词项)
✅ { "match": { "title": "ElasticSearch" } } -> 能查到(走 search_analyzer 同样归一化为 elasticsearch)
✅ { "term": { "title.keyword": "ElasticSearch 实战" } } -> 能查到(keyword 子字段不分词,整串匹配)三条结论:
term是"词项精确匹配":它绕过分析器,所以你必须传入和倒排索引里完全一致的词项(已小写、已分词、已做词干提取);text字段要搜就用match,想让term生效就用它的.keyword子字段(title.keyword);keyword字段的term是真正的大小写敏感精确匹配("Paid" ≠ "paid")——如果业务上需要忽略大小写,要在写入时归一化(如统一转小写再存),因为 keyword 不会替你转。
高频陷阱题:「我用
term查status: "PAID"查不到,但数据确实是 PAID」——先确认这个字段是text还是keyword;如果是text,说明写入时被小写化成paid了,term传大写自然查不到。
8. 聚合有哪些类型?terms 聚合为什么容易内存爆炸?
答: 聚合分三大类:
| 类别 | 作用 | 常见聚合 |
|---|---|---|
| Bucket(分桶) | 按条件把文档分组 | terms、range、date_histogram、histogram、filters、nested、composite |
| Metric(指标) | 对每组做数值计算 | avg、sum、min、max、stats、percentiles、cardinality(近似去重) |
| Pipeline(管道) | 对聚合结果再做计算 | derivative(求导/环比)、cumulative_sum(累计)、bucket_script(自定义公式) |
聚合依赖正排索引:聚合与排序都走 doc_values,所以 text 字段默认不能聚合(除非开 fielddata,会吃堆内存,见《ElasticSearch(一)》第 7 题)。
terms 聚合为什么容易内存爆炸(核心问题):
分布式下 terms 聚合的流程:
① 每个分片在本地算出自己的 Top N 桶
② 所有分片的桶【汇聚到协调节点】再做一次全局归并
③ 归并过程要把桶放在协调节点的内存里三个放大内存的因素:
- 高基数字段:对
user_id、order_id这种几乎不重复的字段做terms聚合,会产生海量桶(几十万、上百万),协调节点内存直接被打爆(Data too large/circuit_breaking_exception); size与shard_size:每个分片实际要返回shard_size个桶(默认约size * 1.5 + 10)。如果你想"取全部桶",就必须把size设得很大,此时每分片返回量随之暴涨;- 嵌套聚合:
terms里再套terms再套date_histogram,桶数量是乘积级增长。
另一个必须知道的现象:terms 聚合的结果可能"不准"
- 因为每个分片只返回自己的 Top N,全局真正的 Top N 可能被漏掉(某个词在 A 分片排第 11、在 B 分片排第 11,各分片都只返回前 10,它在全局其实是第 1 名);
- 缓解:调大
shard_size(精度换内存)、降低分片数、或改用composite聚合。
实践建议:
| 需求 | 方案 |
|---|---|
| 只要去重计数(UV、订单数) | 用 cardinality 聚合(HyperLogLog++ 近似,内存可控,precision_threshold 默认 3000、最大 40000) |
| 必须精确去重 | 只能 terms(有内存风险,需严格限流)或交给外部系统(ClickHouse / 离线计算) |
| 桶数很多且要全部取出 | 用 composite 聚合做游标式分页遍历(类似 search_after),而不是把 size 设成百万 |
| 降低内存 | 先用 filter/时间范围把数据量缩到最小,再做聚合;避免高基数字段直接 terms |
| 避免聚合压垮节点 | 用专用 coordinating 节点承接重聚合(见《ElasticSearch(四)》) |
9. 什么是熔断器(circuit breaker)?too_many_clauses 怎么解决?
答: 两者都是保护机制,但保护的维度不同:熔断器保护内存,max_clause_count 保护查询复杂度。
熔断器(Circuit Breaker):防止单个请求把节点堆内存吃爆
ES 在估算内存开销超过限额时直接拒绝请求(circuit_breaking_exception),而不是等真的 OOM——因为节点级 OOM 会导致整个节点掉线,比单个请求失败严重得多。
各层级默认限额(ES 8.x,务必按新版记忆):
| 断路器 | 设置项 | 默认上限 |
|---|---|---|
| Parent(父级总闸) | indices.breaker.total.limit | use_real_memory 为 true(默认)时是 95% 堆;否则 70% |
| Field data | indices.breaker.fielddata.limit | 40% 堆(fielddata 是最大风险源) |
| Request | indices.breaker.request.limit | 60% 堆(聚合、请求级中间结构) |
| In-flight requests | network.breaker.inflight_requests.limit | 100% 堆(实际受父级约束) |
触发后的正确做法(面试高频追问):
❌ 不要直接调大限额 —— 那只是把「请求失败」变成「节点 OOM 掉线」,问题更严重。
✅ 正确做法是降低单个请求的内存需求:
- 缩小数据范围:加时间范围/类目过滤(
filter); - 降低聚合基数:高基数字段改用
cardinality,或减小size/shard_size; - 消灭
fielddata:给text加keyword子字段,改用正排doc_values聚合; - 拆大请求为小请求(分批聚合,客户端合并);
- 确实需要大聚合时,用专用 coordinating 节点并单独放宽它的限额。
too_many_clauses / maxClauseCount is set to 4096:
- 布尔查询的子句总数超过
indices.query.bool.max_clause_count(默认 4096) 时抛错; - 典型触发场景:
terms查询的列表太长(如把 10 万个 ID 塞进一个terms)、超长query_string、循环拼should; - 解决:
- 拆分请求(每次 1000 个 ID,客户端合并结果);
- 用
termslookup(terms查询支持从另一个索引/字段动态取值,避免把巨量列表放进请求体); - 把大列表改成用
filter+ 位图缓存 / 用ids查询; - 用
bool+minimum_should_match缩小分支数; - 实在必要才调整
max_clause_count(静态设置,需重启节点),但更应优先从查询写法上解决。
三、性能优化与调试
10. 如何优化查询性能?
答: 按「影响面从大到小」分层排查:
1)查询写法(收益最大)
- 能用
filter就用filter(不算分、可缓存,见第 2 题); - 精确匹配用
keyword+term,别对text做term; - 不要用前置通配/
regexp/超大terms(会在词典层全扫描); - 用
match_phrase前先想清楚是否必要(位置校验开销更大),能用match+operator: and就别用 phrase; - 只取需要的字段:
"_source": ["a", "b"]或"_source": false+stored_fields/docvalue_fields;只做统计就"size": 0。
2)减少查询扇出(分片维度)
- 用
routing让查询只命中一个分片(见《ElasticSearch(二)》第 8 题); - 按时间滚动索引(Rollover),查询带上时间范围,天然只扫少量索引;
- 分片数不要过多(每个分片都要出力,协调节点还要合并);
- 用 别名 + 过滤缩小参与检索的索引集合。
3)缓存与分片选择
request cache(索引级index.requests.cache.enable,默认开启):缓存 filter 的结果位图,重复的过滤条件直接命中;- Lucene 段级 query cache:段不变时同一查询的倒排结果可复用;
preference:同一用户/会话固定分片,提升缓存命中率。
4)架构与存储层
| 手段 | 说明 |
|---|---|
| 冷热分层 + ILM | 热数据 SSD、冷数据自动转 warm/cold,并用 force_merge + _shrink 减少段与分片数 |
| 专用 coordinating 节点 | 把重聚合查询从 data 节点剥离,避免抢占 IO |
| filesystem cache 给足内存 | Lucene 读段文件主要靠 OS page cache,堆内存不要占太多(见《ElasticSearch(四)》) |
| 避免深翻页 | 用 search_after/PIT(见第 4 题) |
避免 script 与 script_score | 逐文档执行脚本,性能代价极高 |
5)定位工具
- 慢查询日志(
index.search.slowlog.threshold.*); profile: true(看每个子查询各阶段耗时);_cat/thread_pool/search?v(看 search 队列积压与 rejected);_nodes/hot_threads(看 CPU 热点线程)。
11. 如何调试慢查询与打分不符合预期?
答:
调试慢查询的四件工具:
| 工具 | 用法 | 作用 |
|---|---|---|
| 慢查询日志 | index.search.slowlog.threshold.query.warn/info/debug、...fetch.*(默认 -1 即关闭);输出到 *_index_search_slowlog.log | 发现哪些查询慢(生产必开,阈值常设 1s/5s) |
| Profile API | 查询里加 "profile": true | 拆解每个查询组件的耗时:rewrite、build_scorer、advance、score、next_doc——能精确指出"是哪一层慢" |
_cat/thread_pool/search?v | — | 看 search 线程池队列是否积压、rejected 是否增长(说明并发查询过载) |
_nodes/hot_threads | — | 采样热点线程栈,定位"CPU 被什么吃掉了" |
Profile API 的读法(面试加分点):
advance耗时高 → 多条件求交的开销(terms/bool子句多、倒排表跳过效率低);next_doc耗时高 → 过滤条件不够有选择性,扫了太多文档;score耗时高 → 打分太重(复杂script_score、大量should)→ 考虑挪到 filter;build_scorer耗时高 → 查询构建本身昂贵(如wildcard/regexp要构建自动机)。
调试"打分不符合预期":
- 先用
_analyze验证分词:90% 的"搜不到/相关性怪"都源于分词器与预期不一致(尤其中文); - 用
_explain(或"explain": true)看单个文档的打分明细:能看到 TF、IDF、字段长度归一化、boost 的具体数值,从而判断是"没匹配上"还是"权重不对"; - 检查是否被
filter影响:把条件从must移到filter会改变_score(见第 2 题); - 检查分片本地 IDF:分片数多、数据量小的时候分数不可比,需要更准就用 DFS Query Then Fetch;
- 检查分词是否把词的个数放大了:
match默认 OR,长 query 会把不相关的文档也抬上来 → 用operator: and或minimum_should_match: "75%"; - 检查是否被 boost/长度归一化主导:字段极短或极长的文档会被 BM25 的长度归一化显著影响 → 用
function_score的业务权重覆盖,必要时用constant_score屏蔽文本分。
12. 如何避免返回超大结果集?上亿数据的去重怎么做?
答:
为什么不能返回超大结果集:命中的所有文档都要参与协调节点的全局排序与汇总,size 一大就会在堆里堆出巨量对象(Data too large / OOM),而且网络传输也扛不住。
正确做法:
| 需求 | 方案 |
|---|---|
| 分页浏览 | search_after + PIT(见第 4 题),别用超大 size/from |
| 全量导出 | scroll + PIT,流式逐批拉取 |
| 只要聚合统计 | "size": 0(不返回文档,只回聚合),大幅省内存 |
| 只要部分字段 | _source 过滤或 stored_fields/docvalue_fields |
| 做批量任务 | _reindex / _update_by_query + slices 并行 + wait_for_completion=false 异步 |
上亿数据的去重统计:
- 首选
cardinality聚合:底层是 HyperLogLog++ 近似算法,内存占用可控(与基数无关只与精度有关),误差可通过precision_threshold(默认 3000、最大 40000)调节;- 权衡:
precision_threshold越大越准,但每个分片都要维护更大内存结构,高并发下容易触发熔断; - 只统计已存在的去重数,不能取具体元素;
- 权衡:
- 必须精确去重时:
terms聚合可以拿到精确的分组,但高基数字段会产生海量桶 → 内存爆炸(见第 8 题);只能先大幅缩小范围,或分片分批聚合后客户端合并;- 数据量到亿级建议不要在 ES 上做精确去重,把任务交给 ClickHouse / 数仓离线计算(列存 + 高效去重算子);
- 折中方案:两阶段——用
cardinality快速估算量级,再用composite聚合分批取出明细做精确校验。
一句话原则:ES 擅长"模糊检索 + 近似统计",不擅长"精确去重"与"海量明细导出"——把不擅长的活交给别的系统。
13. ES 的向量检索(kNN)与语义搜索是怎么做的?
答: 这是 ES 近两年新增的高频新考点(结合 RAG、推荐场景问得很多)。
和 BM25 的本质区别:
| BM25(词项匹配) | 向量检索 kNN(语义匹配) | |
|---|---|---|
| 原理 | 倒排索引,要求词项字面命中 | 把文本转向量,比"语义距离" |
| 优点 | 精确、可解释、成本低 | 能召回同义/近义表达("笔记本"能召回"笔记本电脑") |
| 缺点 | 同义词/口语化表达召回差 | 可能引入语义相近但业务不相干的结果;成本高 |
| 适用 | 精确关键词检索、过滤型查询 | 语义搜索、问答、推荐、以图搜图 |
ES 里的实现方式:
- 字段类型
dense_vector:把 embedding 向量(如 768/1024/1536 维)存进文档; - 查询方式:
knn查询(推荐):基于 HNSW 图索引做近似最近邻搜索,速度快;可设置num_candidates平衡精度与延迟;8.x 支持在knn里做filter预过滤;script_score+ 余弦相似度:精确暴力计算,文档多时极慢,只适合小数据量或离线;
- 语义搜索链路:
写入侧:文本 -> embedding 模型(本地/OpenAI/自建)-> dense_vector 字段
查询侧:query 文本 -> 同一个模型 -> 向量 -> knn 查询 -> Top K
融合: 与 BM25 的结果做【混合排序】混合检索(Hybrid Search)——生产推荐的姿势:
- RRF(Reciprocal Rank Fusion):
rrfretriever 可以同时合并 BM25 与 kNN 的结果(按排名倒数加权求和,不依赖分数可比性),ES 8.8+ 支持; - 加权融合:自己给两路结果分配权重再合并;
- 动机:BM25 保准确率、kNN 保召回率,混合通常显著优于单路。
工程要对成本有预期(面试官爱问):
| 成本项 | 说明 |
|---|---|
| 存储与内存 | 向量维度 × 文档数,体积远超原始文本;HNSW 索引本身还有额外开销 |
| 构建慢 | HNSW 图构建耗时耗内存,大批量写入时要注意 |
| 模型一致性 | 写入与查询必须用同一个 embedding 模型;模型升级 = 全量重建索引(配合别名切换) |
script_score 的陷阱 | 不要用它代替 knn,它是 O(N) 暴力扫描 |
其他值得知道的新特性:
semantic_text字段类型(8.15+):内建推理端点,写入文本即可自动生成并存储向量,免去自己搭 ingest pipeline;ES|QL:管道式查询语言(8.11 起引入、后续版本 GA),对聚合分析类场景比 DSL 更直观;rerank/ 多路 retriever:在检索之后接入精排模型,是 RAG 场景的标准后置环节。
下一篇:《ElasticSearch(四)》讲集群与运维——Master 选举与脑裂的版本差异、green/yellow/red 排查、分片分配与恢复调优、零停机重建索引、snapshot 备份、ELK 生态与版本演进。
