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.

Xiaolin Talks Programming
Xiaolin Talks Programming
Xiaolin Talks Programming
Spring Boot + OpenSearch: Building Hybrid Search with Full-Text, Vector Recall & RRF Ranking

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.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Spring BootVector SearchBM25OpenSearchMulti-tenancyCDCHybrid Searchk-NNRRFISM
Xiaolin Talks Programming
Written by

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.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.