1. Introduction: The Vector Search Blindness Problem
Generative Artificial Intelligence has revolutionized enterprise software development. Using Retrieval-Augmented Generation (RAG), systems retrieve private documents from databases to answer complex user questions. Most early RAG applications relied completely on dense vector databases. They converted text into high-dimensional numerical vectors using embedding models.
Dense vector search matches documents based on high-level conceptual similarity. It excels when users ask conversational questions like “What are your return policies for defective items?”. However, in enterprise environments, engineers quickly encountered a serious problem. Dense vector search is frequently blind to exact technical identifiers, product SKUs, part numbers, and legal codes.
If a technician searches for part number SKU-RTX-4090-TI, a dense vector model often retrieves descriptions of general gaming computers instead of the exact component specifications. This semantic blindness causes large language models to hallucinate incorrect details. To solve this critical enterprise flaw, modern systems implement Hybrid Search. In this guide, we combine PostgreSQL 16 pgvector and sparse BM25 full-text search in Spring AI and Spring Boot 4 on Java 25.
2. Dense Vector Search vs. Sparse BM25 Keyword Search
Understanding search requires comparing dense and sparse retrieval mechanisms. Both approaches have complementary strengths and distinct failure modes.

Here is how the two paradigms compare:
- Dense Vector Search (Semantic Understanding): Uses deep learning models (such as OpenAI
text-embedding-3-smallor local Ollama models) to convert text into fixed-length floating-point arrays (e.g., 1536 dimensions). It measures mathematical distance (such as cosine similarity or Euclidean distance). Dense search understands context, synonyms, and multilingual text. However, it cannot guarantee exact string matching. - Sparse BM25 Search (Lexical Precision): Uses traditional inverted indices. It measures Term Frequency (TF) and Inverse Document Frequency (IDF). Sparse search rewards rare, exact keywords. If a document contains the exact phrase
Error 0x80070005, BM25 scores it at the very top. However, it cannot connect the word “laptop” to “notebook”.
To see how Spring AI connects to external tools, review our companion guide on Building an Enterprise MCP Server with Spring AI and our architecture for Enterprise RAG with Spring AI.
3. Why Pure Semantic Vectors Fail on Exact Identifiers
Dense embedding models compress hundreds of words into a single point in high-dimensional vector space. In this compression process, exact character sequences lose their individual identity. The embedding model focuses on overall semantic themes (such as technology, finance, or medicine).
Consider what happens when a software engineer searches for a specific Java error code: NullPointerException: line 42. To an embedding model, this string is conceptually similar to any general software debugging document. The model may rank a high-level troubleshooting guide higher than the specific incident postmortem containing the exact stack trace.
Furthermore, embedding models frequently fail on alphanumeric product serials, drug names in healthcare, and transaction hashes in banking. Relying solely on dense vectors leads to user frustration and incorrect AI generation. A production system must execute both lexical and semantic searches simultaneously.
4. The Reciprocal Rank Fusion (RRF) Algorithm
When you execute two distinct searches, you receive two different score distributions. Cosine similarity produces scores between 0.0 and 1.0. BM25 produces unbounded positive floating-point numbers based on document length and term frequency. You cannot simply average these raw scores together.

The industry standard solution is Reciprocal Rank Fusion (RRF). RRF disregards raw score magnitudes. Instead, it evaluates the relative rank position of each document across both result sets. The mathematical formula is:
$$RRF(d) = \sum_{m \in M} \frac{1}{k + r_m(d)}$$
In this formula:
- $M$ represents the set of search rankers (in our case, Dense Vector and Sparse BM25).
- $r_m(d)$ is the rank position of document $d$ in ranker $m$ (starting at 1 for the top result).
- $k$ is a smoothing constant, typically set to 60.0. The constant prevents top-ranked outliers from dominating the final score.
If a document appears in the top positions of both dense and sparse searches, its RRF score spikes dramatically. This guarantees that documents with both exact keyword matches and strong semantic relevance rise to the top.
5. PostgreSQL 16 Unified Schema and HNSW Indexing
A major advantage of using PostgreSQL 16 is avoiding multi-database synchronization headaches. Instead of running Elasticsearch for text and Pinecone for vectors, PostgreSQL handles both natively in a single relational table.

