Spring Boot + OpenSearch: Building Hybrid Search with Full-Text, Vector Recall & RRF Ranking
This guide walks through building a production-ready hybrid search system using Spring Boot and OpenSearch, covering mapping design for BM25 and k-NN vector search, synonym configuration, RRF-based score fusion, connection pool tuning, multi-tenant security with DLS/FLS, CDC data sync, and cluster operations including ISM lifecycle policies.
1. Why Choose OpenSearch Over Elasticsearch
Since Elastic changed its license, AWS forked OpenSearch. Core search capabilities are nearly identical, but OpenSearch offers more enterprise-grade features for free:
Security plugin : RBAC, field-level permissions included free; Elastic requires Platinum subscription.
SQL/PPL support : Native plugins; Elastic relies on X-Pack or third-party.
Alerting : Free Alerting plugin; Elastic's Watcher is paid.
Choose OpenSearch if you need deep permission customization, SQL querying, or want to avoid commercial licensing risks.
2. Spring Boot Integration & Connection Pool Pitfalls
The official opensearch-java client (v2.9.0) uses Apache HttpClient 5 with async and pooling support. Default configuration exhausts TCP ports under high concurrency — manual pool tuning is mandatory.
2.1 Maven Dependency
<dependency>
<groupId>org.opensearch.client</groupId>
<artifactId>opensearch-java</artifactId>
<version>2.9.0</version>
</dependency>Note: opensearch-java transitively includes opensearch-rest-client ; no separate import needed.
2.2 Connection Pool & Client Configuration
@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() {
ApacheHttpClient5Transport transport = ApacheHttpClient5TransportBuilder.builder(httpHosts())
.setHttpClientConfigCallback(httpClientBuilder -> {
httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider());
// Core connection pool tuning
PoolingAsyncClientConnectionManager connectionManager =
PoolingAsyncClientConnectionManagerBuilder.create()
.setMaxConnTotal(200) // max total connections
.setMaxConnPerRoute(100) // max per route (node)
.build();
httpClientBuilder.setConnectionManager(connectionManager);
// Timeouts
RequestConfig requestConfig = RequestConfig.custom()
.setConnectTimeout(Timeout.ofSeconds(5))
.setResponseTimeout(Timeout.ofSeconds(30))
.setConnectionRequestTimeout(Timeout.ofSeconds(5))
.build();
httpClientBuilder.setDefaultRequestConfig(requestConfig);
return httpClientBuilder;
}).build();
// Must pass transport to client; official docs sometimes omit this
return new OpenSearchClient(transport);
}
private HttpHost[] httpHosts() {
return Arrays.stream(uris).map(HttpHost::create).toArray(HttpHost[]::new);
}
private BasicCredentialsProvider credentialsProvider() {
BasicCredentialsProvider provider = new BasicCredentialsProvider();
provider.setCredentials(
new AuthScope(null, -1),
new UsernamePasswordCredentials(username, password.toCharArray())
);
return provider;
}
}3. Mapping Design to Avoid Future Pain
3.1 Hybrid Search Mapping
To support both BM25 full-text and vector search, define text and knn_vector fields:
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 Shards & Aliases: Never Query Physical Index Directly
Keep single shard size between 10GB–50GB. For small-to-medium companies, 3–5 primary shards handle tens of millions of documents. Always use an alias as a proxy so reindexing and seamless switching require zero business code changes:
POST /_aliases
{
"actions": [
{ "add": { "index": "product_search_v1", "alias": "product_search" } }
]
}4. Synonyms & Tokenization: The Recall Lifeline
In e-commerce or vertical domains, synonyms are critical. Example: "potato" (土豆) and "马铃薯" must match. Place synonym.txt under config/analysis/:
土豆,马铃薯,洋芋
手机,智能手机,移动电话Configure a synonym filter in index settings:
"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
}
}
}
}Setting updateable=true allows hot dictionary reload without index restart; only affects search phase.
4.1 Highlighting Control: Prevent Oversized Payloads
Always limit fragmentSize and numberOfFragments. Unbounded highlights can produce massive JSON responses that crash gateways.
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. Vector Recall: k-NN & Embeddings
Java services typically call external models (e.g., BGE, M3E) to generate 768-dim float arrays:
public float[] generateEmbedding(String text) {
// Call external model service, return 768-dim float array
return embeddingClient.embed(text);
}OpenSearch's k-NN plugin uses HNSW. The ef_search parameter balances precision vs. latency:
GET /product_search/_search
{
"query": {
"knn": {
"field": "embedding",
"query_vector": [0.123, 0.456, 0.789],
"k": 10,
"ef_search": 100
}
}
}Practical tuning : ef_construction (index-time) controls graph quality — higher = better recall but slower writes. ef_search (query-time) controls search breadth — higher = better recall but slower queries. Start with defaults, then adjust based on observed latency.
6. Hybrid Ranking: BM25 + Vector via RRF
Pure vector search misses exact model numbers/proper nouns; pure BM25 lacks semantic understanding. Hybrid search is now standard.
Core problem: score scales differ. BM25 scores ~0–20; vector similarity (e.g., L2 distance) uses a completely different scale. Direct addition fails.
OpenSearch 2.10+ supports native hybrid query with RRF (Reciprocal Rank Fusion). RRF ignores absolute scores, uses only rank: score = 1 / (k + rank).
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"
}Configure the RRF search pipeline:
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 preserves keyword precision while adding semantic fuzzy matching, boosting recall significantly.
7. Data Sync: CDC Real-Time Stream & Bulk Tuning
Business data lives in MySQL. Mainstream pattern: Canal or Debezium captures binlog → Kafka → Data Prepper consumes and writes to OpenSearch. Data Prepper is official, lighter and faster than Logstash.
For historical full migration, apply "write downgrade": set replicas to 0, refresh_interval to -1, restore after load:
PUT /product_search_v1/_settings
{
"index.number_of_replicas": 0,
"index.refresh_interval": "-1"
}Bulk batch size: 5,000–10,000 docs per request, or 5–10 MB payload, for optimal throughput.
8. Multi-Tenancy & Permissions: DLS & FLS
SaaS systems require strict tenant isolation. OpenSearch security plugin supports Document Level Security (DLS). In role mapping, add a filter:
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"]
}]
}The ${user.tenant_id} variable resolves dynamically from user JWT or LDAP. Tenant A never sees Tenant B's data.
For sensitive fields (phone, cost price), use Field Level Security (FLS) to hide or mask, avoiding scattered desensitization logic in code:
"fls": ["product_id", "title", "price", "~cost_price"],
"masked_fields": ["user_phone::/(\\d{3})\\d{4}(\\d{4})/::$1****$2/"]9. Query Tuning: Filter Cache & Slow Query Diagnosis
In hybrid search, non-scoring conditions (tenant_id, category, price range) must go into filter context. Filters skip _score calculation and cache results in Node Query Cache for instant repeat queries.
"query": {
"bool": {
"filter": [
{ "term": { "tenant_id": "T001" } },
{ "range": { "price": { "lte": 1000 } } }
],
"should": [
{ "match": { "title": "耳机" } }
]
}
}When queries suddenly slow down, don't guess — use _search?profile=true. Focus on query phase latency. If knn is slow, reduce ef_search; if match is slow, check for stopwords or low-selectivity terms and add filters.
10. Cluster Operations: Node Separation & ISM Lifecycle
Production clusters must separate node roles:
Master nodes : 3 nodes, low spec (2C4G), manage cluster state only.
Data nodes : High spec (16C64G+), SSD storage.
Ingest nodes : Handle complex preprocessing, offload CPU from data nodes.
Coordinating nodes : 2+ nodes for load balancing and request routing.
For time-decaying data (logs, historical orders), use ISM plugin for Hot-Warm-Cold architecture:
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": {} }] }
]
}
}Hot nodes store last 7 days; warm nodes store 30 days with force_merge; cold nodes store 90 days as read-only; then auto-delete.
For mission-critical workloads, cross-cluster replication (CCR) provides geo-disaster recovery. Primary cluster writes, async replicates to read-only standby; failover achieves minute-level RTO.
Enterprise search is 30% retrieval algorithms, 70% engineering execution. Mapping design, connection pooling, tenant isolation, lifecycle management — these unglamorous tasks determine whether the system survives high concurrency. Avoid the pitfalls above and your search cluster will run rock-solid.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Xiaolin Talks Programming
Focuses on sharing original technical insights. Senior architect at a top tech company with years of experience in technical architecture and management, and extensive interview experience. Offers one-on-one technical coaching, guiding you from beginner to architecture design to technical management. Follow for free learning resources. Free one-on-one interview coaching to help you land offers quickly.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
