Vector Similarity Search in Valkey and Redis on Java
Vector similarity search answers a question that exact-match indexes cannot: not "which records contain this term," but "which records are closest to this point." That single change is what makes semantic search, recommendation engines, and retrieval-augmented generation possible, and both Valkey and Redis can do it without adding a dedicated vector store to your stack.
The examples that circulate for this are almost entirely Python and Node. Java teams end up translating redis-py snippets by hand, usually getting the byte encoding wrong on the first attempt. This guide covers vector similarity search from Java end to end with Redisson: both available approaches, the query types each supports, server-side filtering, hybrid search, and the encoding details that cause most of the early failures. All code is against Redisson 4.6.1.
Two approaches, and how to choose
Valkey and Redis expose vector similarity through two distinct mechanisms, and picking the wrong one costs you a rewrite later.
| Aspect | Vector sets (RVectorSet) | Query Engine (RSearch) |
|---|---|---|
| Model | Native data type | Secondary index over Hash or JSON |
| Setup | Add vectors directly | Declare an index schema first |
| Query types | Nearest neighbors | KNN, range, hybrid |
| Filtering | Expression over JSON attributes | Full text, tag, numeric, geo |
| Quantization | Q8, BIN, NOQUANT | Not available |
| Availability | Redis 8+ only | Redis and Valkey |
The rule of thumb: if the query is "find things like this," use a vector set. If it's "find things like this, in this category, under this price, updated since last week," you want the Query Engine. And if your stack is Valkey rather than Redis, the choice is made for you — Valkey forked before vector sets existed, so similarity search comes from the valkey-search module (Valkey 8.1.1 and above), which Redisson drives through the same RSearch API.
Similarity search with vector sets
A vector set behaves like a sorted set in which each element carries a high-dimensional vector instead of a scalar score. There is no index to declare — you add vectors and query them.
Note the package: the vector API lives in org.redisson.api.vector.
import org.redisson.api.RVectorSet;
import org.redisson.api.vector.VectorAddArgs;
import org.redisson.api.vector.VectorSimilarArgs;
import org.redisson.api.vector.QuantizationType;
import org.redisson.client.protocol.ScoredEntry;
import org.redisson.client.protocol.ScoreAttributesEntry;
RVectorSet products = redisson.getVectorSet("product-embeddings");
products.add(VectorAddArgs.element("sku-501")
.vector(0.90, 0.10, 0.80, 0.20)
.quantization(QuantizationType.Q8));
The builder starts with the element name, then requires a vector — either as Double... or as a byte[] blob of 32-bit floats. Real embedding models return the latter, and the encoding is covered below.
Getting distances back, not just names
The most common early mistake is reaching for getSimilar() and then discovering you have no way to threshold the results. It returns element names only:
List<String> names = products.getSimilar(
VectorSimilarArgs.element("sku-501").count(10));
Most applications need the score. Two other methods return it:
// names plus similarity scores
List<ScoredEntry<String>> scored = products.getSimilarEntries(
VectorSimilarArgs.element("sku-501").count(10));
for (ScoredEntry<String> entry : scored) {
String sku = entry.getValue();
Double score = entry.getScore();
}
// names, scores, and the stored JSON attributes in one round trip
List<ScoreAttributesEntry<String>> full = products.getSimilarEntriesWithAttributes(
VectorSimilarArgs.element("sku-501").count(10));
The third form matters more than it looks. Without it, a result list of ten SKUs becomes ten follow-up lookups to fetch the metadata you need to render them. With it, the whole result set arrives in one call.
Searching by an existing element's name rather than by a raw vector is worth preferring wherever the query object is already stored. A 1,536-dimension query vector is roughly 6 KB pushed over the network on every request; an element name is a few bytes.
Filtering happens on the server
Attributes attached to an element are not merely payload to filter in application code after the fact. VectorSimilarArgs.filter() takes an expression that Valkey or Redis evaluates during the search itself, so filtered-out candidates never cross the network:
import org.redisson.codec.JacksonCodec;
// attributes are serialized from any object via a JsonCodec
products.add(VectorAddArgs.element("sku-777")
.vector(0.88, 0.14, 0.76, 0.25)
.attributes(Map.of("category", "footwear", "price", 129.99),
new JacksonCodec<>(Map.class)));
List<String> matches = products.getSimilar(
VectorSimilarArgs.element("sku-501")
.count(5)
.filter(".category == \"footwear\" && .price < 100"));
This is the single most valuable feature of vector sets and the one most often missed, because the filter is easy to mistake for a client-side convenience. Applying a predicate after retrieval is not equivalent: filter ten nearest neighbours down to three and you have three results, not the ten you asked for, and no way to recover the rest without querying again at a higher k.
Tuning recall and cost
Three parameters control the accuracy/latency trade, and all three have sensible defaults you should only override deliberately:
explorationFactor(int)— how widely the HNSW graph is traversed. Higher values raise recall and latency together. Available on bothVectorAddArgs(at insert time) andVectorSimilarArgs(at query time).epsilon(double)— widens the accepted distance boundary, letting marginally more distant candidates into the result set.filterEffort(int)— how hard the engine works to satisfy a filter before giving up. Raise it when a selective filter is returning fewer results than you asked for.
For correctness checks, useLinearScan() forces an exhaustive comparison against every vector. It is far too slow for production but is the only way to establish ground truth when you suspect approximate search is returning the wrong neighbours.
On the write side, quantization() is the memory lever. Q8 is the default and cuts memory roughly fourfold at a small recall cost; BIN compresses to single bits for coarse first-pass filtering; NOQUANT keeps full 32-bit precision. Since Valkey and Redis hold everything in RAM, this is usually the constraint that decides whether a workload fits — see our guide to using Redis as a vector database for the memory arithmetic.
Similarity search with the Query Engine
When vectors are one attribute of records that also carry business metadata, declare a vector field in an RSearch index alongside ordinary fields. Redisson supports three index types through the same FieldIndex builder:
RSearch search = redisson.getSearch(StringCodec.INSTANCE);
search.createIndex("product_idx", IndexOptions.defaults()
.on(IndexType.JSON)
.prefix(Arrays.asList("product:")),
FieldIndex.text("$.description").as("description"),
FieldIndex.tag("$.category").as("category"),
FieldIndex.numeric("$.price").as("price"),
FieldIndex.hnswVector("$.embedding").as("embedding")
.type(VectorTypeParam.Type.FLOAT32)
.dim(1536)
.distance(VectorDistParam.DistanceMetric.COSINE)
.m(16)
.efConstruction(200)
.efRuntime(10));
FieldIndex.flatVector() swaps in a brute-force index — exact results, linear query time, the right choice for small collections and for benchmarking. FieldIndex.svsVamanaVector() provides a third option. The dimension must match your embedding model's output exactly and cannot be changed without rebuilding the index.
Distance metric is not a tuning knob either. COSINE suits text embeddings, where direction carries meaning and magnitude does not; L2 suits image features and spatial data; IP ranks identically to cosine on vectors already normalized to unit length. Pairing a model with the wrong metric produces plausible-looking results that are quietly wrong, which is considerably harder to debug than an outright error.
Encoding the query vector
This is where most Java implementations fail first. Query vectors travel as raw bytes — 32-bit IEEE 754 floats in little-endian order — not as a list of numbers. Java's ByteBuffer defaults to big-endian, so the byte order has to be set explicitly:
public static byte[] toBytes(List<Float> vector) {
ByteBuffer buffer = ByteBuffer.allocate(vector.size() * Float.BYTES)
.order(ByteOrder.LITTLE_ENDIAN);
for (float f : vector) {
buffer.putFloat(f);
}
return buffer.array();
}
Get this wrong and nothing throws. The query succeeds and returns results that are simply meaningless, ranked by distance in a space your vectors don't occupy. If your similarity scores look like noise, check the byte order before anything else. The same encoding applies to Valkey, so this helper is portable across both engines.
Hybrid search: combining lexical and semantic ranking
Vector search alone loses exact-match precision — product codes, names, and specific terminology are exactly what lexical search is good at. Hybrid search runs both and fuses the rankings. Redisson exposes this through hybridSearch():
HybridSearchResult result = search.hybridSearch("product_idx",
HybridQueryArgs.query("@description:running @category:{footwear}")
.scoreAlias("text_score")
.vectorSimilarity(
VectorSimilarity.of("embedding", "query_vec")
.nearestNeighbors(20)
.yieldDistanceAs("vec_distance"))
.params(Map.of("query_vec", toBytes(queryEmbedding)))
.combine(Combine.reciprocalRankFusion().window(40))
.load("description", "price", "vec_distance")
.limit(0, 10));
long total = result.getTotal();
for (Map<String, String> doc : result.getResults()) {
String description = doc.get("description");
String distance = doc.get("vec_distance");
}
Two fusion strategies are available. Combine.reciprocalRankFusion() merges by rank position rather than raw score, which sidesteps the problem that a BM25 text score and a cosine distance aren't on comparable scales; tune it with window() and constant(). Combine.linear() blends the scores directly with alpha() and beta() weights, useful when you want explicit control over how much each side contributes.
Reciprocal rank fusion is the safer default. Linear combination requires that both score distributions be normalized and stable, and embedding model changes will shift them underneath you.
Three modifiers refine a KNN clause: yieldDistanceAs() exposes the raw distance as a named field you can load and sort on, efRuntime() widens the graph traversal for higher recall, and shardKRatio() controls how many candidates each shard returns relative to the requested k in a cluster. Note that each returns the base VectorSimilarity type, so exactly one can be applied per clause — pick the one your query actually needs rather than chaining them.
Range queries: searching by distance instead of count
KNN always returns k results, even when nothing in the index is genuinely similar. For semantic caching and deduplication that's the wrong shape — you want "everything within this distance, possibly nothing":
HybridSearchResult nearby = search.hybridSearch("product_idx",
HybridQueryArgs.query("*")
.vectorSimilarity(
VectorSimilarity.of("embedding", "query_vec")
.range(0.15)
.epsilon(0.01))
.params(Map.of("query_vec", toBytes(queryEmbedding)))
.limit(0, 50));
This is the query behind semantic caching for LLM applications: embed an incoming prompt, look for any previously answered prompt within a tight distance threshold, and return the cached response on a hit. An empty result is the correct answer when no prior prompt was close enough — a KNN query would have returned the nearest one regardless and quietly served the wrong cached answer.
Using Spring AI with Redisson
Teams building on Spring AI often assume adopting its Redis vector store means introducing a second Valkey or Redis client alongside Redisson. That is no longer the case. Redisson ships its own Spring AI VectorStore implementation, so RAG and semantic search run through the connection pool, cluster configuration, and codecs you already have.
<dependency>
<groupId>pro.redisson</groupId>
<artifactId>redisson-spring-ai-store-starter-10</artifactId>
<version>4.6.1</version>
</dependency>
spring:
ai:
vectorstore:
redisson:
index-name: my-index
prefix: "doc:"
initialize-schema: true
vector-algorithm: HNSW
distance-metric: COSINE
metadata-fields:
- name: category
type: TAG
- name: year
type: NUMERIC
@Autowired
VectorStore vectorStore;
vectorStore.add(List.of(
new Document("Trail running shoe with rock plate",
Map.of("category", "footwear", "year", 2026))));
List<Document> results = vectorStore.similaritySearch(
SearchRequest.builder()
.query("shoes for rocky terrain")
.topK(5)
.similarityThreshold(0.7)
.filterExpression("category == 'footwear' && year >= 2025")
.build());
Portable filter expressions are translated into engine query syntax automatically, so category == 'footwear' && year >= 2025 becomes @category:{footwear} @year:[2025 inf]. Note that initialize-schema defaults to false and must be enabled for the index to be created for you. This integration is a Redisson PRO feature.
Choosing an approach
Reach for RVectorSet when similarity is the entire requirement and you're on Redis 8 or later — it needs no schema, supports quantization, and filters on the server through a compact expression syntax. Reach for RSearch when vectors are one field among several, when you need range queries or hybrid ranking, or when you're running Valkey. Reach for the Spring AI store when you're building RAG inside a Spring application and would rather work at the document level than the vector level.
All three run against Standalone, Sentinel, and Cluster deployments with only configuration changing, which matters when a prototype on one node has to become a clustered production service. For more on where vectors belong in your architecture in the first place, see our guide to Redis as a vector database, and for a closer look at the native data type, our Java developer's guide to vector sets.