
现在做企业级搜索,光靠关键字匹配早就行不通了,用户要的是语义理解。OpenSearch 作为 ES 的开源分支,凭着 k-NN 向量检索和免费的安全插件,成了很多公司重构搜索底层的首选。今天不扯虚的,直接聊聊怎么用 Spring Boot 落地一套支持全文检索、向量召回和混合排序的搜索系统,顺便把踩过的坑和调优经验盘一盘。
1. 为什么选 OpenSearch 而不是 ES?
自从 Elastic 改了许可证,AWS 搞出了 OpenSearch。说实话,底层搜索能力两家基本没差,但企业级特性上 OpenSearch 确实更实在。
最明显的是安全插件。ES 的基础安全免费,但 RBAC、字段级权限这些高级功能得买白金订阅;OpenSearch 直接把这些全免费内置了。另外,SQL 支持方面,ES 得依赖 X-Pack 或者第三方,OpenSearch 原生就带了 SQL 和 PPL 插件。告警监控也是,ES 的 Watcher 是收费的,OpenSearch 的 Alerting 插件直接白嫖。
说白了,如果你需要深度定制权限、想用 SQL 查数据,或者不想天天被法务追着问商业授权风险,选 OpenSearch 准没错。
2. Spring Boot 集成与连接池避坑
在 Spring Boot 里接 OpenSearch,官方推荐的 opensearch-java 客户端是基于 Apache HttpClient 5 的,支持异步和连接池。
这里有个大坑:企业级应用必须手动配连接池。默认配置在高并发下很容易把 TCP 端口耗尽,到时候半夜被报警叫醒就是常态了。
2.1 Maven 依赖
xml
<dependency>
<groupId>org.opensearch.client</groupId>
<artifactId>opensearch-java</artifactId>
<version>2.9.0</version>
</dependency>
注:opensearch-java 已经传递依赖了 opensearch-rest-client,通常不需要单独引入。
2.2 连接池与客户端配置
java
@Configuration
public class OpenSearchConfig {
@Value("${opensearch.uris}")
private String[] uris;
@Value("${opensearch.username}")
private String username;
@Value("${opensearch.password}")
private String password;
@Bean
public OpenSearchClient openSearchClient() {
// 配置 Apache HttpClient 5 连接池
ApacheHttpClient5Transport transport = ApacheHttpClient5TransportBuilder.builder(httpHosts())
.setHttpClientConfigCallback(httpClientBuilder -> {
httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider());
// 核心连接池参数调优
PoolingAsyncClientConnectionManager connectionManager =
PoolingAsyncClientConnectionManagerBuilder.create()
.setMaxConnTotal(200) // 最大连接数
.setMaxConnPerRoute(100) // 每个路由(节点)最大连接数
.build();
httpClientBuilder.setConnectionManager(connectionManager);
// 超时设置
RequestConfig requestConfig = RequestConfig.custom()
.setConnectTimeout(Timeout.ofSeconds(5))
.setResponseTimeout(Timeout.ofSeconds(30))
.setConnectionRequestTimeout(Timeout.ofSeconds(5))
.build();
httpClientBuilder.setDefaultRequestConfig(requestConfig);
return httpClientBuilder;
}).build();
// 注意:这里必须把 transport 传进去,原官方文档有些地方容易漏掉
return new OpenSearchClient(transport);
}
private HttpHost[] httpHosts() {
return Arrays.stream(uris).map(HttpHost::create).toArray(HttpHost[]::new);
}
private BasicCredentialsProvider credentialsProvider() {
BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider();
credentialsProvider.setCredentials(new AuthScope(null, -1),
new UsernamePasswordCredentials(username, password.toCharArray()));
return credentialsProvider;
}
}
3. Mapping 怎么建才不给自己挖坑
3.1 混合检索 Mapping 设计
为了同时支持 BM25 全文检索和向量检索,我们需要设计包含 text 和 knn_vector 的 Mapping。
json
PUT /product_search_v1
{
"settings": {
"number_of_shards": 3,
"number_of_replicas": 1,
"index.knn": true
},
"mappings": {
"properties": {
"product_id": { "type": "keyword" },
"tenant_id": { "type": "keyword" },
"title": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart"
},
"description": { "type": "text", "analyzer": "ik_smart" },
"category": { "type": "keyword" },
"price": { "type": "float" },
"embedding": {
"type": "knn_vector",
"dimension": 768,
"method": {
"name": "hnsw",
"space_type": "l2",
"engine": "faiss",
"parameters": {
"ef_construction": 256,
"m": 48
}
}
}
}
}
}
3.2 分片与别名:别直接查物理索引
单分片大小控制在 10G 到 50G 之间就行,别搞太大,恢复起来能让你怀疑人生。中小厂搞个 3 到 5 个主分片,千万级数据完全够用了。
另外,永远、永远不要直接对物理索引发查询。用别名(Alias)做一层代理,这样以后重建索引、平滑切换的时候,业务代码一行都不用改。
json
POST /_aliases
{
"actions": [
{ "add": { "index": "product_search_v1", "alias": "product_search" } }
]
}
4. 同义词与分词:召回率的救命稻草
做电商或者垂直领域,同义词太重要了。"土豆"和"马铃薯"搜不到一块儿去,用户体验直接拉胯。在 config/analysis/ 下搞个 synonym.txt:
text
土豆,马铃薯,洋芋
手机,智能手机,移动电话
在 Index Settings 里配个 synonym filter:
json
"settings": {
"analysis": {
"analyzer": {
"ik_syno_analyzer": {
"type": "custom",
"tokenizer": "ik_max_word",
"filter": ["my_synonym_filter"]
}
},
"filter": {
"my_synonym_filter": {
"type": "synonym",
"synonyms_path": "analysis/synonym.txt",
"updateable": true
}
}
}
}
注:把 updateable 设为 true,这样热更新字典的时候不用重启索引,仅限 search 阶段生效,很实用。
4.1 高亮控制:别让大 Payload 拖垮网络
高亮这块,一定要限制 fragmentSize 和 numberOfFragments。我之前见过没限制高亮片段大小的,返回的 JSON 大得离谱,直接把网关给撑爆了。
java
SearchRequest searchRequest = SearchRequest.of(s -> s
.index("product_search")
.query(q -> q.match(m -> m.field("title").query("智能手机")))
.highlight(h -> h
.fields("title", hf -> hf
.fragmentSize(100)
.numberOfFragments(3)
.preTags("<em>")
.postTags("</em>")
)
)
);
5. 向量召回:k-NN 与 Embedding
Java 端一般不自己算向量,都是调外部大模型(比如 BGE、M3E)拿 768 维的浮点数组。
java
public float[] generateEmbedding(String text) {
// 调用外部模型服务,返回 768 维浮点数组
return embeddingClient.embed(text);
}
OpenSearch 的 k-NN 插件底层用的是 HNSW 算法。查询的时候有个 ef_search 参数,这玩意儿就是用来平衡精度和性能的。
json
GET /product_search/_search
{
"query": {
"knn": {
"field": "embedding",
"query_vector": [0.123, 0.456, 0.789],
"k": 10,
"ef_search": 100
}
}
}
实战经验:写入时的 ef_construction 决定了图的质量,越大越准但写得越慢;查询时的 ef_search 决定了搜索广度,调大了准但慢。建议先给个默认值,线上跑起来后再根据 RT 慢慢微调。
6. 混合排序:BM25 加上向量,再揉进 RRF
纯向量检索有个毛病,遇到特定型号或者专有名词容易抓瞎;纯 BM25 又不懂语义。所以混合检索(Hybrid Search)现在是标配。
最大的痛点是分数尺度不一样。BM25 的分数一般在 0 到 20 之间,向量相似度(比如 L2 距离)完全是另一套尺度,直接相加肯定翻车。
OpenSearch 2.10 之后原生支持了 hybrid 查询,配合 RRF(Reciprocal Rank Fusion)算法就很好使。RRF 不看绝对分数,只看排名,公式大概是 score = 1 / (k + rank)。这就完美解决了分数融合的问题。
json
GET /product_search/_search
{
"query": {
"hybrid": {
"queries": [
{
"match": {
"title": { "query": "降噪 耳机" }
}
},
{
"knn": {
"field": "embedding",
"query_vector": [0.12, 0.89, 0.33],
"k": 50
}
}
]
}
},
"search_pipeline": "hybrid-search-pipeline"
}
配置 RRF 搜索管道:
json
PUT /_search/pipeline/hybrid-search-pipeline
{
"description": "Post processor for hybrid search",
"phase_results_processors": [
{
"normalization-processor": {
"normalization": { "technique": "min_max" },
"combination": {
"technique": "rrf",
"parameters": { "rank_constant": 60 }
}
}
}
]
}
通过 RRF,系统既保住了关键字的精准命中,又兼顾了语义的模糊匹配,召回率直接上一个台阶。
7. 数据同步:CDC 实时流与全量 Bulk 调优
业务数据基本都在 MySQL 里。现在主流的玩法是 Canal 或 Debezium 抓 binlog 扔进 Kafka,再用 Data Prepper 消费写入 OpenSearch。Data Prepper 是官方搞的,比 Logstash 轻量且性能好得多。
如果是历史数据全量迁移,一定要做"写入降级"。先把副本数调成 0,刷新间隔改成 -1,等数据导完了再改回来。
json
PUT /product_search_v1/_settings
{
"index.number_of_replicas": 0,
"index.refresh_interval": "-1"
}
Bulk 批次大小也别瞎设,Java 客户端里单次请求塞个 5000 到 10000 个文档,或者 Payload 控制在 5M 到 10M,跑起来最顺滑。
8. 多租户与权限:DLS 和 FLS 用起来
做 SaaS 系统,租户数据隔离是底线。OpenSearch 的安全插件支持文档级安全(DLS)。在角色映射里加个过滤条件:
json
PUT /_plugins/_security/api/roles/tenant_user_role
{
"cluster_permissions": [],
"index_permissions": [{
"index_patterns": ["product_search*"],
"dls": "{\"term\": {\"tenant_id\": \"${user.tenant_id}\"}}",
"allowed_actions": ["read", "search"]
}]
}
这个 ${user.tenant_id} 变量会从用户的 JWT 或 LDAP 里动态解析。这样租户 A 绝对搜不到租户 B 的数据。
至于敏感字段,比如手机号、成本价,用字段级安全(FLS)直接隐藏或者做掩码脱敏,省得在代码里到处写脱敏逻辑。
json
"fls": ["product_id", "title", "price", "~cost_price"],
"masked_fields": ["user_phone::/(\\d{3})\\d{4}(\\d{4})/::$1****$2/"]
9. 查询调优:Filter 缓存与慢查询排查
混合检索里,像 tenant_id、分类、价格范围这种不需要算相关性得分的条件,一定要扔到 filter 上下文里。Filter 不算 _score,而且结果会缓存到 Node Query Cache 里,重复查询直接起飞。
json
"query": {
"bool": {
"filter": [
{ "term": { "tenant_id": "T001" } },
{ "range": { "price": { "lte": 1000 } } }
],
"should": [
{ "match": { "title": "耳机" } }
]
}
}
遇到查询突然变慢,别瞎猜,直接上 _search?profile=true。重点看 query 阶段的耗时。如果是 knn 慢,考虑调小 ef_search;如果是 match 慢,看看是不是命中了停用词或者低选择性的词,该加 filter 就加。
10. 集群运维:节点分离与 ISM 生命周期
生产环境千万别把所有角色揉在一个节点上。Master 节点搞 3 个,配置低点(2C4G)就行,专门管集群状态;Data 节点配置拉满(16C64G+),挂 SSD;再搞几个 Ingest 节点处理复杂的数据预处理,把 Data 节点的 CPU 解放出来;最后配两个 Coordinating 节点做负载均衡和请求分发。
对于日志或者历史订单这种有明显时间衰减的数据,直接用 ISM 插件搞 Hot-Warm-Cold 架构。热节点存最近 7 天的,温节点存 30 天的并做 force_merge,冷节点存 90 天的直接设为只读,到期自动删除。
json
PUT /_plugins/_ism/policies/log_lifecycle
{
"policy": {
"description": "Log lifecycle management",
"default_state": "hot",
"states": [
{
"name": "hot",
"actions": [{ "rollover": { "min_size": "50gb", "min_doc_count": 10000000 } }],
"transitions": [{ "state_name": "warm", "conditions": { "min_index_age": "7d" } }]
},
{
"name": "warm",
"actions": [
{ "replica_count": { "number_of_replicas": 1 } },
{ "force_merge": { "max_num_segments": 1 } }
],
"transitions": [{ "state_name": "cold", "conditions": { "min_index_age": "30d" } }]
},
{
"name": "cold",
"actions": [
{ "read_only": {} },
{ "allocation": { "require": { "temp": "cold" } } }
],
"transitions": [{ "state_name": "delete", "conditions": { "min_index_age": "90d" } }]
},
{ "name": "delete", "actions": [{ "delete": {} }] }
]
}
}
如果是核心业务,异地容灾不能少,OpenSearch 原生支持 CCR(跨集群复制)。主集群写,异步复制到只读的备集群,主集群挂了直接切备集群,RTO 能控制在分钟级。
搞企业级搜索,三分靠检索算法,七分靠工程落地。Mapping 怎么建、连接池怎么配、多租户怎么隔离、生命周期怎么管理,这些脏活累活才是决定系统能不能扛住高并发的关键。把上面这些坑避开,你的搜索系统基本就能稳如老狗了。
🎁 福利时间
如果你正在备战面试或者想要学习其他知识,给大家推荐一个宝藏知识库,作者整理了一些列 Java 程序员需要掌握的核心知识,有需要的自取不谢。
知识库地址:https://farerboy.com/