To support hybrid search, define your database schema as follows:
-- Enable pgvector extension
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE document_knowledge_base (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
metadata JSONB DEFAULT '{}',
text_search tsvector GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || content)) STORED,
embedding vector(1536) NOT NULL
);
-- 1. Hierarchical Navigable Small World (HNSW) Index for dense vectors
CREATE INDEX ON document_knowledge_base
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- 2. Generalized Inverted Index (GIN) for sparse full-text search
CREATE INDEX ON document_knowledge_base USING gin (text_search);
This unified table eliminates dual-write anomalies. When you insert or update a document, relational data, text indices, and vector graphs update in a single ACID transaction. For details on database security in PostgreSQL, see our guide on PostgreSQL Row-Level Security in Spring Boot 4.
6. Step-by-Step Implementation with Spring Boot 4
Let us implement the hybrid search engine in Spring Boot 4.0.0 and Java 25. Add the core web, validation, and testing starters to your pom.xml file.
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.tngtech.archunit</groupId>
<artifactId>archunit-junit5</artifactId>
<version>1.4.1</version>
<scope>test</scope>
</dependency>
</dependencies>
Enable Java 25 Virtual Threads in your application.yml file:
spring:
application:
name: spring-ai-pgvector-hybrid-search
threads:
virtual:
enabled: true
server:
port: 8080
management:
endpoints:
web:
exposure:
include: health,info,metrics
7. Implementing the Hybrid Search Engine
Create the domain model records. The DocumentRecord stores text content along with its floating-point embedding vector. SearchResult implements Comparable to sort documents in descending order of relevance.
package com.webcodein.workshop.ai.hybrid.model;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.List;
import java.util.Map;
public record DocumentRecord(
@NotBlank String id,
@NotBlank String title,
@NotBlank String content,
Map<String, String> metadata,
@NotNull List<Double> embedding
) {
public DocumentRecord {
if (id == null || id.isBlank()) throw new IllegalArgumentException("ID cannot be blank");
if (content == null || content.isBlank()) throw new IllegalArgumentException("Content cannot be blank");
if (embedding == null || embedding.isEmpty()) throw new IllegalArgumentException("Embedding required");
}
}
Now create HybridSearchEngine.java. This component calculates cosine similarity for dense embeddings, computes lexical term frequency for sparse keywords, and fuses the result sets using Reciprocal Rank Fusion.
package com.webcodein.workshop.ai.hybrid.search;
import com.webcodein.workshop.ai.hybrid.model.DocumentRecord;
import com.webcodein.workshop.ai.hybrid.model.SearchResult;
import org.springframework.stereotype.Component;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
@Component
public class HybridSearchEngine {
private final Map<String, DocumentRecord> store = new ConcurrentHashMap<>();
public void indexDocument(DocumentRecord document) {
store.put(document.id(), document);
}
public List<SearchResult> searchDense(List<Double> queryEmbedding, int topK) {
return store.values().stream()
.map(doc -> new SearchResult(doc.id(), doc.title(), doc.content(),
cosineSimilarity(queryEmbedding, doc.embedding()), "DENSE_VECTOR"))
.sorted()
.limit(topK)
.collect(Collectors.toList());
}
public List<SearchResult> searchKeyword(String query, int topK) {
String[] terms = query.toLowerCase().split("\\s+");
return store.values().stream()
.map(doc -> new SearchResult(doc.id(), doc.title(), doc.content(),
computeBm25Score(terms, doc.content().toLowerCase(), doc.title().toLowerCase()), "SPARSE_BM25"))
.filter(res -> res.score() > 0.0)
.sorted()
.limit(topK)
.collect(Collectors.toList());
}
public List<SearchResult> searchHybrid(String query, List<Double> queryVec, int topK, double rrfK) {
List<SearchResult> dense = searchDense(queryVec, topK * 2);
List<SearchResult> keyword = searchKeyword(query, topK * 2);
Map<String, Double> rrfScores = new HashMap<>();
Map<String, DocumentRecord> docMap = new HashMap<>();
// 1. Process Dense Vector Ranks
for (int rank = 0; rank < dense.size(); rank++) {
String id = dense.get(rank).documentId();
rrfScores.put(id, rrfScores.getOrDefault(id, 0.0) + (1.0 / (rrfK + (rank + 1))));
docMap.put(id, store.get(id));
}
// 2. Process Sparse BM25 Ranks
for (int rank = 0; rank < keyword.size(); rank++) {
String id = keyword.get(rank).documentId();
rrfScores.put(id, rrfScores.getOrDefault(id, 0.0) + (1.0 / (rrfK + (rank + 1))));
docMap.put(id, store.get(id));
}
// 3. Sort by Combined RRF Score
return rrfScores.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.limit(topK)
.map(entry -> {
DocumentRecord doc = docMap.get(entry.getKey());
return new SearchResult(doc.id(), doc.title(), doc.content(), entry.getValue(), "HYBRID_RRF");
})
.collect(Collectors.toList());
}
private double cosineSimilarity(List<Double> v1, List<Double> v2) {
double dot = 0.0, nA = 0.0, nB = 0.0;
for (int i = 0; i < v1.size(); i++) {
dot += v1.get(i) * v2.get(i);
nA += Math.pow(v1.get(i), 2);
nB += Math.pow(v2.get(i), 2);
}
return (nA == 0 || nB == 0) ? 0.0 : dot / (Math.sqrt(nA) * Math.sqrt(nB));
}
private double computeBm25Score(String[] terms, String content, String title) {
double score = 0.0;
for (String t : terms) {
if (t.isBlank()) continue;
if (title.contains(t)) score += 3.0; // Title boost
if (content.contains(t)) score += 1.0;
}
return score;
}
}
8. Document Search Service and REST Controller
The service orchestrates indexing and search execution. The REST controller exposes endpoints for dense, keyword, and hybrid RRF retrieval.
package com.webcodein.workshop.ai.hybrid.controller;
import com.webcodein.workshop.ai.hybrid.model.DocumentRecord;
import com.webcodein.workshop.ai.hybrid.model.SearchResult;
import com.webcodein.workshop.ai.hybrid.service.DocumentSearchService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/search")
public class DocumentSearchController {
private final DocumentSearchService searchService;
public DocumentSearchController(DocumentSearchService searchService) {
this.searchService = searchService;
}
@PostMapping("/hybrid")
public ResponseEntity<List<SearchResult>> hybridSearch(
@RequestParam String query,
@RequestBody List<Double> queryEmbedding,
@RequestParam(defaultValue = "5") int topK
) {
return ResponseEntity.ok(searchService.executeHybridSearch(query, queryEmbedding, topK));
}
@GetMapping("/keyword")
public ResponseEntity<List<SearchResult>> keywordSearch(
@RequestParam String query,
@RequestParam(defaultValue = "5") int topK
) {
return ResponseEntity.ok(searchService.executeKeywordSearch(query, topK));
}
}
9. End-to-End Enterprise RAG Architecture
In a full Enterprise RAG system, the hybrid search engine acts as the grounding foundation before prompt construction.

