From e52bec441b05e9d81f866bbbf385da94bbea8134 Mon Sep 17 00:00:00 2001
From: Andy Stark
Date: Fri, 30 Oct 2026 15:39:43 +0000
Subject: [PATCH 1/3] DOC-7228 Port the Lettuce recommendation engine to
Lettuce 7.9.0
The demo's pom now pins 7.9.0 (was 7.5.2). 7.9.0 dropped the value type parameter from the
search types, so SearchArgs and SearchReply take only the key type, CreateArgs and FieldArgs
are not generic, and ftSearch takes its query as a String even on the String/byte[] codec.
SearchResult.getFields() returns FieldValue, so Recommender reads fields through a small
text() helper that calls asString(). The page's ftSearch snippet and its sentence about
passing the query as a byte[] value type are updated to match.
dropIndex and indexInfo tolerate a missing index only if the error says "no such index" or
"unknown index name". Redis 8.10 says "SEARCH_INDEX_NOT_FOUND Index not found", so the demo
could not start on a fresh 8.10. A shared isMissingIndex helper now covers all three
wordings. FT.INFO still goes through dispatch(): 7.9.0's RediSearchCommands has no ftInfo.
Verified with mvn compile (no deprecation warnings) and a throwaway driver that calls
Recommender directly against Redis 8.10.2 with the shipped catalog.json: dropIndex on a
missing index, createIndex, indexProducts (37), unfiltered and filtered candidateRetrieve,
listProducts, listCategories, indexInfo and a double dropIndex all work. The embedding model
(DJL + ONNX Runtime) was not available offline, so LocalEmbedder, BuildCatalog and the
DemoServer HTTP layer have not run on 7.9.0.
Learned: Lettuce 7.9.0 search results need FieldValue.asString(), and Redis 8.10 reports a missing index as SEARCH_INDEX_NOT_FOUND
Constraint: ftSearch takes a String query on the binary codec, so do not wrap it in getBytes() again
Gaps: LocalEmbedder, BuildCatalog and the DemoServer endpoints have not run on 7.9.0; only Recommender was exercised
Ticket: DOC-7228
Co-Authored-By: Claude Sonnet 5.5
---
.../java-lettuce/Recommender.java | 84 +++++++++++--------
.../java-lettuce/_index.md | 9 +-
.../java-lettuce/pom.xml | 2 +-
3 files changed, 53 insertions(+), 42 deletions(-)
diff --git a/content/develop/use-cases/recommendation-engine/java-lettuce/Recommender.java b/content/develop/use-cases/recommendation-engine/java-lettuce/Recommender.java
index b18260e3d4..9a9aa7708e 100644
--- a/content/develop/use-cases/recommendation-engine/java-lettuce/Recommender.java
+++ b/content/develop/use-cases/recommendation-engine/java-lettuce/Recommender.java
@@ -19,6 +19,7 @@
import io.lettuce.core.output.NestedMultiOutput;
import io.lettuce.core.protocol.CommandArgs;
import io.lettuce.core.protocol.ProtocolKeyword;
+import io.lettuce.core.search.FieldValue;
import io.lettuce.core.search.SearchReply;
import io.lettuce.core.search.arguments.CreateArgs;
import io.lettuce.core.search.arguments.FieldArgs;
@@ -161,7 +162,7 @@ public String userKey(String userId) {
* {@code FT.SEARCH} can pre-filter and then KNN-rank in one pass.
*/
public void createIndex() {
- List> schema = Arrays.asList(
+ List schema = Arrays.asList(
TextFieldArgs.builder().name("name").weight(1L).build(),
// Lettuce's TextField weight is a long: 1 == default weight,
// we accept the loss of the 0.5 fractional weight on
@@ -181,7 +182,7 @@ public void createIndex() {
.distanceMetric(VectorFieldArgs.DistanceMetric.COSINE)
.build());
- CreateArgs createArgs = CreateArgs.builder()
+ CreateArgs createArgs = CreateArgs.builder()
.on(CreateArgs.TargetType.HASH)
.withPrefix(keyPrefix)
.build();
@@ -205,15 +206,23 @@ public void dropIndex(boolean deleteDocuments) {
try {
connection.sync().ftDropindex(indexName, deleteDocuments);
} catch (RedisCommandExecutionException exc) {
- String msg = String.valueOf(exc.getMessage()).toLowerCase();
- // Different Redis Search versions phrase the missing-index
- // error differently; tolerate either.
- if (!msg.contains("no such index") && !msg.contains("unknown index name")) {
+ if (!isMissingIndex(exc)) {
throw exc;
}
}
}
+ /**
+ * Different Redis Search versions phrase the missing-index error
+ * differently ("Unknown index name", "no such index", and, from
+ * Redis 8.10, "SEARCH_INDEX_NOT_FOUND Index not found").
+ */
+ private static boolean isMissingIndex(RedisCommandExecutionException exc) {
+ String msg = String.valueOf(exc.getMessage()).toLowerCase();
+ return msg.contains("no such index") || msg.contains("unknown index name")
+ || msg.contains("index not found");
+ }
+
// ------------------------------------------------------------------
// Catalogue ingest
// ------------------------------------------------------------------
@@ -325,7 +334,7 @@ public List candidateRetrieve(float[] queryVec, RetrieveOptions opts)
// parameter goes through as raw bytes and the returned hash
// field values come back as byte[] (we UTF-8-decode the text
// fields ourselves below).
- SearchArgs args = SearchArgs.builder()
+ SearchArgs args = SearchArgs.builder()
.param("vec", floatsToBytes(effective))
.returnField("name")
.returnField("description")
@@ -340,34 +349,32 @@ public List candidateRetrieve(float[] queryVec, RetrieveOptions opts)
.dialect(QueryDialects.DIALECT2)
.build();
- // ftSearch's V (value type) is byte[] on the binary connection,
- // so the query expression has to be supplied as UTF-8 bytes too.
- SearchReply reply = binConnection.sync()
- .ftSearch(indexName, utf8(queryStr), args);
- List> results =
+ SearchReply reply = binConnection.sync()
+ .ftSearch(indexName, queryStr, args);
+ List> results =
reply == null ? Collections.emptyList() : reply.getResults();
List out = new ArrayList<>(results.size());
- for (SearchReply.SearchResult doc : results) {
+ for (SearchReply.SearchResult doc : results) {
out.add(decodeCandidate(doc));
}
return out;
}
- private Candidate decodeCandidate(SearchReply.SearchResult doc) {
+ private Candidate decodeCandidate(SearchReply.SearchResult doc) {
String rawKey = doc.getId();
String bareId = rawKey.startsWith(keyPrefix)
? rawKey.substring(keyPrefix.length()) : rawKey;
- Map fields = doc.getFields();
- double dist = parseDouble(fromUtf8(fields.get("vector_score")), 0.0);
- double price = parseDouble(fromUtf8(fields.get("price")), 0.0);
- double rating = parseDouble(fromUtf8(fields.get("rating")), 0.0);
- boolean inStock = "true".equals(fromUtf8(fields.get("in_stock")));
+ Map fields = doc.getFields();
+ double dist = parseDouble(text(fields, "vector_score"), 0.0);
+ double price = parseDouble(text(fields, "price"), 0.0);
+ double rating = parseDouble(text(fields, "rating"), 0.0);
+ boolean inStock = "true".equals(text(fields, "in_stock"));
Candidate c = new Candidate();
c.id = bareId;
- c.name = fromUtf8(fields.get("name"));
- c.description = fromUtf8(fields.get("description"));
- c.category = fromUtf8(fields.get("category"));
- c.brand = fromUtf8(fields.get("brand"));
+ c.name = stringOrEmpty(text(fields, "name"));
+ c.description = stringOrEmpty(text(fields, "description"));
+ c.category = stringOrEmpty(text(fields, "category"));
+ c.brand = stringOrEmpty(text(fields, "brand"));
c.price = price;
c.rating = rating;
c.inStock = inStock;
@@ -695,8 +702,7 @@ public IndexStats indexInfo() {
Object vecMb = info.get("vector_index_sz_mb");
stats.vectorIndexSizeMb = vecMb == null ? 0.0 : parseDouble(vecMb.toString(), 0.0);
} catch (RedisCommandExecutionException exc) {
- String msg = String.valueOf(exc.getMessage()).toLowerCase();
- if (!msg.contains("no such index") && !msg.contains("unknown index name")) {
+ if (!isMissingIndex(exc)) {
throw exc;
}
}
@@ -709,7 +715,7 @@ public IndexStats indexInfo() {
*/
public List listProducts(int limit) {
if (limit <= 0) limit = 100;
- SearchArgs args = SearchArgs.builder()
+ SearchArgs args = SearchArgs.builder()
.returnField("name")
.returnField("category")
.returnField("brand")
@@ -720,21 +726,21 @@ public List listProducts(int limit) {
.limit(0, limit)
.dialect(QueryDialects.DIALECT2)
.build();
- SearchReply reply = connection.sync().ftSearch(indexName, "*", args);
- List> results =
+ SearchReply reply = connection.sync().ftSearch(indexName, "*", args);
+ List> results =
reply == null ? Collections.emptyList() : reply.getResults();
List out = new ArrayList<>(results.size());
- for (SearchReply.SearchResult doc : results) {
+ for (SearchReply.SearchResult doc : results) {
ProductSummary p = new ProductSummary();
String rawKey = doc.getId();
p.id = rawKey.startsWith(keyPrefix) ? rawKey.substring(keyPrefix.length()) : rawKey;
- Map fields = doc.getFields();
- p.name = stringOrEmpty(fields.get("name"));
- p.category = stringOrEmpty(fields.get("category"));
- p.brand = stringOrEmpty(fields.get("brand"));
- p.price = parseDouble(fields.get("price"), 0.0);
- p.rating = parseDouble(fields.get("rating"), 0.0);
- p.inStock = "true".equals(fields.get("in_stock"));
+ Map fields = doc.getFields();
+ p.name = stringOrEmpty(text(fields, "name"));
+ p.category = stringOrEmpty(text(fields, "category"));
+ p.brand = stringOrEmpty(text(fields, "brand"));
+ p.price = parseDouble(text(fields, "price"), 0.0);
+ p.rating = parseDouble(text(fields, "rating"), 0.0);
+ p.inStock = "true".equals(text(fields, "in_stock"));
out.add(p);
}
return out;
@@ -851,6 +857,12 @@ private static String stringOrEmpty(Object value) {
return value == null ? "" : value.toString();
}
+ /** The field's value as a string, or null if the document doesn't have it. */
+ private static String text(Map fields, String key) {
+ FieldValue value = fields.get(key);
+ return value == null ? null : value.asString();
+ }
+
private static byte[] utf8(String value) {
return value == null ? new byte[0] : value.getBytes(StandardCharsets.UTF_8);
}
diff --git a/content/develop/use-cases/recommendation-engine/java-lettuce/_index.md b/content/develop/use-cases/recommendation-engine/java-lettuce/_index.md
index 7c031bcc12..af19131bcb 100644
--- a/content/develop/use-cases/recommendation-engine/java-lettuce/_index.md
+++ b/content/develop/use-cases/recommendation-engine/java-lettuce/_index.md
@@ -178,11 +178,11 @@ FT.SEARCH recommend:idx
When there's no filter, the pre-filter clause is just `(*)`. `vector_score` is the cosine *distance* (0 means identical, 2 means opposite), so the result is sorted ascending and the top row is the closest candidate to the query.
-The Lettuce equivalent uses the typed `ftSearch` method on the binary-codec connection's `RediSearchCommands`. The query expression is passed as the value type (`byte[]` on the binary connection, so UTF-8-encoded) and the binary `$vec` parameter goes through `SearchArgs.Builder.param(...)`:
+The Lettuce equivalent uses the typed `ftSearch` method on the binary-codec connection's `RediSearchCommands`. The query expression is an ordinary `String`, and the binary `$vec` parameter goes through `SearchArgs.Builder.param(...)`:
```java
String knn = filterClause + "=>[KNN " + k + " @embedding $vec AS vector_score]";
-SearchArgs args = SearchArgs.builder()
+SearchArgs args = SearchArgs.builder()
.param("vec", Recommender.floatsToBytes(queryVec))
.returnField("name").returnField("description")
.returnField("category").returnField("brand")
@@ -192,9 +192,8 @@ SearchArgs args = SearchArgs.builder()
.limit(0, k)
.dialect(QueryDialects.DIALECT2)
.build();
-SearchReply reply = binConn.sync()
- .ftSearch("recommend:idx",
- knn.getBytes(StandardCharsets.UTF_8), args);
+SearchReply reply = binConn.sync()
+ .ftSearch("recommend:idx", knn, args);
```
## Lettuce specifics: binary fields and pipelining
diff --git a/content/develop/use-cases/recommendation-engine/java-lettuce/pom.xml b/content/develop/use-cases/recommendation-engine/java-lettuce/pom.xml
index 24837d2f8e..34795549d0 100644
--- a/content/develop/use-cases/recommendation-engine/java-lettuce/pom.xml
+++ b/content/develop/use-cases/recommendation-engine/java-lettuce/pom.xml
@@ -33,7 +33,7 @@
io.lettuce
lettuce-core
- 7.5.2.RELEASE
+ 7.9.0.RELEASE
From f80204c5c8801ce53b6bb8890d3c0f916f0e8e4e Mon Sep 17 00:00:00 2001
From: Andy Stark
Date: Fri, 30 Oct 2026 15:40:46 +0000
Subject: [PATCH 2/3] DOC-7228 Pin Lettuce 7.9.0 in the feature-store demo
The demo's pom.xml, the FeatureStore.java comment and the two passages in the page that name
the pin now say 7.9.0 (was 7.5.2). The statements that Lettuce 6.4 was the first release
with the field-level TTL bindings are feature floors and stay.
The demo needed no code change: it compiles on 7.9.0 with no deprecation warnings. I built it
with mvn package, ran BuildFeatures (500 users, one-hour key TTL) and started DemoServer on
Redis 8.10.2. /inspect shows the five streaming fields with a per-field TTL of 300 seconds
that counts down while the batch fields have none, and the worker toggle and /bulk-load
respond, so HEXPIRE and HTTL work on 7.9.0.
Gaps: the /read and /batch-read endpoints were not exercised, and output was not compared with a 7.5.2 run
Ticket: DOC-7228
Co-Authored-By: Claude Sonnet 5.5
---
.../use-cases/feature-store/java-lettuce/FeatureStore.java | 2 +-
.../develop/use-cases/feature-store/java-lettuce/_index.md | 4 ++--
content/develop/use-cases/feature-store/java-lettuce/pom.xml | 4 ++--
3 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/content/develop/use-cases/feature-store/java-lettuce/FeatureStore.java b/content/develop/use-cases/feature-store/java-lettuce/FeatureStore.java
index 1d0731ae8c..7a37a406bc 100644
--- a/content/develop/use-cases/feature-store/java-lettuce/FeatureStore.java
+++ b/content/develop/use-cases/feature-store/java-lettuce/FeatureStore.java
@@ -39,7 +39,7 @@
* {@code HEXPIRE} and {@code HTTL} require Redis 7.4 or later.
* Lettuce exposes them as {@code hexpire} / {@code httl} on
* {@code RedisAsyncCommands} from 6.4 onwards; the demo pins
- * 7.5.2.RELEASE.
+ * 7.9.0.RELEASE.
*
* Lettuce vs. Jedis
*
diff --git a/content/develop/use-cases/feature-store/java-lettuce/_index.md b/content/develop/use-cases/feature-store/java-lettuce/_index.md
index 0acbefba29..f701ce3ebf 100644
--- a/content/develop/use-cases/feature-store/java-lettuce/_index.md
+++ b/content/develop/use-cases/feature-store/java-lettuce/_index.md
@@ -273,7 +273,7 @@ for debugging and as a freshness signal in the model itself.
> **HEXPIRE requires Redis 7.4 or later.** `HEXPIRE` and the field-level TTL
> commands were added in Redis 7.4. Lettuce 6.4 was the first release with
-> the bindings; the demo's `pom.xml` pins 7.5.2.RELEASE.
+> the bindings; the demo's `pom.xml` pins 7.9.0.RELEASE.
### Inference reads with HMGET
@@ -448,7 +448,7 @@ Endpoints:
demo relies on per-field TTL for the mixed-staleness story.
* **Java 17 or later.** The demo uses switch expressions with arrow labels
(`case "..." -> ...`), records, and text blocks.
-* **Lettuce 6.4 or later.** The demo's `pom.xml` pins 7.5.2.RELEASE.
+* **Lettuce 6.4 or later.** The demo's `pom.xml` pins 7.9.0.RELEASE.
Field-level TTL bindings (`hexpire`, `httl`, `hpersist`) ship from
Lettuce 6.4.
diff --git a/content/develop/use-cases/feature-store/java-lettuce/pom.xml b/content/develop/use-cases/feature-store/java-lettuce/pom.xml
index 7b926a0096..f32ec15258 100644
--- a/content/develop/use-cases/feature-store/java-lettuce/pom.xml
+++ b/content/develop/use-cases/feature-store/java-lettuce/pom.xml
@@ -30,11 +30,11 @@
+ (hexpire / httl / hpersist); the demo pins 7.9.0.RELEASE. -->
io.lettuce
lettuce-core
- 7.5.2.RELEASE
+ 7.9.0.RELEASE
From e3fde396cf197b43bdf880e77cabcb3be320134f Mon Sep 17 00:00:00 2001
From: Andy Stark
Date: Fri, 30 Oct 2026 15:48:19 +0000
Subject: [PATCH 3/3] DOC-7228 Drop stale type arguments from the
recommendation engine builders
The index schema and search code still called TextFieldArgs.builder(),
CreateArgs.builder() and so on. In 7.9.0 those builders are not generic, and
Java quietly ignores explicit type arguments on a non-generic method, so the demo compiled
and my earlier regex missed them (it expected a space before builder). They would mislead
anyone copying the snippet into code that expects the old generics, so they are gone from
Recommender.java and from the page's ftSearch snippet.
Verified by rebuilding with mvn compile (no warnings) and rerunning the Recommender driver
against Redis 8.10.2: every step passes as before.
Ticket: DOC-7228
Co-Authored-By: Claude Sonnet 5.5
---
.../java-lettuce/Recommender.java | 22 +++++++++----------
.../java-lettuce/_index.md | 2 +-
2 files changed, 12 insertions(+), 12 deletions(-)
diff --git a/content/develop/use-cases/recommendation-engine/java-lettuce/Recommender.java b/content/develop/use-cases/recommendation-engine/java-lettuce/Recommender.java
index 9a9aa7708e..146ce85c5d 100644
--- a/content/develop/use-cases/recommendation-engine/java-lettuce/Recommender.java
+++ b/content/develop/use-cases/recommendation-engine/java-lettuce/Recommender.java
@@ -163,18 +163,18 @@ public String userKey(String userId) {
*/
public void createIndex() {
List schema = Arrays.asList(
- TextFieldArgs.builder().name("name").weight(1L).build(),
+ TextFieldArgs.builder().name("name").weight(1L).build(),
// Lettuce's TextField weight is a long: 1 == default weight,
// we accept the loss of the 0.5 fractional weight on
// description because Lettuce 7 does not expose a double
// weight builder.
- TextFieldArgs.builder().name("description").build(),
- TagFieldArgs.builder().name("category").build(),
- TagFieldArgs.builder().name("brand").build(),
- TagFieldArgs.builder().name("in_stock").build(),
- NumericFieldArgs.builder().name("price").sortable().build(),
- NumericFieldArgs.builder().name("rating").sortable().build(),
- VectorFieldArgs.builder()
+ TextFieldArgs.builder().name("description").build(),
+ TagFieldArgs.builder().name("category").build(),
+ TagFieldArgs.builder().name("brand").build(),
+ TagFieldArgs.builder().name("in_stock").build(),
+ NumericFieldArgs.builder().name("price").sortable().build(),
+ NumericFieldArgs.builder().name("rating").sortable().build(),
+ VectorFieldArgs.builder()
.name("embedding")
.hnsw()
.type(VectorFieldArgs.VectorType.FLOAT32)
@@ -182,7 +182,7 @@ public void createIndex() {
.distanceMetric(VectorFieldArgs.DistanceMetric.COSINE)
.build());
- CreateArgs createArgs = CreateArgs.builder()
+ CreateArgs createArgs = CreateArgs.builder()
.on(CreateArgs.TargetType.HASH)
.withPrefix(keyPrefix)
.build();
@@ -344,7 +344,7 @@ public List candidateRetrieve(float[] queryVec, RetrieveOptions opts)
.returnField("rating")
.returnField("in_stock")
.returnField("vector_score")
- .sortBy(SortByArgs.builder().attribute("vector_score").build())
+ .sortBy(SortByArgs.builder().attribute("vector_score").build())
.limit(0, k)
.dialect(QueryDialects.DIALECT2)
.build();
@@ -722,7 +722,7 @@ public List listProducts(int limit) {
.returnField("price")
.returnField("rating")
.returnField("in_stock")
- .sortBy(SortByArgs.builder().attribute("price").build())
+ .sortBy(SortByArgs.builder().attribute("price").build())
.limit(0, limit)
.dialect(QueryDialects.DIALECT2)
.build();
diff --git a/content/develop/use-cases/recommendation-engine/java-lettuce/_index.md b/content/develop/use-cases/recommendation-engine/java-lettuce/_index.md
index af19131bcb..0cd4bcad2a 100644
--- a/content/develop/use-cases/recommendation-engine/java-lettuce/_index.md
+++ b/content/develop/use-cases/recommendation-engine/java-lettuce/_index.md
@@ -188,7 +188,7 @@ SearchArgs args = SearchArgs.builder()
.returnField("category").returnField("brand")
.returnField("price").returnField("rating")
.returnField("in_stock").returnField("vector_score")
- .sortBy(SortByArgs.builder().attribute("vector_score").build())
+ .sortBy(SortByArgs.builder().attribute("vector_score").build())
.limit(0, k)
.dialect(QueryDialects.DIALECT2)
.build();