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();