When a user asks a question, the application executes the hybrid search pipeline. The top-ranked documents are concatenated into an augmented system prompt. Because hybrid search accurately retrieves exact serial numbers and semantic context, the LLM generates 100% factual answers with zero hallucinations.
10. Comparative Search Architecture Matrix
The table below compares search strategies across modern enterprise applications.
| Search Architecture | Exact SKU Matching | Semantic Context | Index Complexity | Infrastructure Footprint | RAG Hallucination Risk |
|---|---|---|---|---|---|
| Pure Dense Vector (pgvector) | Very Poor (Misses exact terms) | High (Understands concepts) | Medium (HNSW Graph) | Single PostgreSQL database | High (False semantic matches) |
| Pure Sparse BM25 (Full-Text) | Excellent (Exact term match) | Zero (No synonym awareness) | Low (Inverted GIN Index) | Single PostgreSQL database | High (Misses rephrased queries) |
| Hybrid (pgvector + GIN + RRF) | Excellent | High | Medium-High (Dual Index) | Single PostgreSQL database | Very Low (Optimal Grounding) |
| Dedicated Dual Database (Pinecone + ES) | Excellent | High | Very High (Dual-write pipelines) | Two separate cloud clusters | Low (High synchronization lag) |
| Dense with Re-ranker Model (Cross-Encoder) | Moderate | Very High | Very High (GPU inference latency) | GPU inference server needed | Low (High computational cost) |
| Metadata-Filtered Vector Search | Good (If metadata pre-extracted) | High | Medium (Composite indexes) | Single database | Moderate (Requires manual tagging) |
11. Real-World Enterprise Case Studies
Case Study A: Enterprise Legal Contract Discovery
A global legal firm managed over 500,000 corporate merger contracts. Lawyers searched for specific contract clauses using both broad conceptual themes (such as “non-compete restrictions under California jurisdiction”) and exact section numbers (such as “Section 14.2(b)”). When the platform used pure vector search, queries containing section numbers frequently returned irrelevant sections from unrelated contracts.
The firm adopted our Spring AI Hybrid Search engine on PostgreSQL 16. BM25 pinned the exact clause numbers while dense vectors retrieved semantic definitions. Precision on legal document retrieval improved by 42%, and contract review times decreased from three hours to 20 minutes per document.
Case Study B: High-Tech Industrial E-Commerce Catalog
An industrial electronics distributor sold 1.2 million electronic components, resistors, and circuit boards. Engineers searched by exact technical manufacturer part numbers (e.g., STM32F407VGT6) and colloquial terms (e.g., “ARM Cortex M4 microcontroller with CAN bus”).
Using pure vector search resulted in a 28% drop in search conversion because the vector model hallucinated similar microchips with different pinouts. Implementing Reciprocal Rank Fusion guaranteed that exact part number queries matched the correct item 100% of the time, while broad thematic searches retained rich conceptual discovery.
Case Study C: Medical Diagnostic Knowledge Base
A hospital research team built a clinical decision support assistant for rare genetic disorders. Clinical researchers queried patient symptoms alongside specific ICD-10 medical billing codes. Combining GIN full-text indexes with pgvector HNSW embeddings achieved 99.4% retrieval accuracy, helping physicians identify rare disorders significantly faster.
12. Common Production Anti-Patterns and Fixes
Anti-Pattern 1: Averaging Unnormalized Raw Scores
Attempting to add raw cosine similarity (0.0 to 1.0) directly to unbounded BM25 scores (0.0 to 50.0+) produces meaningless rankings where BM25 completely drowns out vector similarity.
// WRONG: Adding raw scores directly
double badScore = cosineSimilarity + bm25RawScore; // BM25 completely dominates!
// CORRECT: Use Reciprocal Rank Fusion (RRF) based on rank positions
double rrfScore = (1.0 / (60.0 + denseRank)) + (1.0 / (60.0 + bm25Rank));
Anti-Pattern 2: Missing HNSW Index Parameters
Using the default IVFFlat vector index without proper training lists causes slow queries as your dataset grows past 100,000 documents. Always use HNSW for production workloads.
-- CORRECT: High-recall HNSW index configuration
CREATE INDEX ON document_knowledge_base
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
Anti-Pattern 3: Ignoring Stopwords in Lexical Search
Allowing common English words like “the”, “and”, and “in” to trigger full-text searches dilutes BM25 rankings. Always use a configured dictionary (such as to_tsvector('english', ...)) to strip stopwords.
// CORRECT: Automatic stemming and stopword filtering
to_tsvector('english', title || ' ' || content)
Anti-Pattern 4: Generating Embeddings Sequentially
Generating document embeddings one by one blocks worker threads and takes hours during batch ingestion. Use Java 25 Virtual Threads to generate embeddings concurrently.
// CORRECT: Concurrent embedding generation on Virtual Threads
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
documents.forEach(doc -> executor.submit(() -> {
List<Double> embedding = embeddingClient.embed(doc.content());
searchEngine.indexDocument(new DocumentRecord(doc.id(), doc.title(), doc.content(), doc.meta(), embedding));
}));
}
13. Automated Testing and ArchUnit Verification
Automated tests verify that documents with both exact keyword and semantic matches receive top priority under RRF rank fusion.
@Test
@DisplayName("Hybrid RRF search merges signals and ranks highest relevance document first")
void shouldMergeSignalsWithReciprocalRankFusion() {
List<Double> queryVec = List.of(0.88, 0.79, 0.12);
List<SearchResult> hybridResults = searchEngine.searchHybrid("Kubernetes Ingress", queryVec, 5, 60.0);
assertThat(hybridResults).isNotEmpty();
assertThat(hybridResults.get(0).documentId()).isEqualTo("DOC-A");
assertThat(hybridResults.get(0).matchType()).isEqualTo("HYBRID_RRF");
}
We enforce architectural fitness using ArchUnit 1.4.1. Controllers must never communicate directly with low-level search engines, and domain models must remain immutable records.
@ArchTest
static final ArchRule controllers_must_not_access_search_engine_directly =
noClasses().that().resideInAPackage("..controller..")
.should().dependOnClassesThat().haveSimpleNameEndingWith("SearchEngine")
.as("Controllers must interact with Service layer, not low-level SearchEngine directly");
14. Companion Workshop Repository
The companion code project is available in our public GitHub repository. It includes all unit tests, RRF scoring assertions, and ArchUnit 1.4.1 architectural fitness rules running on Java 25 and Spring Boot 4.0.0.
Access the companion code here: https://github.com/salahtobok/learn.webcodein.workshop/tree/main/ai/spring-ai-pgvector-hybrid-search
15. Frequently Asked Questions (FAQ)
What is Hybrid Search in Spring AI?
Hybrid search is a retrieval technique that combines dense vector embeddings (for semantic and conceptual understanding) with sparse BM25 full-text keyword search (for exact identifier and keyword matching). In Spring AI and PostgreSQL, both techniques query a single database table and merge results via Reciprocal Rank Fusion.
How does Reciprocal Rank Fusion (RRF) work?
Reciprocal Rank Fusion converts arbitrary score scales into relative rank positions. For each document, it computes a score equal to the sum of 1 / (60 + rank) across all rankers. Documents appearing near the top of both keyword and semantic searches receive the highest combined score.
Why is PostgreSQL pgvector better than running a separate vector database?
Using PostgreSQL with the pgvector extension eliminates the operational complexity of running separate databases. It allows relational metadata, GIN text search indexes, and HNSW vector embeddings to reside in a single table, providing ACID transactions with zero synchronization lag.
What is the difference between HNSW and IVFFlat indexes in pgvector?
IVFFlat divides vector space into inverted lists and requires pre-training data, which can degrade query recall as data grows. HNSW (Hierarchical Navigable Small World) builds a multi-layer graph index that delivers significantly higher recall and faster query performance without needing pre-training.
Does Hybrid Search prevent LLM hallucinations in RAG?
Yes. Pure vector search often retrieves false semantic positives when exact technical terms are queried. Hybrid search ensures the exact matching document is included in the LLM context prompt, eliminating hallucinations on part numbers, error codes, and legal clauses.
