{
  "id": "java-lettuce",
  "title": "Redis recommendation engine with Lettuce",
  "url": "https://redis.io/docs/latest/develop/use-cases/recommendation-engine/java-lettuce/",
  "summary": "Build a Redis-backed recommendation engine in Java with Lettuce and DJL (HuggingFace tokenizers + ONNX Runtime)",
  "tags": [
    "docs",
    "develop",
    "stack",
    "oss",
    "rs",
    "rc"
  ],
  "last_updated": "2026-05-26T09:29:27-05:00",
  "children": [],
  "page_type": "content",
  "content_hash": "09ba19fac59cae7aea39d3e15701dd14f622d65099e10514d3e4c238c15d3e41",
  "sections": [
    {
      "id": "overview",
      "title": "Overview",
      "role": "overview",
      "text": "This guide shows you how to build a small Redis-backed product recommendation service in Java with the [Lettuce](https://redis.io/docs/latest/develop/clients/lettuce) client library and the [Deep Java Library](https://djl.ai/) (DJL) with its HuggingFace tokenizer integration and the ONNX Runtime inference engine. It includes a local web server built on the JDK's `com.sun.net.httpserver` so you can embed a natural-language query, run a KNN retrieval with structured pre-filters in one round trip, feed clicks back as a session signal, and watch the next recommendation incorporate them immediately."
    },
    {
      "id": "overview",
      "title": "Overview",
      "role": "overview",
      "text": "Each product is stored as a single Redis [Hash](https://redis.io/docs/latest/develop/data-types/hashes) at `product:<id>`. The hash holds the structured metadata (name, description, category, brand, price, rating, in-stock flag) alongside the raw `float32` bytes of a 384-dimensional embedding. A single [Redis Search](https://redis.io/docs/latest/develop/ai/search-and-query) index covers every field, so one [`FT.SEARCH`](https://redis.io/docs/latest/commands/ft.search) call with a `KNN` clause does the vector similarity *and* the TAG / NUMERIC / TEXT pre-filtering in the same pass &mdash; no cross-store joins.\n\nPer-user state lives in `user:<id>:features`: a session vector written as an exponentially weighted average of recently-clicked item embeddings, plus per-category affinity counters incremented atomically with [`HINCRBYFLOAT`](https://redis.io/docs/latest/commands/hincrbyfloat). [`FT.SEARCH`](https://redis.io/docs/latest/commands/ft.search) does *not* read that hash directly; instead, the application reads it on the next request and passes the session vector to `FT.SEARCH` as the query parameter. The two-step is what lets a click feed the very next recommendation without a batch cycle or cache invalidation.\n\nThat gives you:\n\n* A single round trip for retrieval &mdash; vector KNN + structured filters in one [`FT.SEARCH`](https://redis.io/docs/latest/commands/ft.search).\n* Sub-millisecond hot path once the query is embedded; embedding the query is the bottleneck, and that's a model-side cost, not a Redis one.\n* Real-time session signals &mdash; a click writes a new session vector and bumps an affinity counter; the next query reads them and folds them in.\n* No-downtime embedding refresh &mdash; [`HSET`](https://redis.io/docs/latest/commands/hset) on the vector field, and the HNSW index reflects the change on the next query."
    },
    {
      "id": "how-it-works",
      "title": "How it works",
      "role": "content",
      "text": "There are two distinct paths: a **query path** runs every time the application wants a recommendation, and a **click path** runs every time the user interacts with a product."
    },
    {
      "id": "query-path-per-recommendation-request",
      "title": "Query path (per recommendation request)",
      "role": "content",
      "text": "1. The application calls `embedder.encodeOne(queryText)` to turn a natural-language query into a 384-dimensional `float32` vector. DJL's `Predictor` runs the [HuggingFace tokenizer](https://github.com/deepjavalibrary/djl/tree/master/extensions/tokenizers) and ONNX Runtime inference end-to-end.\n2. The application reads the user's session vector and affinities from the user features hash. If a session vector exists, it gets blended into the query vector with a tunable weight, so the user's recent clicks pull retrieval toward what they've been engaging with.\n3. `recommender.candidateRetrieve(queryVec, opts)` runs [`FT.SEARCH`](https://redis.io/docs/latest/commands/ft.search) with a pre-filter clause built from the request's TAG / NUMERIC / TEXT inputs, followed by a `KNN k @embedding $vec` clause. Redis returns up to `k` candidates with the cosine distance to the query (lower is closer).\n4. `recommender.rerank(candidates, userFeatures, affinityWeight)` subtracts a log-scaled per-category affinity bonus from each candidate's distance and re-sorts the list closest-first. The log scaling keeps repeated clicks from running away with the ranking."
    },
    {
      "id": "click-path-per-user-interaction",
      "title": "Click path (per user interaction)",
      "role": "content",
      "text": "When the user clicks a product, `recommender.recordClick(userId, productId, ewmaAlpha, affinityStep)` does the following:\n\n1. Reads the clicked item's embedding from its hash.\n2. Reads the user's previous session vector from the user features hash, blends the new click in via an exponentially weighted moving average, and writes the new session vector back with [`HSET`](https://redis.io/docs/latest/commands/hset). This is a read-modify-write &mdash; atomic against any single write but not against a concurrent click for the same user. In practice, per-user click streams don't generate the contention to make this matter, and if a deployment does, the read and write can be wrapped in [`WATCH/MULTI/EXEC`](https://redis.io/docs/latest/commands/multi) or a small Lua script.\n3. Bumps the per-category affinity counter with [`HINCRBYFLOAT`](https://redis.io/docs/latest/commands/hincrbyfloat) &mdash; atomic, no read needed &mdash; and the click count with [`HINCRBY`](https://redis.io/docs/latest/commands/hincrby).\n\nThe next query path picks both changes up the next time it reads the user features hash.\n\nRefreshing an item's embedding follows a similar shape: encode the new text, write the vector bytes back with [`HSET`](https://redis.io/docs/latest/commands/hset), and the HNSW index reflects the change on the next query without a rebuild."
    },
    {
      "id": "the-recommender-helper",
      "title": "The recommender helper",
      "role": "content",
      "text": "The `Recommender` class wraps the Redis Search index and the retrieval flow\n([source](https://github.com/redis/docs/blob/main/content/develop/use-cases/recommendation-engine/java-lettuce/Recommender.java)):\n\n[code example]"
    },
    {
      "id": "data-model",
      "title": "Data model",
      "role": "content",
      "text": "Each product is one Redis Hash. The vector field is raw little-endian `float32` bytes &mdash; no JSON wrapping &mdash; because the Redis Search vector encoding expects exactly that.\n\n[code example]\n\nThe Redis Search index schema treats every field as queryable in its natural type:\n\n[code example]\n\n\nThe other ports in this guide series set `description` to `WEIGHT 0.5` so phrase matches in the longer `description` field don't outweigh matches in the short `name` field. Lettuce 7's typed `TextFieldArgs.weight(long)` accepts only integer weights, so this port leaves `description` at its default weight of 1. The difference is only visible if you compare BM25-style TEXT relevance scores side by side; KNN retrieval and TEXT pre-filtering are unaffected.\n\n\nPer-user state is a separate hash. The session vector is stored as raw `float32` bytes the same way; affinity counters are stored as plain numeric strings, one field per category, prefixed with `aff:` so they don't collide with anything else.\n\n[code example]"
    },
    {
      "id": "the-query",
      "title": "The query",
      "role": "content",
      "text": "The KNN clause is a hybrid query: a pre-filter expression in parentheses, then `=>[KNN k @embedding $vec]`. With `DIALECT 2`, Redis applies the filter first and then KNN-ranks only the matching documents.\n\n[code example]\n\nWhen 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.\n\nThe 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(...)`:\n\n[code example]"
    },
    {
      "id": "lettuce-specifics-binary-fields-and-pipelining",
      "title": "Lettuce specifics: binary fields and pipelining",
      "role": "content",
      "text": "Two things in the helper change shape relative to the [Jedis port](https://redis.io/docs/latest/develop/use-cases/recommendation-engine/java-jedis):"
    },
    {
      "id": "1-codec-choice-for-the-binary-embedding-field",
      "title": "1. Codec choice for the binary embedding field",
      "role": "content",
      "text": "Lettuce's default `StringCodec` UTF-8-decodes every hash value, which would corrupt the raw `float32` bytes that the Redis Search vector field expects. Following the [Lettuce vector-search reference](https://redis.io/docs/latest/develop/clients/lettuce/vecsearch), the helper opens a *second* connection bound to a `<String, byte[]>` codec (built with `RedisCodec.of(StringCodec.UTF8, ByteArrayCodec.INSTANCE)`) and routes every command that reads or writes the `embedding` field &mdash; including `FT.SEARCH`, whose `$vec` parameter is raw bytes too &mdash; through that connection. The structured fields share the same hash and are written through the same binary connection as their UTF-8 bytes so Redis sees an identical wire format to what the Jedis port writes.\n\n[code example]"
    },
    {
      "id": "2-pipelining-with-setautoflushcommands-false",
      "title": "2. Pipelining with `setAutoFlushCommands(false)`",
      "role": "content",
      "text": "Jedis pipelines via a dedicated `Pipeline` object you obtain with `client.pipelined()`. Lettuce instead drives pipelining at the *connection* level: turn auto-flush off, queue async commands, flush, then await the futures.\n\n[code example]\n\nThe toggle is connection-wide, so the helper owns its binary connection rather than borrowing a shared one &mdash; any other thread issuing commands on the same connection while auto-flush is off would be stalled until `flushCommands()` is called."
    },
    {
      "id": "a-note-on-ft-info",
      "title": "A note on `FT.INFO`",
      "role": "content",
      "text": "Lettuce 7's `RediSearchCommands` exposes typed wrappers for most `FT.*` commands, but `FT.INFO` isn't one of them. The helper dispatches the raw command via `connection.sync().dispatch(...)` with a `NestedMultiOutput` so it can parse the alternating-pair reply, the same approach the [streaming Lettuce port](https://redis.io/docs/latest/develop/use-cases/streaming/java-lettuce) uses for `XAUTOCLAIM`'s extended reply."
    },
    {
      "id": "the-local-embedder",
      "title": "The local embedder",
      "role": "content",
      "text": "The `LocalEmbedder` class wraps DJL's HuggingFace text-embedding pipeline so the rest of the helper can hand it a string and get back a unit-normalised `float[]`\n([source](https://github.com/redis/docs/blob/main/content/develop/use-cases/recommendation-engine/java-lettuce/LocalEmbedder.java)):\n\n[code example]\n\nThe model URL routes through DJL's model zoo. The `ai.djl.huggingface.onnxruntime` group ID pulls a pre-converted ONNX bundle (model weights plus `tokenizer.json`) so we don't need a separate conversion step. `optEngine(\"OnnxRuntime\")` pins the inference engine to ONNX Runtime, which means the demo only drags in the small ONNX Runtime native library rather than the much heavier PyTorch native library that `ai.djl.huggingface.pytorch` would require.\n\n`Predictor` is *not* thread-safe; the wrapper guards calls with `synchronized` because the JDK HttpServer dispatches each request on a worker thread. For higher concurrency in production you'd hold a pool of `Predictor` instances backed by one `ZooModel`."
    },
    {
      "id": "the-catalogue-builder",
      "title": "The catalogue builder",
      "role": "content",
      "text": "Item vectors are pre-computed once and stored in `catalog.json` so the demo server can boot quickly. `BuildCatalog` is the reference for how to do that &mdash; and is the program you'd adapt for a real catalogue ingestion pipeline\n([source](https://github.com/redis/docs/blob/main/content/develop/use-cases/recommendation-engine/java-lettuce/BuildCatalog.java)):\n\n[code example]\n\nIn production the equivalent lives in an offline pipeline: embed once on catalogue updates and ship the vectors into Redis with [`HSET`](https://redis.io/docs/latest/commands/hset). The serving tier still embeds the *query* on each request, but that's usually fronted by a dedicated model server or batched at the API gateway rather than co-located with the data tier as it is in this demo.\n\nThe shared `catalog.json` wire format (model name, dim, list of products with base64-encoded `float32` LE bytes for the vector) is identical to what the Python, Node, and Go ports produce, so you can re-use any port's catalog with the Lettuce demo as long as the embedding model matches."
    },
    {
      "id": "the-interactive-demo",
      "title": "The interactive demo",
      "role": "content",
      "text": "`DemoServer` runs `com.sun.net.httpserver.HttpServer` with a 16-thread executor and one demo user (`demo`). The HTML page lets you:\n\n* Type a natural-language query and toggle filters: TAG (category, brand, in-stock), NUMERIC (price range, rating), and TEXT (the **Description contains** field, a phrase pre-filter against the `description` text index).\n* Toggle session blending and category-affinity re-ranking independently to see what each layer contributes.\n* Click any product card to record a click into the session. The page re-renders the user features panel immediately &mdash; the click wrote to the user features hash, and the next search reads that hash to fold the update in.\n* Refresh a single product's embedding with new text and watch the ranking change on the next query.\n\nThe server holds one `RedisClient`, two `StatefulRedisConnection`s (regular + binary-codec), one `LocalEmbedder`, and one `Recommender` for the lifetime of the process. Endpoints:\n\n| Endpoint                  | What it does                                                                |\n|---------------------------|-----------------------------------------------------------------------------|\n| `GET  /state`             | Index info, user features, full catalogue listing.                          |\n| `POST /search`            | Embed the query, run `FT.SEARCH` with filters + KNN, optionally re-rank.    |\n| `POST /click`             | Record a click for the demo user: update session vector and affinity.       |\n| `POST /reset-user`        | Drop the user features hash.                                                |\n| `POST /reset-index`       | Drop the index and documents and re-seed from `catalog.json`.               |\n| `POST /refresh-embedding` | Embed new text and overwrite one product's vector with `HSET`.              |"
    },
    {
      "id": "prerequisites",
      "title": "Prerequisites",
      "role": "content",
      "text": "Before running the demo, make sure that:\n\n* Redis 7.0 or later with the Redis Search module is running and accessible. By default the demo connects to `localhost:6379`. [Redis Stack](https://redis.io/docs/latest/operate/oss_and_stack/install/install-stack) or [Redis 8 with Search](https://redis.io/docs/latest/develop/ai/search-and-query) both work.\n* JDK 17 or later is installed (the demo's inline HTML uses text blocks, which require JDK 15+; 17+ keeps the demo on a current LTS).\n* [Maven](https://maven.apache.org/) 3.9 or later for dependency resolution. DJL pulls in a couple of dozen transitive jars, so a manual `javac -cp ...` build is impractical; the `pom.xml` shipped next to the source files lets Maven handle that. Lettuce 7.x is required &mdash; the typed `FT.*` API used by this demo (`ftCreate`, `ftSearch`, `ftDropindex`, `ftTagvals`, the `SearchArgs` / `FieldArgs` / `CreateArgs` builders) lives in `io.lettuce.core.search` and was introduced in the 7.x release line.\n\nIf your Redis server is running elsewhere, start the demo with `--redis-host` and `--redis-port`."
    },
    {
      "id": "run-the-demo-locally",
      "title": "Run the demo locally",
      "role": "content",
      "text": "1.  Clone the [`redis/docs`](https://github.com/redis/docs) repository and change into the example\n    directory:\n\n    [code example]\n\n2.  Build the project. The first build downloads the dependency graph (Lettuce 7, DJL API, the\n    HuggingFace tokenizer extension, the ONNX Runtime engine, and Gson):\n\n    [code example]\n\n3.  Generate the catalogue with pre-computed embeddings. The first run downloads the\n    `all-MiniLM-L6-v2` ONNX bundle (~80&nbsp;MB) into the local DJL cache\n    (`~/.djl.ai/cache/`):\n\n    [code example]\n\n4.  Start the demo server:\n\n    [code example]\n\n    Override the defaults with `-Dexec.args=\"--port 8090 --redis-host 127.0.0.1\"`.\n\n5.  Open <http://localhost:8084> and try some queries:\n\n    * **\"insulated down jacket for cold weather\"** &mdash; filtered to `outerwear`, in-stock only.\n    * **\"comfortable shoes for trail running\"** &mdash; filtered to `footwear`.\n    * Add **Description contains: waterproof** to either query above to see a TEXT pre-filter\n      combine with the KNN.\n    * Click a couple of products to seed a session, then re-run the same query\n      with **Blend session vector into query** on and watch the ranking shift.\n    * Use **Refresh embedding** to change a product's vector &mdash; for example,\n      change the Alpine down parka's text to \"heavy duty arctic expedition parka\n      with hood\" and re-run the jacket query to see the result move.\n\nThe server is read/write against your local Redis. The default index name is `recommend:idx` and product keys live under `product:`. Pass `--no-reset` to keep an existing index across restarts, or `--index-name` / `--key-prefix` to point the demo at a different prefix entirely."
    }
  ],
  "examples": [
    {
      "id": "the-recommender-helper-ex0",
      "language": "java",
      "code": "import io.lettuce.core.RedisClient;\nimport io.lettuce.core.api.StatefulRedisConnection;\nimport io.lettuce.core.codec.ByteArrayCodec;\nimport io.lettuce.core.codec.RedisCodec;\nimport io.lettuce.core.codec.StringCodec;\n\n// One client, two connections: a String/String one for structured\n// commands and FT.* index management, plus a String/byte[] one for\n// every command that touches the binary embedding field (including\n// FT.SEARCH, whose $vec parameter is raw bytes too).\nRedisClient client = RedisClient.create(\"redis://localhost:6379\");\nStatefulRedisConnection<String, String> conn = client.connect();\nStatefulRedisConnection<String, byte[]> binConn = client.connect(\n        RedisCodec.of(StringCodec.UTF8, ByteArrayCodec.INSTANCE));\n\nRecommender recommender = new Recommender(conn, binConn,\n        \"recommend:idx\", \"product:\", \"user:\", 384);\n\nLocalEmbedder embedder = new LocalEmbedder();   // all-MiniLM-L6-v2 via DJL + ONNX Runtime\n\n// One-time index setup (idempotent).\nrecommender.createIndex();\n\n// Embed the natural-language query.\nfloat[] queryVec = embedder.encodeOne(\"warm waterproof jacket for hiking\");\n\n// Retrieval: KNN with structured pre-filter in one round trip.\n// Filters combine TAG (category, brand, inStockOnly), NUMERIC\n// (price range, rating), and TEXT (textMatch against textField) —\n// Redis applies them all in front of the KNN.\nRecommender.RetrieveOptions opts = new Recommender.RetrieveOptions();\nopts.category = \"outerwear\";\nopts.inStockOnly = true;\nopts.minPrice = 50.0;\nopts.maxPrice = 200.0;\nopts.textMatch = \"waterproof\";   // TEXT pre-filter on @description\nopts.k = 10;\nList<Recommender.Candidate> candidates = recommender.candidateRetrieve(queryVec, opts);\n\n// Record a click — updates the user's session vector and category\n// affinity atomically; the next call to candidateRetrieve sees it.\nrecommender.recordClick(\"alice\", \"p001\", 0.4, 1.0);\n\n// Pull user features so the next retrieval can blend the session\n// vector into the query and apply the category-affinity re-rank.\nRecommender.UserFeatures features = recommender.getUserFeatures(\"alice\");\nopts.sessionVec = features.sessionVec;\nopts.sessionWeight = 0.3;\ncandidates = recommender.candidateRetrieve(queryVec, opts);\ncandidates = recommender.rerank(candidates, features, 0.15);\n\n// Hot embedding refresh — overwrite the vector for one product; the\n// HNSW index reflects the change on the next FT.SEARCH.\nfloat[] newVector = embedder.encodeOne(\"heavy-duty arctic expedition parka\");\nrecommender.refreshEmbedding(\"p001\", newVector);",
      "section_id": "the-recommender-helper"
    },
    {
      "id": "data-model-ex0",
      "language": "text",
      "code": "product:p001\n  name=Alpine down parka\n  description=Heavyweight 800-fill goose down parka...\n  category=outerwear\n  brand=northpeak\n  price=289.0\n  rating=4.7\n  in_stock=true\n  embedding=<384 x float32 little-endian bytes>",
      "section_id": "data-model"
    },
    {
      "id": "data-model-ex1",
      "language": "text",
      "code": "FT.CREATE recommend:idx\n  ON HASH PREFIX 1 product:\n  SCHEMA\n    name        TEXT\n    description TEXT\n    category    TAG\n    brand       TAG\n    in_stock    TAG\n    price       NUMERIC SORTABLE\n    rating      NUMERIC SORTABLE\n    embedding   VECTOR HNSW 6 TYPE FLOAT32 DIM 384 DISTANCE_METRIC COSINE",
      "section_id": "data-model"
    },
    {
      "id": "data-model-ex2",
      "language": "text",
      "code": "user:alice:features\n  session_vec=<384 x float32 little-endian bytes>\n  aff:outerwear=2.0\n  aff:footwear=1.0\n  last_clicked_id=p015\n  last_clicked_category=footwear\n  clicks=3",
      "section_id": "data-model"
    },
    {
      "id": "the-query-ex0",
      "language": "text",
      "code": "FT.SEARCH recommend:idx\n  \"(@category:{outerwear} @in_stock:{true} @price:[50.0 200.0])\n     =>[KNN 10 @embedding $vec AS vector_score]\"\n  PARAMS 2 vec <384-float32-bytes>\n  SORTBY vector_score\n  RETURN 8 name description category brand price rating in_stock vector_score\n  DIALECT 2",
      "section_id": "the-query"
    },
    {
      "id": "the-query-ex1",
      "language": "java",
      "code": "String knn = filterClause + \"=>[KNN \" + k + \" @embedding $vec AS vector_score]\";\nSearchArgs<String, byte[]> args = SearchArgs.<String, byte[]>builder()\n        .param(\"vec\", Recommender.floatsToBytes(queryVec))\n        .returnField(\"name\").returnField(\"description\")\n        .returnField(\"category\").returnField(\"brand\")\n        .returnField(\"price\").returnField(\"rating\")\n        .returnField(\"in_stock\").returnField(\"vector_score\")\n        .sortBy(SortByArgs.<String>builder().attribute(\"vector_score\").build())\n        .limit(0, k)\n        .dialect(QueryDialects.DIALECT2)\n        .build();\nSearchReply<String, byte[]> reply = binConn.sync()\n        .ftSearch(\"recommend:idx\",\n                knn.getBytes(StandardCharsets.UTF_8), args);",
      "section_id": "the-query"
    },
    {
      "id": "1-codec-choice-for-the-binary-embedding-field-ex0",
      "language": "java",
      "code": "RedisClient client = RedisClient.create(\"redis://localhost:6379\");\nStatefulRedisConnection<String, String> conn = client.connect();\nStatefulRedisConnection<String, byte[]> binConn = client.connect(\n        RedisCodec.of(StringCodec.UTF8, ByteArrayCodec.INSTANCE));",
      "section_id": "1-codec-choice-for-the-binary-embedding-field"
    },
    {
      "id": "2-pipelining-with-setautoflushcommands-false-ex0",
      "language": "java",
      "code": "RedisAsyncCommands<String, byte[]> async = binConn.async();\nbinConn.setAutoFlushCommands(false);\ntry {\n    List<RedisFuture<Long>> futures = new ArrayList<>();\n    for (Product p : products) {\n        Map<String, byte[]> fields = ...;          // name, description, ..., embedding\n        futures.add(async.hset(productKey(p.id), fields));\n    }\n    binConn.flushCommands();\n    for (RedisFuture<Long> f : futures) f.get();\n} finally {\n    binConn.setAutoFlushCommands(true);\n}",
      "section_id": "2-pipelining-with-setautoflushcommands-false"
    },
    {
      "id": "the-local-embedder-ex0",
      "language": "java",
      "code": "Criteria<String, float[]> criteria = Criteria.builder()\n        .setTypes(String.class, float[].class)\n        .optModelUrls(\"djl://ai.djl.huggingface.onnxruntime/sentence-transformers/all-MiniLM-L6-v2\")\n        .optEngine(\"OnnxRuntime\")\n        .optTranslatorFactory(new TextEmbeddingTranslatorFactory())\n        .optProgress(new ProgressBar())\n        .build();\nZooModel<String, float[]> model = criteria.loadModel();\nPredictor<String, float[]> predictor = model.newPredictor();\nfloat[] vector = predictor.predict(\"warm waterproof jacket for hiking\");",
      "section_id": "the-local-embedder"
    },
    {
      "id": "the-catalogue-builder-ex0",
      "language": "java",
      "code": "List<CatalogSeed.Seed> seeds = CatalogSeed.all();\nList<String> texts = new ArrayList<>();\nfor (CatalogSeed.Seed s : seeds) {\n    texts.add(CatalogSeed.embedTextFor(s));    // \"<name>. <description>\"\n}\ntry (LocalEmbedder embedder = new LocalEmbedder()) {\n    float[][] vectors = embedder.encodeMany(texts);\n    Catalog.write(Path.of(\"catalog.json\"),\n            embedder.getModelName(), vectors[0].length, seeds, vectors);\n}",
      "section_id": "the-catalogue-builder"
    },
    {
      "id": "run-the-demo-locally-ex0",
      "language": "bash",
      "code": "git clone https://github.com/redis/docs.git\n    cd docs/content/develop/use-cases/recommendation-engine/java-lettuce",
      "section_id": "run-the-demo-locally"
    },
    {
      "id": "run-the-demo-locally-ex1",
      "language": "bash",
      "code": "mvn -DskipTests package",
      "section_id": "run-the-demo-locally"
    },
    {
      "id": "run-the-demo-locally-ex2",
      "language": "bash",
      "code": "mvn -DskipTests exec:java -Dexec.mainClass=BuildCatalog",
      "section_id": "run-the-demo-locally"
    },
    {
      "id": "run-the-demo-locally-ex3",
      "language": "bash",
      "code": "mvn -DskipTests exec:java -Dexec.mainClass=DemoServer",
      "section_id": "run-the-demo-locally"
    }
  ]
}
