Claude Cursor Skill

redis-search

Redis Search guidance covering FT.CREATE schema design, field type selection (TEXT, TAG, NUMERIC, GEO, GEOSHAPE, VECTOR, JSON path), DIALECT 2 query syntax, FT.SEARCH / FT.AGGREGATE / FT.HYBRID command selection, vector similarity with HNSW or FLAT, hybrid retrieval combining lex

LLM Mart · 0 points · 11 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download redis-agent-skills-skills_redis-search-172fb9e.zip · 97 KB
Part of redis/agent-skills — 12 skills

Install

skills CLI npx skills add https://github.com/redis/agent-skills/tree/main/skills/redis-search
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install redis-agent-skills@llmmart
Git git clone https://github.com/redis/agent-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole redis/agent-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Redis Search

Single source of guidance for Redis Search — the retrieval surface that spans lexical, numeric, geo, JSON-path, and vector queries. Vector fields are part of the same FT.CREATE machinery as TEXT/TAG/NUMERIC fields, and FT.HYBRID blends lexical and vector ranking in one command, so this skill covers them together.

When to apply

  • Creating, modifying, or reviewing a Redis Search index (FT.CREATE, FT.ALTER).
  • Writing or optimizing FT.SEARCH, FT.AGGREGATE, or FT.HYBRID queries.
  • Picking between TEXT, TAG, NUMERIC, GEO, GEOSHAPE, VECTOR, or JSON-path fields.
  • Defining a VECTOR field, choosing HNSW vs FLAT, tuning HNSW parameters.
  • Building a retrieval-augmented generation (RAG) pipeline.
  • Rolling out a new index schema without downtime.
  • Troubleshooting empty results, slow queries, or tokenization issues with FT.EXPLAIN, FT.PROFILE, FT.INFO.

1. Pick the right command

Three query commands. Reach for the narrowest one that fits.

Command When to use Mental model Minimum Redis
FT.SEARCH Document retrieval, ranked or sorted. Best default. Returns matching docs directly. 2.0 (module) / 8.0 (built-in)
FT.AGGREGATE Faceting, computed fields, custom output shape, analytics. Declarative pipeline: LOAD, APPLY, GROUPBY, REDUCE, SORTBY. 2.0 / 8.0
FT.HYBRID Blend lexical (BM25) with vector similarity, with configurable fusion. Pipeline with explicit SEARCH + VSIM legs and a COMBINE fusion stage. 8.4.0
# FT.SEARCH — most common
FT.SEARCH idx:products "@category:{electronics} @price:[100 500]" LIMIT 0 20 RETURN 3 name price category

# FT.AGGREGATE — top categories by avg price
FT.AGGREGATE idx:products "*" GROUPBY 1 @category REDUCE AVG 1 @price AS avg_price SORTBY 2 @avg_price DESC

# FT.HYBRID (Redis ≥ 8.4) — lexical + vector fusion
FT.HYBRID idx:docs
  SEARCH "@title:transformers" SCORER BM25 YIELD_SCORE_AS lexscore
  VSIM embedding $vec KNN count 1 K 50 YIELD_SCORE_AS vecscore
  COMBINE RRF 2 CONSTANT 60
  PARAMS 2 vec "..."
  DIALECT 2

For Redis < 8.4 the lexical+vector blend is approximated with FT.SEARCH pre-filter + =>[KNN ...]. See references/command-selection.md and references/hybrid-search.md.

2. Schema basics — FT.CREATE

FT.CREATE indexes Hash or JSON documents matching a PREFIX. Always set PREFIX. Use DIALECT 2 (the default since Redis 8; required for vector queries).

FT.CREATE idx:products ON HASH PREFIX 1 product:
    SCHEMA
        name TEXT WEIGHT 2.0
        category TAG SORTABLE
        price NUMERIC SORTABLE
        location GEO
        embedding VECTOR HNSW 6
            TYPE FLOAT32
            DIM 1536
            DISTANCE_METRIC COSINE

Pick the narrowest field type that supports your access pattern:

Field type Use when Notes
TEXT Full-text search Tokenized + stemmed; not for exact match
TAG Exact match / filtering Add SORTABLE UNF for fastest tag queries
NUMERIC Range queries, sorting Prices, counts, timestamps
GEO Lat/long points Stores, users
GEOSHAPE Polygon / area queries Delivery zones, regions
VECTOR Similarity search HNSW or FLAT; see §4
JSON $.path AS alias Nested JSON fields ON JSON; see references/json-indexing.md

The classic mistake is TEXT for a category or status field "because it's a string" — TAG is roughly 10× faster for exact-match filtering.

See references/index-creation.md, references/field-types.md, references/dialect.md, references/ft-create-options.md, references/json-indexing.md.

3. Common queries

Narrow with filters; return only what you need.

# Tag filter + numeric range, sorted by price
FT.SEARCH idx:products "@category:{electronics} @price:[100 500]"
    SORTBY price ASC
    LIMIT 0 20
    RETURN 3 name price category

# Text + tag filter
FT.SEARCH idx:products "wireless headphones @category:{audio}"

# Negation and OR
FT.SEARCH idx:products "@category:{audio} -@brand:{generic} (@price:[0 100] | @on_sale:{true})"

Operators worth remembering: space = AND, | = OR, - = NOT, ~ = optional (scoring boost), =>{$weight: N} = boost. Escape hyphens and special characters inside TAG values (@sku:{ABC\\-123}). See references/query-syntax.md and references/search-syntax-primitives.md for the DSL vocabulary.

For tokenization gotchas (stemming, stopwords, language) see references/text-tokenization.md. For result shaping (SORTBY, RETURN, HIGHLIGHT, SUMMARIZE, NOCONTENT) see references/result-shaping.md. For performance levers (pre-filters, SORTABLE fields, tight RETURN, FT.PROFILE) see references/query-optimization.md.

4. Vector basics

Three vector settings have to match the embedding model exactly:

  • DIM — output dimensionality (e.g. 1536 for OpenAI text-embedding-3-small). Mismatch produces silent garbage.
  • DISTANCE_METRIC — COSINE for normalized text embeddings (common case), IP for unnormalized inner-product, L2 for raw Euclidean.
  • TYPE — usually FLOAT32. Use FLOAT16 or quantized variants only when memory is the binding constraint.
# Index
FT.CREATE idx:docs ON HASH PREFIX 1 doc:
    SCHEMA
        content TEXT
        embedding VECTOR HNSW 6 TYPE FLOAT32 DIM 1536 DISTANCE_METRIC COSINE

# Pure KNN query (top 5 by cosine similarity)
FT.SEARCH idx:docs "*=>[KNN 5 @embedding $vec AS score]"
    PARAMS 2 vec "..."
    SORTBY score
    DIALECT 2
Algorithm Speed Accuracy Memory Use for
HNSW Fast (approximate) ~95%+ recall (tunable) Higher Production: >10k vectors, latency-sensitive
FLAT Slow (exact) 100% Lower Small corpora (<10k), exact-match required

HNSW tuning levers: M (16–64, connections per node), EF_CONSTRUCTION (100–500, build quality), EF_RUNTIME (query-time candidate list).

See references/vector-query.md, references/algorithm-choice.md.

5. Hybrid retrieval

Two distinct patterns get called "hybrid." Pick by intent.

Filter-then-vector (any Redis version) — apply attribute filters so the engine narrows the search space before the vector comparison.

FT.SEARCH idx:docs "(@category:{tech} @date:[2024 +inf])=>[KNN 10 @embedding $vec AS score]"
    PARAMS 2 vec "..."
    SORTBY score
    DIALECT 2

Lexical + vector fusion (Redis ≥ 8.4) — blend BM25 text scoring with vector similarity, fuse with RRF or LINEAR. Use FT.HYBRID (see §1).

Don't fetch a wide unfiltered result and filter client-side — slower and less accurate. See references/hybrid-search.md.

6. Aggregations and shaping

FT.AGGREGATE is the declarative result-shaping command. Build a pipeline of stages.

# Top 5 categories by total revenue
FT.AGGREGATE idx:orders "@status:{shipped}"
    LOAD 2 @category @amount
    GROUPBY 1 @category
        REDUCE SUM 1 @amount AS revenue
    SORTBY 2 @revenue DESC
    LIMIT 0 5

Common stages: LOAD, APPLY (computed fields), FILTER (post-query), GROUPBY + REDUCE (SUM, COUNT, AVG, FIRST_VALUE, TOLIST), SORTBY, LIMIT.

For long-running result sets use WITHCURSOR + FT.CURSOR READ to page server-side. See references/aggregate-pipeline.md and references/aggregate-cursors.md.

7. RAG pattern

Standard pipeline: embed the query, vector-search Redis, pass top-K context to the LLM.

Practical tips:

  • Match the metric to the embedding model (almost always COSINE for normalized text models).
  • Chunk long documents (200–500-token chunks usually beat indexing whole pages).
  • Batch inserts rather than one call per record.
  • Pre-filter with attributes (tenant, recency, document type) before the vector search — see §5.
  • Re-rank at the top of the funnel if precision matters more than recall.

See references/rag-pattern.md.

8. Operations

Zero-downtime schema changes: keep app queries pointed at an alias and swap the underlying index.

FT.CREATE idx:products_v2 ON HASH PREFIX 1 product: SCHEMA ...
FT.ALIASUPDATE products idx:products_v2
# App queries are stable:
FT.SEARCH products "@category:{electronics}"

Useful management commands: FT.INFO, FT.DROPINDEX, FT._LIST, FT.ALIASADD/UPDATE/DEL. See references/index-management.md.

Debug empty or slow queries with FT.EXPLAIN (shows how the query was parsed) and FT.PROFILE (shows execution stats). See references/debugging.md.

9. Client examples

Inline examples in this SKILL.md are CLI / RESP form — the wire protocol every client serializes to. For idiomatic snippets in a specific client:

Other clients (Lettuce, node-redis, go-redis, NRedisStack, .NET) translate the same CLI form; coverage is tracked as a follow-up.

References

Files (agent-skills)
  • references
    • clients
      • java-jedis.md 50 KB
        
        # Jedis — Redis Search quick reference
        
        This reference covers the `FT.*` (Redis Search) surface of the Jedis client. It shows how Jedis *expresses* the canonical CLI form — it does not re-explain the query DSL. Read it after a reference that already states *what* to do.
        
        - **Query DSL vocabulary** (delimiters, operators, escaping): [`../search-syntax-primitives.md`](../search-syntax-primitives.md). Do not duplicate that grammar here.
        - **redis-py (Python) equivalents** for the same operations: [`python-redis-py.md`](./python-redis-py.md).
        - **RedisVL** is a Python SDK only; there is no Java equivalent. For Java targets, this is the reference.
        
        Examples below trace to specific files in `redis/jedis/src/test/java/io/redis/examples/` and the broader Jedis test suite, preserving the upstream `STEP_START`/`STEP_END` labels so you can pair-verify against the runnable Java source — and against the matching Python steps in [`python-redis-py.md`](./python-redis-py.md). The shared **Bicycle dataset** (`bicycle:<n>` JSON docs with `brand`, `model`, `description`, `price`, `condition`, `type`, `pickup_zone`, `store_location`, `description_embeddings`) is used throughout.
        
        **Async / reactive:** Jedis is sync-only by design. For non-blocking I/O on Redis Search, use Lettuce — out of scope for v1 of this reference.
        
        ## Table of contents
        
        1. [Minimum supported versions](#1-minimum-supported-versions)
        2. [Client class choice](#2-client-class-choice)
        3. [Connection setup](#3-connection-setup)
        4. [Schema imports](#4-schema-imports)
        5. [Create index — HASH](#5-create-index--hash)
        6. [Create index — JSON](#6-create-index--json)
        7. [FT.SEARCH idioms](#7-ftsearch-idioms)
        8. [FT.AGGREGATE idioms](#8-ftaggregate-idioms)
        9. [Cursors](#9-cursors)
        10. [Vector queries](#10-vector-queries)
        11. [FT.HYBRID](#11-fthybrid)
        12. [Debugging](#12-debugging)
        13. [Index management](#13-index-management)
        14. [Common errors & version gotchas](#14-common-errors--version-gotchas)
        15. [Upstream examples index](#15-upstream-examples-index)
        
        
        ## 1. Minimum supported versions
        
        redis-py equivalent: see [`python-redis-py.md#1-minimum-supported-versions`](./python-redis-py.md#1-minimum-supported-versions).
        
        | Component | Minimum | Notes |
        |-----------|---------|-------|
        | Jedis | **5.0** | 4.x predates the `redis.clients.jedis.search.schemafields.*` package and the fluent `SchemaField[]` API. Examples in this reference will not compile against 4.x. |
        | Jedis (FT.HYBRID high-level API) | **6.0** | `ftHybrid` / `FTHybridParams` are not in Jedis 5.x — fall back to `sendCommand(SearchCommand.HYBRID, ...)` (see §11). |
        | Redis server (FT.SEARCH / FT.AGGREGATE) | **7.4** | Redis Search ships built-in from Redis 8.0; on 7.4 the RediSearch module must be loaded. |
        | Redis server (`FT.HYBRID`) | **8.4.0** | Hard floor. `ftHybrid` returns `JedisDataException: unknown command 'FT.HYBRID'` on older Redis. Fall back to pre-filter + `=>[KNN ...]` via FT.SEARCH. |
        | Java | 8+ | Jedis 5.x targets Java 8 baseline; 6.x targets Java 11+. |
        
        **DIALECT default:** Jedis does **not** set DIALECT on your behalf. Every query in this reference passes DIALECT 2 explicitly via `FTSearchParams.searchParams().dialect(2)` or `AggregationBuilder.dialect(2)`. GEOSHAPE `WITHIN`/`CONTAINS` predicates require DIALECT 3.
        
        
        ## 2. Client class choice
        
        redis-py has a single client class (`redis.Redis`); no equivalent client-choice section — see [`python-redis-py.md#2-connection-setup`](./python-redis-py.md#2-connection-setup) for how the single class is constructed.
        
        Jedis has accumulated several entry points. Pick **one** per project and stay consistent — mixing them in the same codebase forces conversions and confuses readers.
        
        | Class | Use when… | Threading | Notes |
        |-------|-----------|-----------|-------|
        | `RedisClient` | **Current upstream default.** All examples under `redis/jedis/src/test/java/io/redis/examples` use this. Constructed via `RedisClient.create("redis://localhost:6379")`. | Internal pool; safe to share across threads. | Use this for new code. |
        | `UnifiedJedis` | Single sync client without a pool wrapper — useful for tests, scripts, or single-threaded callers. | Single connection; **not** thread-safe. | Parent class of `RedisClient` and `JedisPooled`; appears in internal test base classes. |
        | `JedisPooled` | Pre-`RedisClient` recommended pooled client. Still widely used in existing apps. | Internal `JedisPool`; thread-safe. | Functionally equivalent to `RedisClient` for FT.* calls. Don't rewrite working `JedisPooled` code just to swap names. |
        | `Jedis` (legacy) | A single raw connection, the original 4.x-era API. | One connection; **not** thread-safe. Must be returned to a pool or `close()`'d per use. | Avoid in new code. Many community blog posts still show this pattern. |
        | `JedisCluster` | Redis Cluster. | Cluster-aware pool. | FT.* indexes are not sharded across cluster slots — see Redis Search cluster docs before using. |
        
        **Migration path:** `Jedis` → `JedisPooled` (same API + connection management) → `RedisClient` (same API, current upstream name). All three accept the same `ftSearch`, `ftCreate`, `ftAggregate` etc. methods, so migration is mostly a constructor swap.
        
        **Divergence to flag:** upstream Jedis examples (`SearchQuickstartExample.java`) use `RedisClient.create("localhost", 6379)` while most public-internet blog posts and older Redis docs still show `UnifiedJedis` or `JedisPooled`. If you're porting code from those sources, the FT.* method names are identical — only the construction line differs.
        
        
        ## 3. Connection setup
        
        redis-py equivalent: see [`python-redis-py.md#2-connection-setup`](./python-redis-py.md#2-connection-setup).
        
        The canonical connect, from `SearchQuickstartExample.java` — STEP_START `connect`:
        
        ```java
        import redis.clients.jedis.RedisClient;
        
        RedisClient jedis = RedisClient.create("localhost", 6379);
        // or, URI form (used in QueryFtExample.java, QueryEmExample.java, etc.):
        RedisClient jedis2 = RedisClient.create("redis://localhost:6379");
        ```
        
        `RedisClient` carries an internal pool and is safe to share across threads. Use it as a long-lived field; do not create one per request. Always `close()` it at application shutdown (or use try-with-resources for short-lived scripts):
        
        ```java
        try (RedisClient jedis = RedisClient.create("redis://localhost:6379")) {
            // FT.* calls here
        }
        ```
        
        For TLS / auth, prefer the URI form: `redis://user:password@host:6379` or `rediss://...` for TLS. Configuration of pool size, timeouts, and SSL contexts goes through `DefaultJedisClientConfig.builder()` — out of scope here, see Jedis docs.
        
        
        ## 4. Schema imports
        
        redis-py equivalent: see [`python-redis-py.md#3-schema-imports`](./python-redis-py.md#3-schema-imports).
        
        The canonical Jedis import block for FT.* code, mirroring `SearchQuickstartExample.java` and `HomeJsonExample.java` STEP_START `import`:
        
        ```java
        import redis.clients.jedis.RedisClient;
        import redis.clients.jedis.exceptions.JedisDataException;
        import redis.clients.jedis.json.Path2;
        import redis.clients.jedis.search.*;                 // Query, SearchResult, Document,
                                                             // FTCreateParams, FTSearchParams,
                                                             // IndexDataType, RediSearchUtil
        import redis.clients.jedis.search.schemafields.*;    // TextField, TagField, NumericField,
                                                             // GeoField, GeoShapeField, VectorField
        import redis.clients.jedis.search.aggr.*;            // AggregationBuilder, AggregationResult,
                                                             // Reducers, SortedField, Row, Group
        import redis.clients.jedis.args.SortingOrder;        // ASC / DESC
        ```
        
        For FT.HYBRID (Redis ≥ 8.4.0, Jedis ≥ 6.0):
        
        ```java
        import redis.clients.jedis.search.Combiners;
        import redis.clients.jedis.search.Scorers;
        import redis.clients.jedis.search.hybrid.FTHybridParams;
        import redis.clients.jedis.search.hybrid.FTHybridSearchParams;
        import redis.clients.jedis.search.hybrid.FTHybridVectorParams;
        import redis.clients.jedis.search.hybrid.FTHybridPostProcessingParams;
        import redis.clients.jedis.search.hybrid.HybridResult;
        ```
        
        Notes:
        
        - The schema field classes live under `redis.clients.jedis.search.schemafields.*` — not `redis.clients.jedis.search.*`. Star-importing only `redis.clients.jedis.search.*` will miss them and produce confusing "cannot find symbol `TextField`" errors.
        - **Use `SchemaField[]` (the modern API) — not the deprecated `Schema` class.** `Schema sc = new Schema().addTextField(...)` still appears in older test bases and pre-5.0 docs; treat it as legacy. See §14 for the migration.
        - **Use `Path2` — not `Path`.** Both exist; `Path2` is the current one for `FT.*` JSON paths and `jsonSet` calls. All upstream examples use `Path2`.
        
        
        ## 5. Create index — HASH
        
        redis-py equivalent: see [`python-redis-py.md#4-create-index--hash`](./python-redis-py.md#4-create-index--hash).
        
        CLI form (from [`index-creation.md`](../index-creation.md)):
        
        ```
        FT.CREATE idx:bicycle ON HASH PREFIX 1 bicycle:
            SCHEMA
                model        TEXT WEIGHT 2.0
                description  TEXT
                brand        TAG
                condition    TAG
                price        NUMERIC SORTABLE
                store_location GEO
        ```
        
        Jedis — mirrors `HomeJsonExample.java` STEP_START `make_hash_index` (the upstream HASH-index example; the JSON variant is in §6):
        
        ```java
        // STEP_START create_index_hash
        SchemaField[] schema = {
            TextField.of("model").weight(2.0),
            TextField.of("description"),
            TagField.of("brand"),
            TagField.of("condition"),
            NumericField.of("price").sortable(),
            GeoField.of("store_location")
        };
        
        jedis.ftCreate("idx:bicycle",
            FTCreateParams.createParams()
                .on(IndexDataType.HASH)
                .addPrefix("bicycle:"),
            schema
        );
        // STEP_END
        ```
        
        HASH-specific notes:
        
        - Field names in the schema are the **hash field names verbatim** — no `$.` path, no `.as("alias")` call. The schema field's name *is* the alias.
        - Document keys must literally start with the declared prefix (`bicycle:1`, `bicycle:2`, …). An empty / missing prefix indexes every hash in the database.
        - Write documents with `jedis.hset("bicycle:1", Map.of(...))` — indexing happens synchronously on the write.
        
        
        ## 6. Create index — JSON
        
        redis-py equivalent: see [`python-redis-py.md#5-create-index--json`](./python-redis-py.md#5-create-index--json).
        
        CLI form:
        
        ```
        FT.CREATE idx:bicycle ON JSON PREFIX 1 bicycle:
            SCHEMA
                $.brand        AS brand        TEXT
                $.model        AS model        TEXT
                $.description  AS description  TEXT
                $.price        AS price        NUMERIC
                $.condition    AS condition    TAG
        ```
        
        Jedis — mirrors `SearchQuickstartExample.java` STEP_START `create_index`:
        
        ```java
        // STEP_START create_index_json
        SchemaField[] schema = {
            TextField.of("$.brand").as("brand"),
            TextField.of("$.model").as("model"),
            TextField.of("$.description").as("description"),
            NumericField.of("$.price").as("price"),
            TagField.of("$.condition").as("condition")
        };
        
        jedis.ftCreate("idx:bicycle",
            FTCreateParams.createParams()
                .on(IndexDataType.JSON)
                .addPrefix("bicycle:"),
            schema
        );
        // STEP_END
        ```
        
        JSON-specific notes:
        
        - `TextField.of("$.brand")` takes the **JSONPath**, not the alias. Always pair it with `.as("brand")`; the alias is what queries reference as `@brand`.
        - Without `.as(...)`, Redis auto-generates an alias from the path — usable but brittle (renaming the JSON key silently breaks the index).
        - Array projections: `TextField.of("$.tags[*]").as("tags")`. Nested objects: `TextField.of("$.address.city").as("city")`.
        - Add documents with `jedis.jsonSet("bicycle:1", Path2.ROOT_PATH, bicycleJson)` or `jsonSetWithEscape(...)` for a POJO that needs JSON-string-value escaping (used in `SearchQuickstartExample.java` STEP_START `add_documents`).
        
        
        ## 7. FT.SEARCH idioms
        
        redis-py equivalent: see [`python-redis-py.md#6-ftsearch-idioms`](./python-redis-py.md#6-ftsearch-idioms).
        
        For the query DSL itself (delimiters, operators, escaping), read [`../search-syntax-primitives.md`](../search-syntax-primitives.md). This section shows only how Jedis *binds* a query to `FT.SEARCH`.
        
        ### Two call shapes
        
        Jedis exposes two overloads:
        
        ```java
        // String-only — convenient for simple queries.
        SearchResult res = jedis.ftSearch("idx:bicycle", "@condition:{new}");
        
        // String + FTSearchParams — the full surface (filters, dialect, sort, return fields, paging).
        SearchResult res2 = jedis.ftSearch("idx:bicycle",
            "@condition:{new}",
            FTSearchParams.searchParams()
                .returnFields("brand", "model", "price")
                .sortBy("price", SortingOrder.ASC)
                .limit(0, 10)
                .dialect(2)
        );
        
        // Legacy: Query object (still supported; FTSearchParams is the modern path).
        Query q = new Query("@condition:{new}").returnFields("brand").dialect(2);
        SearchResult res3 = jedis.ftSearch("idx:bicycle", q);
        ```
        
        `FTSearchParams.searchParams()` is the modern fluent path used across all current upstream examples (`QueryRangeExample.java`, `QueryGeoExample.java`, `QueryEmExample.java`). Use it for new code; the `Query` class still works and is preserved for backward compatibility.
        
        | `FTSearchParams` method | CLI equivalent | Purpose |
        |-------------------------|----------------|---------|
        | `.limit(offset, num)` | `LIMIT offset num` | Result page slice. |
        | `.sortBy(field, SortingOrder.ASC)` | `SORTBY field ASC\|DESC` | Override score-based ranking. Requires the field declared `.sortable()` at index time. |
        | `.returnFields(f1, f2, ...)` | `RETURN n f1 f2 ...` | Project only listed fields. |
        | `.noContent()` | `NOCONTENT` | IDs only — pair with `LIMIT 0 0` for count-only queries. |
        | `.withScores()` | `WITHSCORES` | Append per-doc relevance score. |
        | `.verbatim()` | `VERBATIM` | Disable stemming. |
        | `.dialect(2)` | `DIALECT 2` | **Always pass this.** |
        | `.filter("field", min, max)` | `FILTER field min max` | Inline numeric range; alternative to `@field:[min max]` in the expression. |
        | `.addParam("name", value)` | `PARAMS n name value …` | Bind `$name` placeholders in the expression. |
        
        ### Exact match (TAG / NUMERIC) — mirrors `QueryEmExample.java`
        
        ```java
        // STEP_START em1 — numeric exact match via range with equal bounds
        SearchResult res1 = jedis.ftSearch("idx:bicycle", "@price:[270 270]");
        // Equivalent via FILTER (no inline range):
        SearchResult res2 = jedis.ftSearch("idx:bicycle", "*",
            FTSearchParams.searchParams().filter("price", 270, 270));
        
        // STEP_START em2 — tag exact match
        SearchResult res3 = jedis.ftSearch("idx:bicycle", "@condition:{new}");
        
        // STEP_START em4 — exact phrase in TEXT
        SearchResult res5 = jedis.ftSearch("idx:bicycle", "@description:\"rough terrain\"");
        ```
        
        ### Numeric ranges — mirrors `QueryRangeExample.java`
        
        ```java
        // STEP_START range1 — inclusive
        SearchResult res1 = jedis.ftSearch("idx:bicycle", "@price:[500 1000]",
            FTSearchParams.searchParams().returnFields("price").dialect(2));
        
        // STEP_START range3 — exclusive lower, unbounded upper, via FTSearchParams.filter
        SearchResult res3 = jedis.ftSearch("idx:bicycle", "*",
            FTSearchParams.searchParams()
                .returnFields("price")
                .filter("price", 1000, true, Double.POSITIVE_INFINITY, false)
                .dialect(2));
        
        // STEP_START range4 — sorted + paged
        SearchResult res4 = jedis.ftSearch("idx:bicycle", "@price:[-inf 2000]",
            FTSearchParams.searchParams()
                .returnFields("price")
                .sortBy("price", SortingOrder.ASC)
                .limit(0, 5)
                .dialect(2));
        ```
        
        `.filter(field, min, minExclusive, max, maxExclusive)` is Jedis's typed equivalent of `"@price:[(1000 +inf]"`. Pass `Double.POSITIVE_INFINITY` / `Double.NEGATIVE_INFINITY` for unbounded ends.
        
        ### Full-text idioms — mirrors `QueryFtExample.java`
        
        ```java
        // STEP_START ft1 — field-scoped term
        SearchResult res1 = jedis.ftSearch("idx:bicycle", "@description: kids");
        
        // STEP_START ft2 — prefix
        SearchResult res2 = jedis.ftSearch("idx:bicycle", "@model: ka*");
        
        // STEP_START ft3 — suffix (requires WITHSUFFIXTRIE at index time for efficiency)
        SearchResult res3 = jedis.ftSearch("idx:bicycle", "@brand: *bikes");
        
        // STEP_START ft4 — fuzzy (Levenshtein distance 1)
        SearchResult res4 = jedis.ftSearch("idx:bicycle", "%optamized%");
        
        // STEP_START ft5 — fuzzy distance 2 (double % per side)
        SearchResult res5 = jedis.ftSearch("idx:bicycle", "%%optamised%%");
        ```
        
        ### Geo — mirrors `QueryGeoExample.java`
        
        ```java
        // STEP_START geo1 — radius query, parameterised
        SearchResult res1 = jedis.ftSearch("idx:bicycle",
            "@store_location:[$lon $lat $radius $units]",
            FTSearchParams.searchParams()
                .addParam("lon", -0.1778)
                .addParam("lat", 51.5524)
                .addParam("radius", 20)
                .addParam("units", "mi")
                .dialect(2));
        
        // STEP_START geo2 — GEOSHAPE CONTAINS (requires DIALECT 3)
        SearchResult res2 = jedis.ftSearch("idx:bicycle",
            "@pickup_zone:[CONTAINS $bike]",
            FTSearchParams.searchParams()
                .addParam("bike", "POINT(-0.1278 51.5074)")
                .dialect(3));
        
        // STEP_START geo3 — GEOSHAPE WITHIN polygon
        SearchResult res3 = jedis.ftSearch("idx:bicycle",
            "@pickup_zone:[WITHIN $europe]",
            FTSearchParams.searchParams()
                .addParam("europe", "POLYGON((-25 35, 40 35, 40 70, -25 70, -25 35))")
                .dialect(3));
        ```
        
        Note that GEOSHAPE fields need `GeoShapeField.of("$.pickup_zone", GeoShapeField.CoordinateSystem.FLAT).as("pickup_zone")` in the schema (see `QueryGeoExample.java`).
        
        ### Reading results
        
        `SearchResult` exposes:
        
        ```java
        res.getTotalResults();              // server-reported match count (long)
        res.getDocuments();                 // List<Document>
        
        for (Document doc : res.getDocuments()) {
            doc.getId();                    // "bicycle:0"
            doc.getScore();                 // double, when .withScores() was set
            doc.getString("brand");         // typed field access
            doc.get("price");               // raw Object value
            doc.hasProperty("price");       // existence check
        }
        ```
        
        For HASH indexes, field values come back as `String`. For JSON indexes without `.returnFields(...)`, Jedis returns the whole JSON document under the `$` property (see `SearchQuickstartExample.java` STEP_START `query_single_term` output comments).
        
        
        ## 8. FT.AGGREGATE idioms
        
        redis-py equivalent: see [`python-redis-py.md#7-ftaggregate-idioms`](./python-redis-py.md#7-ftaggregate-idioms).
        
        For pipeline-stage ordering rules, see [`aggregate-pipeline.md`](../aggregate-pipeline.md). This section shows only the Jedis builder shape.
        
        ### The `AggregationBuilder`
        
        `AggregationBuilder("<filter-expression>")` is Jedis's `FT.AGGREGATE` shape, separate from `Query`. Fluent setters map directly to pipeline stages:
        
        | `AggregationBuilder` method | CLI stage |
        |-----------------------------|-----------|
        | `.load(field1, field2, ...)` | `LOAD n f1 f2 ...` |
        | `.apply("<expr>", "alias")` | `APPLY <expr> AS alias` (note: expression first, alias second — opposite of redis-py's keyword form) |
        | `.filter("<expr>")` | `FILTER <expr>` |
        | `.groupBy("@field", Reducers.X.as("alias"))` | `GROUPBY n field REDUCE ...` |
        | `.sortBy(SortedField.asc("@field"))` / `.sortBy(n, SortedField.desc("@field"))` | `SORTBY n <field> ASC\|DESC` |
        | `.limit(offset, num)` | `LIMIT offset num` |
        | `.cursor(count, maxIdleMs)` | `WITHCURSOR [COUNT n] [MAXIDLE ms]` (see §9) |
        | `.dialect(2)` | `DIALECT 2` |
        
        Reducers live in `redis.clients.jedis.search.aggr.Reducers` as static factory methods. Common ones:
        
        | Factory | CLI form |
        |---------|----------|
        | `Reducers.count()` | `REDUCE COUNT 0` |
        | `Reducers.count_distinct("@f")` | `REDUCE COUNT_DISTINCT 1 @f` |
        | `Reducers.sum("@f")` | `REDUCE SUM 1 @f` |
        | `Reducers.avg("@f")` | `REDUCE AVG 1 @f` |
        | `Reducers.min("@f")` / `Reducers.max("@f")` | `REDUCE MIN 1 @f` / `MAX 1 @f` |
        | `Reducers.quantile("@f", 0.95)` | `REDUCE QUANTILE 2 @f 0.95` |
        | `Reducers.to_list("@f")` | `REDUCE TOLIST 1 @f` |
        
        Every reducer takes `.as("alias")` to set the `AS <alias>` token.
        
        ### Worked pipeline — mirrors `QueryAggExample.java`
        
        ```java
        // STEP_START agg1 — LOAD + APPLY (no grouping)
        AggregationResult res1 = jedis.ftAggregate("idx:bicycle",
            new AggregationBuilder("@condition:{new}")
                .load("__key", "price")
                .apply("@price - (@price * 0.1)", "discounted")
                .dialect(2));
        // Rows: {__key=bicycle:0, discounted=243, price=270}, ...
        
        // STEP_START agg2 — APPLY + GROUPBY + REDUCE
        AggregationResult res2 = jedis.ftAggregate("idx:bicycle",
            new AggregationBuilder("*")
                .load("price")
                .apply("@price<1000", "price_category")
                .groupBy("@condition", Reducers.sum("@price_category").as("num_affordable"))
                .dialect(2));
        
        // STEP_START agg3 — synthesised group key via APPLY
        AggregationResult res3 = jedis.ftAggregate("idx:bicycle",
            new AggregationBuilder("*")
                .apply("'bicycle'", "type")
                .groupBy("@type", Reducers.count().as("num_total"))
                .dialect(2));
        // Rows: {type=bicycle, num_total=10}
        
        // STEP_START agg4 — GROUPBY + TOLIST
        AggregationResult res4 = jedis.ftAggregate("idx:bicycle",
            new AggregationBuilder("*")
                .load("__key")
                .groupBy("@condition", Reducers.to_list("__key").as("bicycles"))
                .dialect(2));
        ```
        
        Result shape: `AggregationResult.getRows()` returns `List<Row>`. Per row:
        
        ```java
        Row r = res2.getRows().get(0);
        r.getString("condition");       // "new"
        r.getLong("num_affordable");    // 3
        r.getDouble("avg_price");       // when applicable
        r.get("bicycles");              // raw value for TOLIST (ArrayList<String>)
        ```
        
        **Argument order gotcha:** `.apply(expression, alias)` puts the expression *first*. redis-py uses the opposite order via keyword: `apply(discounted="@price * 0.9")` — keyword *is* the alias. When porting from Python, swap.
        
        
        ## 9. Cursors
        
        redis-py equivalent: see [`python-redis-py.md#8-cursors`](./python-redis-py.md#8-cursors).
        
        For lifecycle rules and when to use cursors, see [`aggregate-cursors.md`](../aggregate-cursors.md).
        
        CLI form:
        
        ```
        FT.AGGREGATE idx:bicycle "*"
            GROUPBY 1 @brand REDUCE COUNT 0 AS n
            WITHCURSOR COUNT 1000 MAXIDLE 30000
            DIALECT 2
        
        FT.CURSOR READ idx:bicycle <cursor_id> COUNT 1000
        FT.CURSOR DEL  idx:bicycle <cursor_id>
        ```
        
        Jedis — open a cursor (`AggregationCommandsTestBase.java` STEP_START `cursor`):
        
        ```java
        // STEP_START aggregate_cursor_open
        AggregationBuilder ab = new AggregationBuilder("*")
            .groupBy("@brand", Reducers.count().as("n"))
            .sortBy(10, SortedField.desc("@n"))
            .cursor(1000, 30000)            // COUNT 1000, MAXIDLE 30000 ms
            .dialect(2);
        
        AggregationResult page = jedis.ftAggregate("idx:bicycle", ab);
        long cursorId = page.getCursorId();
        // STEP_END
        ```
        
        Read subsequent pages:
        
        ```java
        // STEP_START aggregate_cursor_read
        while (cursorId != 0) {             // 0 signals exhausted server-side cursor
            page = jedis.ftCursorRead("idx:bicycle", cursorId, 1000);
            cursorId = page.getCursorId();
            process(page.getRows());
        }
        // STEP_END
        ```
        
        Explicit cleanup (release before MAXIDLE):
        
        ```java
        // STEP_START aggregate_cursor_del
        jedis.ftCursorDel("idx:bicycle", cursorId);
        // STEP_END
        ```
        
        **Higher-level helper.** Jedis also exposes `ftAggregateIteration(...)` which encapsulates the cursor loop and exposes `nextBatch()` / `collect(...)`, mirroring `AggregationCommandsTestBase.java` `aggregateIteration` test:
        
        ```java
        FtAggregateIteration it = jedis.ftAggregateIteration("idx:bicycle", ab);
        while (!it.isIterationCompleted()) {
            AggregationResult batch = it.nextBatch();
            process(batch.getRows());
        }
        ```
        
        Use the helper for straightforward "drain to the end" cases; fall back to manual `ftCursorRead` / `ftCursorDel` when you need per-batch flow control or explicit cleanup on cancellation.
        
        
        ## 10. Vector queries
        
        redis-py equivalent: see [`python-redis-py.md#9-vector-queries`](./python-redis-py.md#9-vector-queries).
        
        For query-attribute syntax (`=>[KNN ...]`, `[VECTOR_RANGE ...]`) and pre-filter shape, read [`vector-query.md`](../vector-query.md).
        
        **Note on upstream sourcing.** `VectorSetExample.java` in `redis/jedis/src/test/java/io/redis/examples` demonstrates the Redis **Vector Set** data type (`VADD`, `VSIM`) — a separate feature, **not** FT.* vector indexing. The canonical FT.* vector tests live in `redis/jedis/src/test/java/redis/clients/jedis/commands/unified/search/SearchWithParamsCommandsTestBase.java` (methods `testHNSWVectorSimilarity`, `testFlatVectorSimilarity`, `vectorSearchProfile`). Examples below mirror those.
        
        ### Index a vector field
        
        CLI form:
        
        ```
        FT.CREATE idx:bicycle ON JSON PREFIX 1 bicycle: SCHEMA
            ...
            $.description_embeddings AS vector VECTOR FLAT 6
                TYPE FLOAT32 DIM 1536 DISTANCE_METRIC COSINE
        ```
        
        Jedis — mirrors `SearchWithParamsCommandsTestBase.java` `testHNSWVectorSimilarity` adapted to the bicycle schema (dim 1536 matches OpenAI `text-embedding-3-small` / `ada-002`):
        
        ```java
        // STEP_START create_vector_index
        import redis.clients.jedis.search.schemafields.VectorField;
        import redis.clients.jedis.search.schemafields.VectorField.VectorAlgorithm;
        
        int VECTOR_DIMENSION = 1536;        // match your embedding model
        
        Map<String, Object> vectorAttrs = new HashMap<>();
        vectorAttrs.put("TYPE", "FLOAT32");
        vectorAttrs.put("DIM", VECTOR_DIMENSION);
        vectorAttrs.put("DISTANCE_METRIC", "COSINE");
        
        SchemaField[] schema = {
            TextField.of("$.model").noStem().as("model"),
            TextField.of("$.brand").noStem().as("brand"),
            NumericField.of("$.price").as("price"),
            TagField.of("$.type").as("type"),
            VectorField.builder()
                .fieldName("$.description_embeddings")
                .algorithm(VectorAlgorithm.FLAT)        // or HNSW for ANN
                .attributes(vectorAttrs)
                .build()
                .as("vector")
        };
        
        jedis.ftCreate("idx:bicycle",
            FTCreateParams.createParams()
                .on(IndexDataType.JSON)
                .addPrefix("bicycle:"),
            schema);
        // STEP_END
        ```
        
        ### Encode the query vector
        
        The de facto pattern — `float[]` → little-endian `byte[]`. Jedis ships a helper: `RediSearchUtil.toByteArray(float[])`:
        
        ```java
        import redis.clients.jedis.search.RediSearchUtil;
        
        byte[] queryBytes = RediSearchUtil.toByteArray(embedding);   // dim 1536 float[]
        ```
        
        Equivalent explicit form (mirrors `FTHybridCommandsTestBase.java` `floatArrayToByteArray` and the `redis-py` convention):
        
        ```java
        static byte[] floatArrayToByteArray(float[] floats) {
            ByteBuffer buf = ByteBuffer.allocate(floats.length * 4).order(ByteOrder.LITTLE_ENDIAN);
            for (float f : floats) buf.putFloat(f);
            return buf.array();
        }
        ```
        
        `FLOAT32` little-endian is the only encoding `redis-py` and Jedis ship with — match this on both index and query side, every time. A `double[]` (or big-endian buffer) silently produces zero hits because per-element byte offsets disagree with the index's `TYPE FLOAT32`.
        
        ### KNN — mirrors `SearchWithParamsCommandsTestBase.java` `testHNSWVectorSimilarity`
        
        ```java
        // STEP_START vector_knn
        FTSearchParams searchParams = FTSearchParams.searchParams()
            .addParam("query_vector", queryBytes)
            .sortBy("vector_score", SortingOrder.ASC)
            .returnFields("vector_score", "brand", "model", "description")
            .dialect(2);
        
        SearchResult res = jedis.ftSearch("idx:bicycle",
            "(*)=>[KNN 3 @vector $query_vector AS vector_score]",
            searchParams);
        // STEP_END
        ```
        
        `AS vector_score` aliases the distance field — sort by it and return it just like any other field.
        
        ### Pre-filtered KNN — mirrors the `query_combined.py` shape from redis-py
        
        ```java
        // STEP_START vector_prefilter  (pre-Redis-8.4 hybrid pattern — for native blended ranking see §11)
        SearchResult res = jedis.ftSearch("idx:bicycle",
            "(@price:[500 1000] -@condition:{new})=>[KNN 3 @vector $query_vector AS vector_score]",
            FTSearchParams.searchParams()
                .addParam("query_vector", queryBytes)
                .sortBy("vector_score", SortingOrder.ASC)
                .returnFields("vector_score", "brand", "model", "price")
                .dialect(2));
        // STEP_END
        ```
        
        The pre-filter `(@price:[500 1000] -@condition:{new})` is applied **before** the KNN scan — it shrinks the candidate set HNSW/FLAT has to walk. Forgetting it is the most common cause of slow vector queries.
        
        ### Range — mirrors `SearchWithParamsCommandsTestBase.java` vector range pattern
        
        ```java
        // STEP_START vector_range
        SearchResult res = jedis.ftSearch("idx:bicycle",
            "@vector:[VECTOR_RANGE $range $query_vector]=>{$YIELD_DISTANCE_AS: vector_score}",
            FTSearchParams.searchParams()
                .addParam("range", 0.55)
                .addParam("query_vector", queryBytes)
                .sortBy("vector_score", SortingOrder.ASC)
                .returnFields("vector_score", "brand", "model", "description")
                .limit(0, 4)
                .dialect(2));
        // STEP_END
        ```
        
        `AS <alias>` (KNN form) and `$YIELD_DISTANCE_AS: <alias>` (RANGE form) are not interchangeable — the syntax differs by query type.
        
        ### HNSW tuning per-query
        
        `EF_RUNTIME` is an in-query attribute on the KNN tail:
        
        ```java
        jedis.ftSearch("idx:bicycle",
            "*=>[KNN 10 @vector $query_vector EF_RUNTIME 200 AS score]",
            FTSearchParams.searchParams().addParam("query_vector", queryBytes).dialect(2));
        ```
        
        Index-time `EF_CONSTRUCTION` lives in the `VectorField` attributes map and is independent of `EF_RUNTIME`.
        
        
        ## 11. FT.HYBRID
        
        redis-py equivalent: see [`python-redis-py.md#10-fthybrid`](./python-redis-py.md#10-fthybrid).
        
        **Version gate:** `FT.HYBRID` requires Redis ≥ **8.4.0**. On older Redis use the pre-filter + KNN pattern in §10. See [`command-selection.md`](../command-selection.md) for the SEARCH vs AGGREGATE vs HYBRID decision.
        
        **Jedis client gate:** the high-level `ftHybrid` method requires **Jedis 6.x**. Jedis 5.x users must use the `sendCommand` fallback shown at the bottom of this section.
        
        ### High-level builder
        
        Jedis 6.x ships a high-level `ftHybrid` method backed by `FTHybridParams.builder()`. Pattern: build a `FTHybridSearchParams` (text leg) + `FTHybridVectorParams` (vector leg), combine with a `Combiners.rrf()` or `Combiners.linear()`, optionally add a `FTHybridPostProcessingParams` for `LOAD` / `GROUPBY` / `APPLY` / `SORTBY` / `FILTER` / `LIMIT` stages. Mirrors `FTHybridCommandsTestBase.java` `testComprehensiveFtHybridWithAllFeatures`:
        
        ```java
        // STEP_START run_hybrid_query_native
        import redis.clients.jedis.search.Combiners;
        import redis.clients.jedis.search.Scorers;
        import redis.clients.jedis.search.hybrid.*;
        import redis.clients.jedis.search.aggr.Group;
        import redis.clients.jedis.search.aggr.Reducers;
        import redis.clients.jedis.search.aggr.SortedField;
        import redis.clients.jedis.search.Apply;
        import redis.clients.jedis.search.Filter;
        import redis.clients.jedis.search.Limit;
        
        FTHybridPostProcessingParams postProcessing = FTHybridPostProcessingParams.builder()
            .load("price", "brand", "@category")
            .groupBy(new Group("@brand")
                .reduce(Reducers.sum("@price").as("sum"))
                .reduce(Reducers.count().as("count")))
            .apply(Apply.of("@sum * 0.9", "discounted_price"))
            .sortBy(SortedField.asc("@sum"), SortedField.desc("@count"))
            .filter(Filter.of("@sum > 700"))
            .limit(Limit.of(0, 20))
            .build();
        
        FTHybridParams hybridArgs = FTHybridParams.builder()
            .search(FTHybridSearchParams.builder()
                .query("@category:{electronics} smartphone camera")
                .scorer(Scorers.bm25std())
                .scoreAlias("text_score")
                .build())
            .vectorSearch(FTHybridVectorParams.builder()
                .field("@image_embedding")
                .vector("vector")                          // param name, bound below
                .method(FTHybridVectorParams.Knn.of(20).efRuntime(150))
                .filter("(@brand:{apple|samsung|google}) (@price:[500 1500])")
                .scoreAlias("vector_score")
                .build())
            .combine(Combiners.linear().alpha(0.7).beta(0.3).window(25))   // or Combiners.rrf().window(60)
            .postProcessing(postProcessing)
            .param("vector", queryBytes)
            .build();
        
        HybridResult reply = jedis.ftHybrid("idx:products", hybridArgs);
        
        reply.getTotalResults();
        reply.getDocuments();             // List<Document>
        reply.getExecutionTime();         // server-side timing (double, ms)
        reply.getWarnings();
        // STEP_END
        ```
        
        ### Combine methods
        
        | Factory | CLI emitted | When to use |
        |---------|-------------|-------------|
        | `Combiners.rrf()` | `COMBINE RRF count [CONSTANT c] [WINDOW w]` | **Default for blended ranking.** Reciprocal Rank Fusion — rank-based, robust without weight tuning. Knobs: `.window(int)`, `.constant(double)` (typically 60). |
        | `Combiners.linear()` | `COMBINE LINEAR count [ALPHA a] [BETA b] [WINDOW w]` | Weighted score blend. Needs `.alpha(double)` / `.beta(double)` tuned to your scorer scales. |
        
        Both expose `.as("alias")` to alias the final combined score.
        
        ### Important behaviours
        
        - `ftHybrid` is annotated `@Experimental` in Jedis. Pin your Jedis minor version if you depend on it in production; the builder API may shift between minors.
        - `FTHybridSearchParams` and `FTHybridVectorParams` use different builders — the search leg owns the text query and scorer; the vector leg owns the vector field, KNN/range method, optional internal `.filter(...)` (applied before the vector scan), and per-leg `.scoreAlias(...)`.
        - `FTHybridVectorParams.Knn.of(int)` sets `K`; chain `.efRuntime(int)` to tune HNSW per query.
        - Vector blob is bound by name via the top-level `.param("vector", byte[])` — same `PARAMS`-binding mechanism as FT.SEARCH.
        - `FTHybridPostProcessingParams.load(...)`-returned field values may come back as `byte[]` rather than `String` depending on protocol and field type — defensive callers should check with `instanceof` before casting (mirrors the `redis-py` HYBRID gotcha).
        
        ### Raw `sendCommand` fallback (Jedis 5.x or features missing from the builder)
        
        For Jedis 5.x callers — or features that have not yet landed in the high-level builder — drop to the **binary** `sendCommand` overload. Encoding the vector through `new String(bytes, ISO_8859_1)` is lossy on RESP3 and corrupts certain byte values; pass the raw `byte[]` to `sendCommand(ProtocolCommand, byte[]...)` instead:
        
        ```java
        import redis.clients.jedis.search.SearchProtocol.SearchCommand;
        import redis.clients.jedis.util.SafeEncoder;
        
        byte[] queryBytes = RediSearchUtil.toByteArray(embedding);   // FLOAT32 little-endian
        
        // UnifiedJedis (parent of RedisClient / JedisPooled) exposes sendCommand(ProtocolCommand, byte[]...).
        Object raw = ((UnifiedJedis) jedis).sendCommand(
            SearchCommand.HYBRID,
            SafeEncoder.encode("idx:products"),
            SafeEncoder.encode("SEARCH"),      SafeEncoder.encode("laptop"),
            SafeEncoder.encode("VSIM"),        SafeEncoder.encode("@description_vector"),
                                               SafeEncoder.encode("$query_vec"),
            SafeEncoder.encode("KNN"),         SafeEncoder.encode("2"),
                                               SafeEncoder.encode("K"), SafeEncoder.encode("10"),
            SafeEncoder.encode("COMBINE"),     SafeEncoder.encode("RRF"),
                                               SafeEncoder.encode("2"),
                                               SafeEncoder.encode("WINDOW"), SafeEncoder.encode("100"),
            SafeEncoder.encode("PARAMS"),      SafeEncoder.encode("2"),
                                               SafeEncoder.encode("query_vec"),
                                               queryBytes,                              // raw vector — DO NOT round-trip through String
            SafeEncoder.encode("DIALECT"),     SafeEncoder.encode("2")
        );
        ```
        
        Key points:
        
        - Use `SearchCommand.HYBRID` (`redis.clients.jedis.search.SearchProtocol.SearchCommand`) rather than an ad-hoc `ProtocolCommand` anonymous class — it's the canonical enum and survives upstream renames.
        - Use `((UnifiedJedis) jedis).sendCommand(ProtocolCommand, byte[]...)` (the **binary** varargs form, defined on `UnifiedJedis`). The `String...` overload silently UTF-8-encodes its arguments and **mangles vector bytes** on the wire.
        - `SafeEncoder.encode(String)` is Jedis's canonical UTF-8 string→`byte[]` helper — use it for every text argument so the wire bytes match what the high-level API would emit.
        - The vector `byte[]` is passed in directly; no `new String(queryBytes, ISO_8859_1)` round-trip.
        
        The raw shape mirrors the verified syntax in spec 0001 §5.0a. Use it only when the high-level `ftHybrid` builder lacks a flag you need — and consider opening an issue upstream once you confirm the gap.
        
        Upstream sources: `src/main/java/redis/clients/jedis/search/hybrid/FTHybridParams.java`, `src/main/java/redis/clients/jedis/search/Combiners.java`, `src/test/java/redis/clients/jedis/commands/unified/search/FTHybridCommandsTestBase.java`.
        
        
        ## 12. Debugging
        
        redis-py equivalent: see [`python-redis-py.md#11-debugging`](./python-redis-py.md#11-debugging).
        
        For interpreting `FT.EXPLAIN` and `FT.PROFILE` output, see [`debugging.md`](../debugging.md).
        
        ### `FT.EXPLAIN`
        
        ```java
        // Pass either a Query object or a raw query string.
        String plan = jedis.ftExplain("idx:bicycle",
            new Query("(@brand:{Velorim}) @price:[100 500]").dialect(2));
        System.out.println(plan);
        // INTERSECT {
        //   TAG:@brand { Velorim }
        //   NUMERIC {100.000000 <= @price <= 500.000000}
        // }
        ```
        
        The output is the server's parse tree — useful for spotting unexpected stemming, tokenization, or operator-precedence surprises.
        
        ### `FT.PROFILE`
        
        ```java
        import redis.clients.jedis.search.FTProfileParams;
        import redis.clients.jedis.search.ProfilingInfo;
        
        Map.Entry<SearchResult, ProfilingInfo> reply = jedis.ftProfileSearch("idx:bicycle",
            FTProfileParams.profileParams(),
            "@brand:{Velorim}",
            FTSearchParams.searchParams().dialect(2));
        
        SearchResult result = reply.getKey();
        Object profile = reply.getValue().getProfilingInfo();   // shape depends on protocol (RESP2/RESP3)
        ```
        
        For aggregations:
        
        ```java
        Map.Entry<AggregationResult, ProfilingInfo> aggReply = jedis.ftProfileAggregate("idx:bicycle",
            FTProfileParams.profileParams(),
            new AggregationBuilder("*").groupBy("@brand", Reducers.count().as("n")).dialect(2));
        ```
        
        The `ProfilingInfo` payload is protocol-shaped: on RESP3 it's a `Map<String, Object>` with `Shards` / `Coordinator` top-level keys (Redis 8+); on RESP2 it's a nested `List`. Cast accordingly — see `SearchWithParamsCommandsTestBase.java` `vectorSearchProfile` for the pattern.
        
        ### `FT.INFO`
        
        ```java
        Map<String, Object> info = jedis.ftInfo("idx:bicycle");
        info.get("index_name");                  // "idx:bicycle"
        info.get("num_docs");                    // server-stringified counts; cast as needed
        info.get("hash_indexing_failures");      // non-zero = silent dropouts (schema mismatch)
        info.get("attributes");                  // List of per-field detail maps
        info.get("inverted_sz_mb");              // memory footprint
        info.get("indexing");                    // 1 while background scan runs
        info.get("percent_indexed");             // 0.0 – 1.0
        ```
        
        `ftInfo` returns a `Map<String, Object>` because the server's reply mixes scalars, lists, and maps. Treat any numeric you read from it as protocol-dependent: RESP2 typically gives strings, RESP3 typed values. Cast defensively.
        
        Key fields to monitor:
        
        | Key | Why it matters |
        |-----|----------------|
        | `num_docs` | Docs successfully indexed. |
        | `hash_indexing_failures` | **Non-zero means silent dropouts** — usually schema/path mismatches. |
        | `inverted_sz_mb` | Inverted-index memory footprint. |
        | `indexing` | `1` while a background scan is running. |
        | `percent_indexed` | Progress of the background scan. |
        
        
        ## 13. Index management
        
        redis-py equivalent: see [`python-redis-py.md#12-index-management`](./python-redis-py.md#12-index-management).
        
        For semantics (FT.ALTER capacity, alias use cases), see [`index-management.md`](../index-management.md).
        
        ### Add fields
        
        ```java
        jedis.ftAlter("idx:bicycle",
            TagField.of("availability"),
            TextField.of("name").weight(0.5));
        ```
        
        `ftAlter` accepts a varargs of `SchemaField`. Mirrors `SearchWithParamsCommandsTestBase.java` `alter` test. Subject to `MAXTEXTFIELDS` capacity declared at FT.CREATE time. There is no `FT.ALTER` for removing or retyping a field — drop and recreate the index.
        
        ### Aliases (for blue/green index swaps)
        
        ```java
        jedis.ftAliasAdd("idx:bicycle:active", "idx:bicycle_v2");
        jedis.ftAliasUpdate("idx:bicycle:active", "idx:bicycle_v2");    // repoint
        jedis.ftAliasDel("idx:bicycle:active");
        ```
        
        Argument order is **`(alias, indexName)`** — Jedis aliases come first, the underlying index second. Mirrors `SearchWithParamsCommandsTestBase.java` `alias` test. Aliases let application code query a stable name while you build a replacement index behind it.
        
        ### Drop the index
        
        ```java
        // Keep documents, drop only the index
        jedis.ftDropIndex("idx:bicycle");
        
        // Drop index AND delete every indexed document (destructive)
        jedis.ftDropIndexDD("idx:bicycle");
        ```
        
        `ftDropIndexDD` is the equivalent of `FT.DROPINDEX ... DD` — gone forever, no undo. The double-D in the name signals "drop the docs too."
        
        ### List indexes
        
        ```java
        Set<String> all = jedis.ftList();
        ```
        
        
        ## 14. Common errors & version gotchas
        
        redis-py equivalent: see [`python-redis-py.md#13-common-errors--version-gotchas`](./python-redis-py.md#13-common-errors--version-gotchas).
        
        | Symptom | Likely cause | Fix |
        |---------|--------------|-----|
        | `JedisDataException: unknown command 'FT.CREATE'` (or any other `FT.*`) | Redis < 8.0 without the RediSearch module loaded. | Load the module (`MODULE LOAD /path/to/redisearch.so` or via `loadmodule` in `redis.conf`), or upgrade to Redis ≥ 8.0 where Redis Search is built-in. |
        | `JedisDataException: unknown command 'FT.HYBRID'` | Server < 8.4.0. | Upgrade or fall back to pre-filter + KNN via FT.SEARCH (§10). |
        | `Syntax error at offset N near KNN` | Missing DIALECT 2. | `FTSearchParams.searchParams().dialect(2)` on every vector query and every modern parser feature. |
        | GEOSHAPE WITHIN/CONTAINS returns syntax error | Missing `.dialect(3)`, or server lacks DIALECT 3 support. | Pass `.dialect(3)` explicitly; ensure Redis ≥ 7.2 with GEOSHAPE-capable RediSearch. |
        | `JedisDataException: Vector dimension mismatch` | Query vector dim differs from index `DIM`. | Recompute embedding with the same model used at index time; assert `embedding.length == DIM`. |
        | Vector query returns 0 hits despite obvious matches | Query vector encoded big-endian or as `double[]`. | Use `RediSearchUtil.toByteArray(float[])` or `ByteBuffer.allocate(n*4).order(LITTLE_ENDIAN).putFloat(...)`. |
        | `cannot find symbol: class TextField` | Missing `import redis.clients.jedis.search.schemafields.*;` — `redis.clients.jedis.search.*` does not pull in field types. | Add the `schemafields.*` import explicitly. See §4. |
        | `JedisDataException: Index already exists` | `ftCreate` is not "create or replace." | Wrap idempotent setup in try/catch on `JedisDataException`, or `ftDropIndex` first when bootstrapping. |
        | JSON paths not matching docs | Document set with `jedis.jsonSet(...)` but index defined `ON HASH` (or vice versa). | Match `IndexDataType` to write path. `ftInfo`'s `hash_indexing_failures > 0` is the signal. |
        | `Cannot resolve method 'addTextField'` after upgrade to Jedis 5.x | Code uses deprecated `Schema` class. | Migrate to `SchemaField[]` (see migration below). |
        | `Cannot resolve symbol 'Path'` after upgrade | Code uses `redis.clients.jedis.json.Path`. | Switch to `redis.clients.jedis.json.Path2` — current path API for FT.* and JSON commands. |
        | Empty `getDocuments()` but non-zero `getTotalResults()` | `.noContent()` was set. | Remove `.noContent()` or call `.returnFields(...)`. |
        
        ### `Schema` → `SchemaField[]` migration
        
        Older Jedis code (pre-5.x, or community blog posts) uses the `Schema` class:
        
        ```java
        // LEGACY — Schema class, do not use in new code.
        Schema sc = new Schema()
            .addSortableTextField("name", 1.0)
            .addSortableNumericField("count")
            .addTagField("tags");
        jedis.ftCreate(INDEX, IndexOptions.defaultOptions(), sc);
        ```
        
        Current API:
        
        ```java
        // MODERN — SchemaField[] + FTCreateParams.
        SchemaField[] schema = {
            TextField.of("name").weight(1.0).sortable(),
            NumericField.of("count").sortable(),
            TagField.of("tags")
        };
        jedis.ftCreate(INDEX, FTCreateParams.createParams(), schema);
        ```
        
        Translation rules:
        - `addTextField(name, weight)` → `TextField.of(name).weight(weight)`
        - `addSortableTextField(...)` → `.sortable()` chained
        - `addNumericField(name)` → `NumericField.of(name)`
        - `addTagField(name)` → `TagField.of(name)`
        - `addVectorField(name, algo, attrs)` → `VectorField.builder().fieldName(name).algorithm(algo).attributes(attrs).build()`
        - `IndexOptions.defaultOptions()` → `FTCreateParams.createParams()` (then chain `.on(IndexDataType.JSON)`, `.addPrefix(...)`, etc.)
        
        ### `Path` vs `Path2`
        
        | Class | Status | Use it |
        |-------|--------|--------|
        | `redis.clients.jedis.json.Path` | Legacy | No |
        | `redis.clients.jedis.json.Path2` | Current | **Yes** — for `jsonSet`, `jsonGet`, `jsonDel`, and any `$.*` paths the FT.* schema references. All upstream examples use `Path2`. |
        
        
        ## 15. Upstream examples index
        
        redis-py equivalent: see [`python-redis-py.md#14-upstream-examples-index`](./python-redis-py.md#14-upstream-examples-index).
        
        Curated index of `STEP_START` labels in `redis/jedis/src/test/java/io/redis/examples/` and the broader Jedis FT.* test suite, so you can fetch the runnable Java source by step name. Step labels match the redis-py reference where the two clients cover the same operation — pair them up to verify cross-language behaviour.
        
        | Step label | Operation | Upstream file |
        |------------|-----------|---------------|
        | `connect` | `RedisClient.create("localhost", 6379)` | `SearchQuickstartExample.java` |
        | `create_index` (bicycle JSON) | JSON schema with TEXT/TAG/NUMERIC + `.as(alias)` | `SearchQuickstartExample.java` |
        | `add_documents` | `jsonSetWithEscape(key, bicyclePojo)` | `SearchQuickstartExample.java` |
        | `wildcard_query` | `Query("*")` | `SearchQuickstartExample.java` |
        | `query_single_term` | `Query("@model:Jigger")` | `SearchQuickstartExample.java` |
        | `query_single_term_limit_fields` | `Query("@model:Jigger").returnFields("price")` | `SearchQuickstartExample.java` |
        | `query_single_term_and_num_range` | `Query("basic @price:[500 1000]")` | `SearchQuickstartExample.java` |
        | `query_exact_matching` | `Query("@brand:\"Noka Bikes\"")` | `SearchQuickstartExample.java` |
        | `simple_aggregation` | `AggregationBuilder("*").groupBy(...).count()` | `SearchQuickstartExample.java` |
        | `import` | Canonical import block | `HomeJsonExample.java` |
        | `make_index` | JSON index for users | `HomeJsonExample.java` |
        | `make_hash_index` | HASH index, same fields without `$.` paths | `HomeJsonExample.java` |
        | `add_data` | `jedis.jsonSet(key, Path2.ROOT_PATH, doc)` | `HomeJsonExample.java` |
        | `query1` | `ftSearch("idx:users", "Paul @age:[30 40]")` | `HomeJsonExample.java` |
        | `query2` | `FTSearchParams.searchParams().returnFields("city")` | `HomeJsonExample.java` |
        | `query3` | `AggregationBuilder("*").groupBy("@city", Reducers.count().as("count"))` | `HomeJsonExample.java` |
        | `em1` | Numeric exact match `@price:[270 270]` + `.filter("price", 270, 270)` | `QueryEmExample.java` |
        | `em2` | TAG exact match `@condition:{new}` | `QueryEmExample.java` |
        | `em3` | Escaping `@email` via `RediSearchUtil.escapeQuery` | `QueryEmExample.java` |
        | `em4` | Exact phrase `@description:"rough terrain"` | `QueryEmExample.java` |
        | `ft1`–`ft5` | Field-scoped term, prefix, suffix, fuzzy `%term%`, double-fuzzy `%%term%%` | `QueryFtExample.java` |
        | `range1` | Inclusive `@price:[500 1000]` | `QueryRangeExample.java` |
        | `range2` | `.filter("price", 500, 1000)` form | `QueryRangeExample.java` |
        | `range3` | `.filter("price", 1000, true, +inf, false)` (exclusive lower) | `QueryRangeExample.java` |
        | `range4` | Range + `.sortBy(..., ASC).limit(0, 5)` | `QueryRangeExample.java` |
        | `geo1` | Geo radius parameterised via `.addParam` | `QueryGeoExample.java` |
        | `geo2` | `GEOSHAPE CONTAINS` with `.dialect(3)` | `QueryGeoExample.java` |
        | `geo3` | `GEOSHAPE WITHIN` polygon | `QueryGeoExample.java` |
        | `agg1` | `LOAD` + `APPLY` (no grouping) | `QueryAggExample.java` |
        | `agg2` | `APPLY` + `GROUPBY` + `Reducers.sum` | `QueryAggExample.java` |
        | `agg3` | Synthesised group key via `.apply("'bicycle'", "type")` | `QueryAggExample.java` |
        | `agg4` | `GROUPBY` + `Reducers.to_list("__key")` | `QueryAggExample.java` |
        | `aggregate_cursor_open` (in-doc; pairs with redis-py `aggregate_cursor_open`) | `.cursor(count, maxIdle)` opens the cursor; `getCursorId()` reads it | `AggregationCommandsTestBase.java` (`cursor()` test) |
        | `aggregate_cursor_read` (in-doc; pairs with redis-py `aggregate_cursor_read`) | `ftCursorRead(index, cursorId, count)` page loop | `AggregationCommandsTestBase.java` (`cursor()` test) |
        | `aggregate_cursor_del` (in-doc; pairs with redis-py `aggregate_cursor_del`) | `ftCursorDel(index, cursorId)` explicit release | `AggregationCommandsTestBase.java` (`cursor()` test) |
        | `aggregateIteration` (upstream method name) | `ftAggregateIteration(...)` higher-level loop helper | `AggregationCommandsTestBase.java` |
        | `vector_knn` (in-doc; pairs with redis-py `vector_knn`) | `(*)=>[KNN 3 @vector $query_vector AS vector_score]` | `SearchWithParamsCommandsTestBase.java` (`testHNSWVectorSimilarity`) |
        | `vector_prefilter` (in-doc; pairs with redis-py `vector_prefilter`) | `(@price:[…] -@condition:{new})=>[KNN 3 @vector $query_vector …]` | `SearchWithParamsCommandsTestBase.java` (`testHNSWVectorSimilarity`) |
        | `vector_range` (in-doc; pairs with redis-py `vector_range`) | `@vector:[VECTOR_RANGE $range $query_vector]=>{$YIELD_DISTANCE_AS: …}` | `SearchWithParamsCommandsTestBase.java` |
        | `testHNSWVectorSimilarity` | `VectorField.builder().algorithm(HNSW)` + `*=>[KNN 2 @v $vec]` | `SearchWithParamsCommandsTestBase.java` |
        | `testFlatVectorSimilarity` | `VectorField.builder().algorithm(FLAT)` + `*=>[KNN 2 @v $vec]` | `SearchWithParamsCommandsTestBase.java` |
        | `vectorSearchProfile` | KNN inside `ftProfileSearch` | `SearchWithParamsCommandsTestBase.java` |
        | `testComprehensiveFtHybridWithAllFeatures` | Full `ftHybrid` with `linear()` combiner + post-processing pipeline | `FTHybridCommandsTestBase.java` |
        | `alter` | `ftAlter(index, TagField.of(...), TextField.of(...).weight(0.5))` | `SearchWithParamsCommandsTestBase.java` |
        | `alias` | `ftAliasAdd` / `ftAliasUpdate` / `ftAliasDel` | `SearchWithParamsCommandsTestBase.java` |
        | `ftExplain` | `ftExplain(index, new Query(...).dialect(2))` | `SearchWithParamsCommandsTestBase.java` |
        | `info` | `ftInfo(index)` returns `Map<String, Object>` | `SearchWithParamsCommandsTestBase.java` |
        
        Examples files live under `https://github.com/redis/jedis/tree/master/src/test/java/io/redis/examples/`. The FT.* test bases (`SearchWithParamsCommandsTestBase`, `AggregationCommandsTestBase`, `FTHybridCommandsTestBase`) live under `https://github.com/redis/jedis/tree/master/src/test/java/redis/clients/jedis/commands/unified/search/`.
        
      • python-redis-py.md 33.8 KB
        
        # redis-py — Redis Search quick reference
        
        This reference covers the `FT.*` (Redis Search) surface of the raw `redis-py` client. It shows how `redis-py` *expresses* the canonical CLI form — it does not re-explain the query DSL. Read it after a reference that already states *what* to do.
        
        - **Query DSL vocabulary** (delimiters, operators, attributes): [`../search-syntax-primitives.md`](../search-syntax-primitives.md). Do not duplicate that grammar here.
        - **Jedis (Java) equivalents** for the same operations: [`java-jedis.md`](./java-jedis.md).
        - **RedisVL** is a different SDK (schema-first, semantic-cache, message-history). For RedisVL targets, read [`python-redisvl.md`](./python-redisvl.md) instead; the two are not interchangeable.
        
        Examples below trace to specific files in `redis/redis-py/doctests/` and preserve the upstream `STEP_START`/`STEP_END` labels so you can pair-verify against the runnable source. The shared **Bicycle dataset** (`bicycle:<n>` JSON docs with `brand`, `model`, `description`, `price`, `condition`, `type`, `store_location`, `description_embeddings`) is used throughout.
        
        ## Table of contents
        
        1. [Minimum supported versions](#1-minimum-supported-versions)
        2. [Connection setup](#2-connection-setup)
        3. [Schema imports](#3-schema-imports)
        4. [Create index — HASH](#4-create-index--hash)
        5. [Create index — JSON](#5-create-index--json)
        6. [FT.SEARCH idioms](#6-ftsearch-idioms)
        7. [FT.AGGREGATE idioms](#7-ftaggregate-idioms)
        8. [Cursors](#8-cursors)
        9. [Vector queries](#9-vector-queries)
        10. [FT.HYBRID](#10-fthybrid)
        11. [Debugging](#11-debugging)
        12. [Index management](#12-index-management)
        13. [Common errors & version gotchas](#13-common-errors--version-gotchas)
        14. [Upstream examples index](#14-upstream-examples-index)
        
        
        ## 1. Minimum supported versions
        
        Jedis equivalent: see [`java-jedis.md#1-minimum-supported-versions`](./java-jedis.md#1-minimum-supported-versions).
        
        | Component | Minimum | Notes |
        |-----------|---------|-------|
        | `redis-py` | **5.0** | Earlier 4.x releases predate the consolidated `redis.commands.search.*` import paths and lack `IndexType.JSON` ergonomics. |
        | `redis-py` (for `HybridQuery`) | **7.1.0** | The `redis.commands.search.hybrid_query` module ships from `redis-py` 7.1.0. Older releases (5.x–7.0.x) lack the `HybridQuery` builder and `index.hybrid_search()`. |
        | Redis server (FT.SEARCH / FT.AGGREGATE) | **7.4** | Redis Search ships built-in from Redis 8.0; 7.4 still requires the RediSearch module. |
        | Redis server (`FT.HYBRID`) | **8.4.0** | Hard floor. Older Redis returns `unknown command 'FT.HYBRID'`. Fall back to pre-filter + `=>[KNN ...]` via FT.SEARCH. |
        | Python | 3.8+ | Type hints in `redis.commands.search.*` assume `typing` from 3.8. |
        
        **DIALECT default:** `redis-py` does **not** set DIALECT on your behalf. Every query in this reference passes `DIALECT 2` explicitly (`.dialect(2)` or as a `Query()` argument) — required for vector attribute syntax (`=>[KNN ...]`) and the modern numeric/tag parser. Redis 8 changed the server default to DIALECT 2, but client-side absence still emits the server's compatibility default for older servers.
        
        
        ## 2. Connection setup
        
        Jedis equivalent: see [`java-jedis.md#2-connection-setup`](./java-jedis.md#2-connection-setup).
        
        The canonical connect, from `doctests/search_quickstart.py` — STEP_START `connect`:
        
        ```python
        import redis
        
        r = redis.Redis(host="localhost", port=6379, db=0, decode_responses=True)
        ```
        
        `decode_responses=True` is the right default for `FT.*` work because Search returns field names and string values as bytes by default — every result tuple becomes `b"..."` keys/values otherwise. The case that justifies leaving it `False`:
        
        - **Vector blobs you re-emit unchanged.** Vector data is binary `FLOAT32` bytes; with `decode_responses=True` Redis Search still returns them correctly because the client only decodes RESP simple/bulk strings, but mixing decoded + raw bytes in the same result set is error-prone.
        
        For FT.HYBRID results, field decoding differs — see §10.
        
        Pool reuse (for any non-toy app):
        
        ```python
        pool = redis.ConnectionPool(host="localhost", port=6379, decode_responses=True, max_connections=32)
        r = redis.Redis(connection_pool=pool)
        ```
        
        Reuse one `Redis()` instance across threads — it's thread-safe via the underlying pool.
        
        
        ## 3. Schema imports
        
        Jedis equivalent: see [`java-jedis.md#3-schema-imports`](./java-jedis.md#3-schema-imports).
        
        `redis-py` splits the Search API across submodules of `redis.commands.search`. The canonical import block, mirroring `doctests/search_quickstart.py` and `search_vss.py`:
        
        ```python
        from redis.commands.search.field import (
            TextField,
            TagField,
            NumericField,
            GeoField,
            GeoShapeField,
            VectorField,
        )
        from redis.commands.search.index_definition import IndexDefinition, IndexType
        from redis.commands.search.query import Query, NumericFilter
        from redis.commands.search.aggregation import AggregateRequest, Cursor
        import redis.commands.search.reducers as reducers
        ```
        
        Notes:
        
        - `index_definition` is the modern path; older code imports from `indexDefinition` (camelCase). Both work in 5.x, but the underscore form is what current upstream doctests use.
        - The two query builders are **different classes**: `Query` for `FT.SEARCH`, `AggregateRequest` for `FT.AGGREGATE`. They are not interchangeable and don't share methods. This is the single most common source of confusion when porting from another client.
        - `redis.commands.search.reducers` is a *module* of factory functions (`count()`, `sum()`, `avg()`, `tolist()`), not a class — that's why upstream imports it with `as reducers`.
        
        
        ## 4. Create index — HASH
        
        Jedis equivalent: see [`java-jedis.md#4-create-index--hash`](./java-jedis.md#4-create-index--hash).
        
        CLI form (from [`index-creation.md`](../index-creation.md)):
        
        ```
        FT.CREATE idx:bicycle ON HASH PREFIX 1 bicycle:
            SCHEMA
                model        TEXT WEIGHT 2.0
                description  TEXT
                brand        TAG
                condition    TAG
                price        NUMERIC SORTABLE
                store_location GEO
        ```
        
        redis-py — mirrors `doctests/home_json.py` STEP_START `make_hash_index` (the upstream HASH index example; `home_json.py` itself is JSON-indexed elsewhere, but this specific step demonstrates the HASH variant):
        
        ```python
        # STEP_START create_index_hash
        schema = (
            TextField("model", weight=2.0),
            TextField("description"),
            TagField("brand"),
            TagField("condition"),
            NumericField("price", sortable=True),
            GeoField("store_location"),
        )
        r.ft("idx:bicycle").create_index(
            schema,
            definition=IndexDefinition(prefix=["bicycle:"], index_type=IndexType.HASH),
        )
        # STEP_END
        ```
        
        HASH-specific notes:
        
        - Field names in the schema are the **hash field names verbatim** (no `$.` path prefix; no `as_name`).
        - Document keys must literally start with the declared prefix — `bicycle:1`, `bicycle:2`, … An empty / missing prefix indexes every hash in the database.
        - Use `r.hset("bicycle:1", mapping={...})` to add documents; indexing happens synchronously on write.
        
        
        ## 5. Create index — JSON
        
        Jedis equivalent: see [`java-jedis.md#5-create-index--json`](./java-jedis.md#5-create-index--json).
        
        CLI form:
        
        ```
        FT.CREATE idx:bicycle ON JSON PREFIX 1 bicycle:
            SCHEMA
                $.brand        AS brand        TEXT
                $.model        AS model        TEXT
                $.description  AS description  TEXT
                $.price        AS price        NUMERIC
                $.condition    AS condition    TAG
        ```
        
        redis-py — mirrors `doctests/search_quickstart.py` STEP_START `create_index` and `home_json.py` STEP_START `make_index`:
        
        ```python
        # STEP_START create_index_json
        schema = (
            TextField("$.brand", as_name="brand"),
            TextField("$.model", as_name="model"),
            TextField("$.description", as_name="description"),
            NumericField("$.price", as_name="price"),
            TagField("$.condition", as_name="condition"),
        )
        r.ft("idx:bicycle").create_index(
            schema,
            definition=IndexDefinition(prefix=["bicycle:"], index_type=IndexType.JSON),
        )
        # STEP_END
        ```
        
        JSON-specific notes:
        
        - The first positional argument is the **JSONPath**, not the alias. Always pair it with `as_name="<alias>"`; the alias is what queries reference as `@<alias>`.
        - Without `as_name`, Redis auto-generates a field alias from the path — usable but brittle (renaming the JSON key silently breaks the index).
        - Array projections use `[*]`: `TextField("$.tags[*]", as_name="tags")`. Nested objects use the obvious `$.address.city`.
        - Add documents with `r.json().set("bicycle:1", "$", {...})` (see `home_json.py` STEP_START `add_data`).
        
        
        ## 6. FT.SEARCH idioms
        
        Jedis equivalent: see [`java-jedis.md#6-ftsearch-idioms`](./java-jedis.md#6-ftsearch-idioms).
        
        For the query DSL itself (delimiters, operators, escaping), read [`../search-syntax-primitives.md`](../search-syntax-primitives.md). This section shows only how `redis-py` *binds* a query to `FT.SEARCH`.
        
        ### The `Query` builder
        
        `Query("<expression>")` wraps the query expression. The fluent setters mirror `FT.SEARCH` flags:
        
        | `Query` method | CLI equivalent | Purpose |
        |----------------|----------------|---------|
        | `.paging(offset, num)` | `LIMIT offset num` | Result page slice. |
        | `.sort_by(field, asc=True)` | `SORTBY field ASC|DESC` | Override score-based ranking. Requires `SORTABLE` at index time. |
        | `.return_fields(*fields)` | `RETURN n f1 f2 ...` | Project only listed fields. |
        | `.return_field(path, as_field=...)` | `RETURN n path AS alias` | JSON projection by path with alias. |
        | `.no_content()` | `NOCONTENT` | IDs only — saves bandwidth for `LIMIT 0 0` count queries. |
        | `.with_scores()` | `WITHSCORES` | Append relevance score per hit. |
        | `.verbatim()` | `VERBATIM` | Disable stemming. |
        | `.dialect(2)` | `DIALECT 2` | **Always pass this.** |
        | `.add_filter(NumericFilter(...))` | `FILTER field min max` | Inline numeric range; alternative to `@field:[min max]` in the expression. |
        
        ### Exact match (TAG / NUMERIC) — mirrors `doctests/query_em.py`
        
        ```python
        # STEP_START em1 — numeric exact match via range with equal bounds
        r.ft("idx:bicycle").search(Query("@price:[270 270]").dialect(2))
        
        # STEP_START em2 — tag exact match
        r.ft("idx:bicycle").search(Query("@condition:{new}").dialect(2))
        
        # STEP_START em4 — exact phrase in TEXT
        r.ft("idx:bicycle").search(Query('@description:"rough terrain"').dialect(2))
        ```
        
        ### Numeric ranges — mirrors `doctests/query_range.py`
        
        ```python
        # STEP_START range1 — inclusive
        r.ft("idx:bicycle").search(Query("@price:[500 1000]").dialect(2))
        
        # STEP_START range3 — exclusive lower, unbounded upper, via NumericFilter
        q = Query("*").add_filter(NumericFilter("price", "(1000", "+inf")).dialect(2)
        r.ft("idx:bicycle").search(q)
        
        # STEP_START range4 — sorted + paged
        q = Query("@price:[-inf 2000]").sort_by("price").paging(0, 5).dialect(2)
        r.ft("idx:bicycle").search(q)
        ```
        
        `NumericFilter` accepts numeric values or RESP-style strings (`"(1000"` for exclusive, `"+inf"` / `"-inf"`).
        
        ### Full-text idioms — mirrors `doctests/query_ft.py`
        
        ```python
        # STEP_START ft1 — field-scoped term
        r.ft("idx:bicycle").search(Query("@description: kids").dialect(2))
        
        # STEP_START ft2 — prefix
        r.ft("idx:bicycle").search(Query("@model: ka*").dialect(2))
        
        # STEP_START ft3 — suffix (requires WITHSUFFIXTRIE at index time for efficiency)
        r.ft("idx:bicycle").search(Query("@brand: *bikes").dialect(2))
        
        # STEP_START ft4 — fuzzy (Levenshtein distance 1)
        r.ft("idx:bicycle").search(Query("%optamized%").dialect(2))
        ```
        
        ### Geo — mirrors `doctests/query_geo.py`
        
        ```python
        # STEP_START geo1 — radius query, parametrised
        params = {"lon": -0.1778, "lat": 51.5524, "radius": 20, "units": "mi"}
        q = Query("@store_location:[$lon $lat $radius $units]").dialect(2)
        r.ft("idx:bicycle").search(q, query_params=params)
        
        # STEP_START geo2 — GEOSHAPE CONTAINS (requires DIALECT 3)
        # DIALECT 3 required for GEOSHAPE WITHIN/CONTAINS predicates (Redis 7.2+ with FT.CREATE GEOSHAPE field).
        params = {"bike": "POINT(-0.1278 51.5074)"}
        q = Query("@pickup_zone:[CONTAINS $bike]").dialect(3)
        r.ft("idx:bicycle").search(q, query_params=params)
        ```
        
        `query_params` is the redis-py mechanism for binding `$name` placeholders in the query expression — use it for any user-supplied or binary value (vector blobs, geo points, range bounds).
        
        ### Reading results
        
        `search()` returns a `Result` with `.total` (server-reported match count) and `.docs` (list of `Document` objects). Each `Document` exposes `id`, `payload`, and one attribute per returned field:
        
        ```python
        res = r.ft("idx:bicycle").search(Query("@condition:{new}").return_fields("brand", "model", "price").dialect(2))
        for doc in res.docs:
            print(doc.id, doc.brand, doc.model, doc.price)
        ```
        
        When `decode_responses=False`, both attribute names and values come back as bytes — fix it at the connection level, not via per-result decoding.
        
        
        ## 7. FT.AGGREGATE idioms
        
        Jedis equivalent: see [`java-jedis.md#7-ftaggregate-idioms`](./java-jedis.md#7-ftaggregate-idioms).
        
        For pipeline-stage ordering rules, see [`aggregate-pipeline.md`](../aggregate-pipeline.md). This section shows only the `redis-py` builder shape.
        
        ### The `AggregateRequest` builder
        
        `AggregateRequest("<filter-expression>")` is a separate class from `Query`. The fluent setters map directly to `FT.AGGREGATE` stages:
        
        | `AggregateRequest` method | CLI stage |
        |---------------------------|-----------|
        | `.load(*fields)` | `LOAD n f1 f2 ...` |
        | `.apply(alias="<expr>")` | `APPLY <expr> AS alias` (keyword form: alias on left) |
        | `.filter("<expr>")` | `FILTER <expr>` |
        | `.group_by(field_or_list, *reducers)` | `GROUPBY n f1 ... REDUCE ...` |
        | `.sort_by(("<field>", "ASC|DESC"))` | `SORTBY n <field> ASC|DESC` |
        | `.limit(offset, num)` | `LIMIT offset num` |
        | `.cursor(count=<n>, max_idle=<seconds>)` | `WITHCURSOR [COUNT n] [MAXIDLE ms]` (see §8) |
        | `.dialect(2)` | `DIALECT 2` |
        
        Reducers live in `redis.commands.search.reducers` as factory functions. Common ones:
        
        | Factory | CLI form |
        |---------|----------|
        | `reducers.count()` | `REDUCE COUNT 0` |
        | `reducers.count_distinct("@f")` | `REDUCE COUNT_DISTINCT 1 @f` |
        | `reducers.sum("@f")` | `REDUCE SUM 1 @f` |
        | `reducers.avg("@f")` | `REDUCE AVG 1 @f` |
        | `reducers.min("@f")` / `reducers.max("@f")` | `REDUCE MIN 1 @f` / `MAX 1 @f` |
        | `reducers.quantile("@f", 0.95)` | `REDUCE QUANTILE 2 @f 0.95` |
        | `reducers.tolist("@f")` | `REDUCE TOLIST 1 @f` |
        
        Every reducer factory takes `.alias("<name>")` to set the `AS <alias>` token.
        
        ### Worked pipeline — mirrors `doctests/query_agg.py`
        
        ```python
        # STEP_START agg1 — LOAD + APPLY (no grouping)
        req = (
            AggregateRequest(query="@condition:{new}")
            .load("__key", "price")
            .apply(discounted="@price - (@price * 0.1)")
            .dialect(2)
        )
        res = r.ft("idx:bicycle").aggregate(req)
        # res.rows -> [['__key', 'bicycle:0', 'price', '270', 'discounted', '243'], ...]
        
        # STEP_START agg2 — APPLY + GROUPBY + REDUCE
        req = (
            AggregateRequest(query="*")
            .load("price")
            .apply(price_category="@price<1000")
            .group_by("@condition", reducers.sum("@price_category").alias("num_affordable"))
            .dialect(2)
        )
        r.ft("idx:bicycle").aggregate(req)
        
        # STEP_START agg3 — synthesised group key via APPLY (mirrors doctests/query_agg.py)
        req = (
            AggregateRequest(query="*")
            .apply(type="'bicycle'")
            .group_by("@type", reducers.count().alias("num_total"))
            .dialect(2)
        )
        r.ft("idx:bicycle").aggregate(req)
        # res.rows -> [['type', 'bicycle', 'num_total', '10']]
        
        # STEP_START agg4 — GROUPBY + TOLIST
        req = (
            AggregateRequest(query="*")
            .load("__key")
            .group_by("@condition", reducers.tolist("__key").alias("bicycles"))
            .dialect(2)
        )
        r.ft("idx:bicycle").aggregate(req)
        ```
        
        Result shape: `AggregateResult` with `.rows` (a list of flat `[key, val, key, val, ...]` lists, mirroring RESP2). Pair adjacent elements yourself or convert via the upstream `pandas` helper in `search_vss.py`.
        
        `.apply()` uses keyword arguments where the **keyword is the alias** and the value is the expression — `apply(discounted="@price * 0.9")` emits `APPLY "@price * 0.9" AS discounted`.
        
        
        ## 8. Cursors
        
        Jedis equivalent: see [`java-jedis.md#8-cursors`](./java-jedis.md#8-cursors).
        
        For lifecycle rules and when to use cursors, see [`aggregate-cursors.md`](../aggregate-cursors.md).
        
        > **API note.** `redis-py` 5.x does not expose standalone `ft().cursor_read()` or `ft().cursor_del()` methods. `FT.CURSOR READ` is invoked by passing a `Cursor` instance back to `ft().aggregate(cursor)`. `FT.CURSOR DEL` requires the raw `r.execute_command("FT.CURSOR", "DEL", index, cursor_id)` path shown below.
        
        CLI form:
        
        ```
        FT.AGGREGATE idx:bicycle "*"
            GROUPBY 1 @brand REDUCE COUNT 0 AS n
            WITHCURSOR COUNT 1000 MAXIDLE 30000
            DIALECT 2
        
        FT.CURSOR READ idx:bicycle <cursor_id> COUNT 1000
        FT.CURSOR DEL  idx:bicycle <cursor_id>
        ```
        
        redis-py — open a cursor:
        
        ```python
        # STEP_START aggregate_cursor_open
        req = (
            AggregateRequest(query="*")
            .group_by("@brand", reducers.count().alias("n"))
            .cursor(count=1000, max_idle=30.0)   # max_idle is seconds; client converts to ms
            .dialect(2)
        )
        result = r.ft("idx:bicycle").aggregate(req)
        cursor = result.cursor             # redis.commands.search.aggregation.Cursor
        first_batch = result.rows
        # STEP_END
        ```
        
        Read the next page by passing the `Cursor` back into `aggregate()`:
        
        ```python
        # STEP_START aggregate_cursor_read
        while cursor.cid != 0:             # cid == 0 signals exhausted server-side cursor
            cursor.count = 1000            # optional: override per-read batch size
            page = r.ft("idx:bicycle").aggregate(cursor)
            cursor = page.cursor
            process(page.rows)
        # STEP_END
        ```
        
        Explicit cleanup (release before MAXIDLE):
        
        ```python
        # STEP_START aggregate_cursor_del
        r.execute_command("FT.CURSOR", "DEL", "idx:bicycle", cursor.cid)
        # STEP_END
        ```
        
        See the API note at the top of this section — `FT.CURSOR DEL` requires `execute_command`; `FT.CURSOR READ` is wrapped via `aggregate(cursor)`.
        
        
        ## 9. Vector queries
        
        Jedis equivalent: see [`java-jedis.md#9-vector-queries`](./java-jedis.md#9-vector-queries).
        
        For query-attribute syntax (`=>[KNN ...]`, `[VECTOR_RANGE ...]`) and pre-filter shape, read [`vector-query.md`](../vector-query.md).
        
        ### Index a vector field
        
        CLI form:
        
        ```
        FT.CREATE idx:bicycle ON JSON PREFIX 1 bicycle: SCHEMA
            ...
            $.description_embeddings AS vector VECTOR FLAT 6
                TYPE FLOAT32 DIM 1536 DISTANCE_METRIC COSINE
        ```
        
        redis-py — mirrors `doctests/search_vss.py` STEP_START `create_index` (dimension parametrised; use 1536 for OpenAI `text-embedding-3-small` / `ada-002`):
        
        ```python
        # STEP_START create_vector_index
        VECTOR_DIMENSION = 1536            # match your embedding model
        schema = (
            TextField("$.model", no_stem=True, as_name="model"),
            TextField("$.brand", no_stem=True, as_name="brand"),
            NumericField("$.price", as_name="price"),
            TagField("$.type", as_name="type"),
            VectorField(
                "$.description_embeddings",
                "FLAT",                    # or "HNSW" for ANN
                {
                    "TYPE": "FLOAT32",
                    "DIM": VECTOR_DIMENSION,
                    "DISTANCE_METRIC": "COSINE",
                },
                as_name="vector",
            ),
        )
        r.ft("idx:bicycle").create_index(
            schema,
            definition=IndexDefinition(prefix=["bicycle:"], index_type=IndexType.JSON),
        )
        # STEP_END
        ```
        
        ### Encode the query vector
        
        The de facto pattern (used by every upstream doctest): `numpy.array(...).astype(np.float32).tobytes()`. Mirrors `query_combined.py`:
        
        ```python
        import numpy as np
        
        def embed_to_bytes(model, text: str) -> bytes:
            return np.array(model.encode(text)).astype(np.float32).tobytes()
        ```
        
        `FLOAT32` little-endian is the only encoding `redis-py` ships with — match this on both index and query side, every time. A `FLOAT64` array silently produces zero hits because the per-element byte offsets disagree with the index's `TYPE FLOAT32`.
        
        ### KNN — mirrors `doctests/search_vss.py` STEP_START `run_knn_query`
        
        ```python
        # STEP_START vector_knn
        query = (
            Query("(*)=>[KNN 3 @vector $query_vector AS vector_score]")
            .sort_by("vector_score")
            .return_fields("vector_score", "id", "brand", "model", "description")
            .dialect(2)
        )
        res = r.ft("idx:bicycle").search(
            query,
            query_params={"query_vector": embed_to_bytes(model, "Bike for small kids")},
        )
        # STEP_END
        ```
        
        ### Pre-filtered KNN — mirrors `doctests/query_combined.py` STEP_START `combined7`
        
        ```python
        # STEP_START vector_prefilter
        query = (
            Query("(@price:[500 1000] -@condition:{new})=>[KNN 3 @vector $query_vector AS vector_score]")
            .sort_by("vector_score")
            .return_fields("vector_score", "brand", "model", "price")
            .dialect(2)
        )
        r.ft("idx:bicycle").search(query, query_params={"query_vector": query_vec})
        # STEP_END
        ```
        
        The pre-filter `(@price:[500 1000] -@condition:{new})` is applied **before** the KNN scan — it shrinks the candidate set HNSW/FLAT has to walk. Forgetting it is the most common cause of slow vector queries.
        
        ### Range — mirrors `doctests/search_vss.py` STEP_START `run_range_query`
        
        ```python
        # STEP_START vector_range
        range_query = (
            Query(
                "@vector:[VECTOR_RANGE $range $query_vector]=>"
                "{$YIELD_DISTANCE_AS: vector_score}"
            )
            .sort_by("vector_score")
            .return_fields("vector_score", "brand", "model", "description")
            .paging(0, 4)
            .dialect(2)
        )
        r.ft("idx:bicycle").search(
            range_query,
            query_params={"range": 0.55, "query_vector": query_vec},
        )
        # STEP_END
        ```
        
        `AS <alias>` (KNN form) and `$YIELD_DISTANCE_AS` (RANGE form) are not interchangeable — the upstream doctest demonstrates the difference.
        
        ### HNSW tuning per-query
        
        `EF_RUNTIME` is an in-query attribute:
        
        ```python
        Query("*=>[KNN 10 @vector $query_vector EF_RUNTIME 200 AS score]").dialect(2)
        ```
        
        Index-time `EF_CONSTRUCTION` is set in the `VectorField` algorithm dict and is independent.
        
        
        ## 10. FT.HYBRID
        
        Jedis equivalent: see [`java-jedis.md#10-fthybrid`](./java-jedis.md#10-fthybrid).
        
        **Version gate:** `FT.HYBRID` requires Redis ≥ **8.4.0** *and* `redis-py` ≥ **7.1.0** (the release that ships the `hybrid_query` module). On older Redis or older `redis-py`, use the pre-filter + KNN pattern in §9. See [`command-selection.md`](../command-selection.md) for the SEARCH vs AGGREGATE vs HYBRID decision.
        
        ### High-level builder (recommended)
        
        `redis-py` ≥ **7.1.0** ships an `@experimental` high-level `HybridQuery` builder under `redis.commands.search.hybrid_query`. The shape: build a `HybridSearchQuery` (text leg) + `HybridVsimQuery` (vector leg), combine with a `CombineResultsMethod`, call `index.hybrid_search(...)`.
        
        ```python
        # STEP_START hybrid_query
        from redis.commands.search.hybrid_query import (
            HybridQuery,
            HybridSearchQuery,
            HybridVsimQuery,
            VectorSearchMethods,
            CombineResultsMethod,
            CombinationMethods,
            HybridPostProcessingConfig,
        )
        # Result types live in a separate module:
        from redis.commands.search.hybrid_result import HybridResult, HybridCursorResult
        
        search_leg = HybridSearchQuery(
            query_string="laptop",
            scorer="BM25",
            yield_score_as="text_score",
        )
        vsim_leg = HybridVsimQuery(
            vector_field_name="@description_vector",
            vector_data="$query_vec",                    # bound via params_substitution below
            vsim_search_method=VectorSearchMethods.KNN,
            vsim_search_method_params={"K": 10, "EF_RUNTIME": 100},
            yield_score_as="vec_score",
        )
        hybrid = HybridQuery(search_leg, vsim_leg)
        combine = CombineResultsMethod(
            CombinationMethods.RRF,                      # or CombinationMethods.LINEAR
            WINDOW=100,
            YIELD_SCORE_AS="final_score",
        )
        result = r.ft("idx:bicycle").hybrid_search(
            hybrid,
            combine_method=combine,
            params_substitution={"query_vec": embed_to_bytes(model, "laptop")},
            timeout=2000,
        )
        # STEP_END
        ```
        
        Returns a `HybridResult` (or `HybridCursorResult` when `cursor=...` is supplied).
        
        ### Important behaviours
        
        - `hybrid_search` is decorated `@experimental_method()`. API may shift; pin `redis-py` if you depend on it in production.
        - `LOAD`-returned field values come back as **bytes by default**, even with `decode_responses=True`, to match the legacy RESP2 HYBRID contract. Opt into decoding per field via `HybridPostProcessingConfig.load("brand", "model", decode_field=True)` and pass the config as `post_processing=`.
        - `CombineResultsMethod` kwargs are **passed verbatim** to the server — `WINDOW`, `CONSTANT`, `YIELD_SCORE_AS` for RRF; `ALPHA`, `BETA`, `YIELD_SCORE_AS` for LINEAR. The client does no validation.
        
        ### Raw `execute_command` fallback
        
        When you need a feature not yet wrapped (or are on a redis-py minor that pre-dates the high-level builder), drop to raw RESP:
        
        ```python
        r.execute_command(
            "FT.HYBRID", "idx:bicycle",
            "SEARCH", "laptop",
            "VSIM", "@description_vector", "$query_vec",
            "KNN", "2", "K", "10",
            "COMBINE", "RRF", "2", "WINDOW", "100",
            "PARAMS", "2", "query_vec", embed_to_bytes(model, "laptop"),
            "DIALECT", "2",
        )
        ```
        
        The raw shape mirrors the verified syntax in spec 0001 §5.0a.
        
        Upstream: `redis/redis-py` master — `redis/commands/search/hybrid_query.py` (builder classes), `redis/commands/search/hybrid_result.py` (`HybridResult`, `HybridCursorResult`), and `redis/commands/search/commands.py` (the `hybrid_search` method). The API is decorated `@experimental_method` and may shift between minor releases — pin `redis-py` if you depend on it.
        
        
        ## 11. Debugging
        
        Jedis equivalent: see [`java-jedis.md#11-debugging`](./java-jedis.md#11-debugging).
        
        For interpreting `FT.EXPLAIN` and `FT.PROFILE` output, see [`debugging.md`](../debugging.md).
        
        ### `FT.EXPLAIN`
        
        ```python
        # Pass either a Query or a raw string
        plan = r.ft("idx:bicycle").explain(
            Query("(@brand:{Velorim}) @price:[100 500]").dialect(2)
        )
        print(plan)
        # INTERSECT {
        #   TAG:@brand {
        #     Velorim
        #   }
        #   NUMERIC {100.000000 <= @price <= 500.000000}
        # }
        ```
        
        The output is a parse tree — useful for spotting unexpected stemming, tokenization, or operator-precedence surprises.
        
        ### `FT.PROFILE`
        
        ```python
        result, profile_info = r.ft("idx:bicycle").profile(
            Query("@brand:{Velorim}").dialect(2),
            limited=False,
        )
        print(profile_info.iterators_profile)
        print(profile_info.result_processors_profile)
        print(profile_info.total_profile_time)
        ```
        
        `profile()` returns a `(Result, ProfileInformation)` tuple for `Query` input; for `AggregateRequest` it returns `(AggregateResult, ProfileInformation)`. `limited=True` suppresses the per-iterator detail when you only care about totals.
        
        ### `FT.INFO`
        
        ```python
        info = r.ft("idx:bicycle").info()
        print(info["num_docs"], info["hash_indexing_failures"], info["inverted_sz_mb"])
        ```
        
        `info` is a dict-like with stringly-typed values (Redis returns them as strings; cast to int/float as needed). Key fields to monitor:
        
        | Key | Why it matters |
        |-----|----------------|
        | `num_docs` | Docs successfully indexed. |
        | `hash_indexing_failures` | **Non-zero means silent dropouts** — usually schema/path mismatches. |
        | `inverted_sz_mb` | Inverted-index memory footprint. |
        | `indexing` | `1` while a background scan is running. |
        | `percent_indexed` | Progress of the background scan. |
        
        
        ## 12. Index management
        
        Jedis equivalent: see [`java-jedis.md#12-index-management`](./java-jedis.md#12-index-management).
        
        For semantics (FT.ALTER capacity, alias use cases), see [`index-management.md`](../index-management.md).
        
        ### Add a field
        
        ```python
        r.ft("idx:bicycle").alter_schema_add(TagField("availability"))
        ```
        
        Subject to the `MAXTEXTFIELDS` capacity declared at FT.CREATE time. There is no `FT.ALTER` for removing or retyping a field — drop and recreate the index.
        
        ### Aliases (for blue/green index swaps)
        
        ```python
        r.ft("idx:bicycle_v2").aliasadd("idx:bicycle:active")
        r.ft("idx:bicycle_v2").aliasupdate("idx:bicycle:active")   # repoint existing alias
        r.ft("idx:bicycle_v2").aliasdel("idx:bicycle:active")
        ```
        
        All three are wrapped — they call `FT.ALIASADD` / `FT.ALIASUPDATE` / `FT.ALIASDEL` respectively. Aliases let application code query a stable name while you build a replacement index behind it.
        
        ### Drop the index
        
        ```python
        # Keep documents, drop only the index
        r.ft("idx:bicycle").dropindex()
        
        # Drop index AND delete every indexed document (destructive)
        r.ft("idx:bicycle").dropindex(delete_documents=True)
        ```
        
        `delete_documents=True` is the equivalent of `FT.DROPINDEX ... DD` — gone forever, no undo.
        
        
        ## 13. Common errors & version gotchas
        
        Jedis equivalent: see [`java-jedis.md#13-common-errors--version-gotchas`](./java-jedis.md#13-common-errors--version-gotchas).
        
        | Symptom | Likely cause | Fix |
        |---------|--------------|-----|
        | `unknown command 'FT.CREATE'` (or any other `FT.*`) | Redis < 8.0 without the RediSearch module loaded. | Load the module (`MODULE LOAD /path/to/redisearch.so` or via `loadmodule` in `redis.conf`), or upgrade to Redis ≥ 8.0 where Redis Search is built-in. |
        | `unknown command 'FT.HYBRID'` | Server < 8.4.0. | Upgrade or fall back to pre-filter + KNN via FT.SEARCH (§9). |
        | `ImportError` / `cannot import name 'HybridQuery'` from `redis.commands.search.hybrid_query` | `redis-py` < 7.1.0 — the `hybrid_query` module ships from 7.1.0. | Upgrade `redis-py` to ≥ 7.1.0, or fall back to pre-filter + KNN via FT.SEARCH (§9). |
        | `Syntax error at offset N near KNN` | Missing `DIALECT 2`. | Always `.dialect(2)` on every `Query` and `AggregateRequest`. |
        | `GEOSHAPE WITHIN/CONTAINS` returns syntax error | Missing `.dialect(3)`, or server lacks DIALECT 3 support. | Pass `.dialect(3)` explicitly; ensure Redis ≥ 7.2 with GEOSHAPE-capable RediSearch. |
        | `Vector dimension mismatch` | Query vector dim differs from index `DIM`. | Recompute embedding with the same model used at index time; assert `len(arr) == DIM`. |
        | Vector query returns 0 hits despite obvious matches | Query vector encoded as `FLOAT64` (default numpy dtype). | Always `.astype(np.float32)` before `.tobytes()`. |
        | Result fields come back as `b"..."` bytes | `decode_responses=False`. | Set `decode_responses=True` on the connection. Don't decode per-result. |
        | Result fields come back as bytes inside an FT.HYBRID response | Expected: `HybridResult` LOAD values stay bytes by default. | Pass `HybridPostProcessingConfig().load("brand", decode_field=True)` as `post_processing=`. |
        | `Index already exists` from idempotent setup | `create_index` is not "create or replace". | Try/except `ResponseError`, or `dropindex()` first when bootstrapping. |
        | `JSON paths` not matching docs | Document set with `JSON.SET` but index defined `ON HASH` (or vice versa). | Match `IndexType` to write path; `info()`'s `hash_indexing_failures` > 0 is the signal. |
        | Empty `.docs` but non-zero `.total` | `NOCONTENT` (via `.no_content()`) was set. | Remove `.no_content()` or call `.return_fields(...)`. |
        | `'@price:[270]' syntax not yet supported` | Single-value numeric-bracket form is a Redis 8 server feature, but `Query` builder validation may reject it pre-Redis-8 clients. | Use `@price:[270 270]` or `NumericFilter("price", 270, 270)` (mirrors `query_em.py`). |
        
        **DIALECT defaults:** server default is DIALECT 2 from Redis 8; older servers default to 1 and reject the vector attribute form (`=>[KNN ...]`). `redis-py` itself never injects DIALECT — *you* must pass `.dialect(2)`. This is the most common silent failure mode when porting code between Redis versions.
        
        
        ## 14. Upstream examples index
        
        Jedis equivalent: see [`java-jedis.md#14-upstream-examples-index`](./java-jedis.md#14-upstream-examples-index).
        
        Curated index of `STEP_START` labels in `redis/redis-py/doctests/` so you can fetch the runnable source by step name. Files live at `https://github.com/redis/redis-py/blob/master/doctests/<file>`.
        
        | Step label | Operation | Upstream file |
        |------------|-----------|---------------|
        | `connect` | `redis.Redis(host=..., decode_responses=True)` | `search_quickstart.py` |
        | `data_sample` | Bicycle JSON document shape | `search_quickstart.py` |
        | `create_index` (bicycle JSON) | JSON schema with TEXT/TAG/NUMERIC + `as_name` aliases | `search_quickstart.py` |
        | `make_index` | JSON index for users (TextField/TagField/NumericField) | `home_json.py` |
        | `make_hash_index` | HASH index, same fields without `$.` paths | `home_json.py` |
        | `add_data` | `r.json().set(key, "$", doc)` | `home_json.py` |
        | `query1` | `Query("Paul @age:[30 40]")` — combined TEXT + NUMERIC | `home_json.py` |
        | `query2` | `Query("Paul").return_field("$.city", as_field="city")` | `home_json.py` |
        | `query3` | `AggregateRequest.group_by("@city", reducers.count().alias("count"))` | `home_json.py` |
        | `em1` | Numeric exact match `@price:[270 270]` + `NumericFilter` | `query_em.py` |
        | `em2` | TAG exact match `@condition:{new}` | `query_em.py` |
        | `em4` | Exact phrase in TEXT | `query_em.py` |
        | `range1` | Inclusive numeric range | `query_range.py` |
        | `range3` | Exclusive lower bound via `NumericFilter("price", "(1000", "+inf")` | `query_range.py` |
        | `range4` | Range + `sort_by("price").paging(0, 5)` | `query_range.py` |
        | `ft1`–`ft5` | Field-scoped term, prefix, suffix, fuzzy `%term%`, double-fuzzy `%%term%%` | `query_ft.py` |
        | `geo1` | Geo radius with `query_params` substitution | `query_geo.py` |
        | `geo2` | `GEOSHAPE` CONTAINS, requires `.dialect(3)` | `query_geo.py` |
        | `geo3` | `GEOSHAPE` WITHIN polygon | `query_geo.py` |
        | `combined1`–`combined7` | Mixed TAG / NUMERIC / TEXT / negation / KNN pre-filter | `query_combined.py` |
        | `agg1` | `LOAD` + `APPLY` (no grouping) | `query_agg.py` |
        | `agg2` | `APPLY` + `GROUPBY` + `REDUCE SUM` | `query_agg.py` |
        | `agg3` | Synthesised group key via `APPLY type="'bicycle'"` | `query_agg.py` |
        | `agg4` | `GROUPBY` + `REDUCE TOLIST` | `query_agg.py` |
        | `imports` | Canonical `from redis.commands.search.*` import block | `search_vss.py` |
        | `create_index` (vector) | JSON schema with `VectorField("FLAT", {...}, as_name="vector")` for VSS | `search_vss.py` |
        | `run_knn_query` | `Query("(*)=>[KNN 3 @vector $query_vector AS vector_score]")` | `search_vss.py` |
        | `run_hybrid_query` | Pre-filter + KNN: `(@brand:Peaknetic)=>[KNN ...]` | `search_vss.py` |
        | `run_range_query` | `[VECTOR_RANGE $range $query_vector]=>{$YIELD_DISTANCE_AS: ...}` | `search_vss.py` |
        
        
        ### Footer: async
        
        `redis.asyncio.Redis` mirrors the sync API for `FT.*` (same `Query`, `AggregateRequest`, schema imports). Out of scope for v1 of this reference — see `redis-py`'s async tests under `tests/test_asyncio/test_search.py` for parallel examples. Sync semantics described above apply.
        
      • python-redisvl.md 49.1 KB
        
        # RedisVL — Redis Search quick reference
        
        This reference covers the **search / index / query surface** of RedisVL — the higher-level, schema-first Python SDK that builds on `redis-py`. It shows how RedisVL *expresses* the canonical CLI form via `IndexSchema`, `SearchIndex`, the query classes, and the `FilterExpression` DSL. It does not re-explain the query DSL grammar — that lives in [`../search-syntax-primitives.md`](../search-syntax-primitives.md).
        
        - **redis-py (raw) equivalents** for the same operations: [`python-redis-py.md`](./python-redis-py.md). RedisVL is built on `redis-py`; an `FT.*` command that RedisVL does not wrap is reachable by calling `redis-py` directly through the index's underlying client (see §3). For tasks that are not RedisVL-specific (no schema, no vectorizer, no LLM primitive), prefer the `redis-py` reference.
        - **Query DSL vocabulary** (delimiters, operators, escaping): [`../search-syntax-primitives.md`](../search-syntax-primitives.md). RedisVL's `FilterExpression` is a typed builder for that DSL — the grammar itself is not duplicated here.
        - **Jedis (Java) equivalents** for cross-language verification of FT.* behaviour: [`java-jedis.md`](./java-jedis.md). RedisVL is Python-only; there is no Java equivalent.
        
        Examples below trace to the upstream **RedisVL user-guide notebooks** under `redis/redis-vl-python/docs/user_guide/`. Each section cites the originating notebook. The shared **Bicycle dataset** is reused where it fits (filters, ranges, RAG-shape examples). For vectorizer demos that need text-embedding-friendly prose, the upstream notebook's small sentence/document dataset is preserved verbatim.
        
        ## Table of contents
        
        1. [When to choose RedisVL over raw redis-py](#1-when-to-choose-redisvl-over-raw-redis-py)
        2. [Minimum supported versions](#2-minimum-supported-versions)
        3. [Connection](#3-connection)
        4. [Schema definition — Python dict](#4-schema-definition--python-dict)
        5. [Schema definition — YAML](#5-schema-definition--yaml)
        6. [Storage type: HASH vs JSON](#6-storage-type-hash-vs-json)
        7. [Index lifecycle](#7-index-lifecycle)
        8. [FilterExpression DSL](#8-filterexpression-dsl)
        9. [Query classes](#9-query-classes)
        10. [Vectorizers](#10-vectorizers)
        11. [Hybrid retrieval](#11-hybrid-retrieval)
        12. [Async](#12-async)
        13. [LLM primitives (summary level)](#13-llm-primitives-summary-level)
        14. [Common errors & version gotchas](#14-common-errors--version-gotchas)
        15. [Upstream examples index](#15-upstream-examples-index)
        
        
        ## 1. When to choose RedisVL over raw redis-py
        
        redis-py equivalent: not applicable — see [`python-redis-py.md`](./python-redis-py.md) when none of the criteria below apply.
        
        | Signal in the task | Pick |
        |--------------------|------|
        | Schema is declared up front (Python dict or YAML) and the agent is asked to "define an index" | RedisVL |
        | Task names a `vectorizer` provider (OpenAI, HuggingFace, Cohere, Vertex, Azure OpenAI, Bedrock, Mistral, VoyageAI) and wants embeddings produced from text | RedisVL |
        | Task involves LLM primitives (semantic cache, message history, semantic router) | RedisVL |
        | Task is async-first and wants a clean async surface (`AsyncSearchIndex.query`, `.aembed_many`) | RedisVL |
        | Task wants the lowest-level control over RESP commands or doesn't involve embeddings at all | raw `redis-py` ([`python-redis-py.md`](./python-redis-py.md)) |
        | Task is "use FT.HYBRID directly with custom post-processing the high-level builder doesn't expose" | raw `redis-py` (drop through `index.client`) |
        | Task mentions Jedis, Lettuce, node-redis, go-redis, NRedisStack | not RedisVL — RedisVL is Python only |
        
        RedisVL builds on `redis-py`. The two are not mutually exclusive: when an `FT.*` command isn't wrapped at the RedisVL level, the same `SearchIndex` exposes its raw client via the public `index.client` property so an agent can fall through to the `redis-py` patterns described in [`python-redis-py.md`](./python-redis-py.md) without constructing a second connection. (The `index._redis_client` attribute also returns a client but is the internal lazy-init accessor — use `.client` in user code.)
        
        
        ## 2. Minimum supported versions
        
        redis-py equivalent: see [`python-redis-py.md#1-minimum-supported-versions`](./python-redis-py.md#1-minimum-supported-versions).
        
        | Component | Minimum | Notes |
        |-----------|---------|-------|
        | `redisvl` | **0.18.2** | RedisVL has evolved rapidly; older `0.x` releases predate `HybridQuery`, `AggregateHybridQuery`, `MultiVectorQuery`, and the consolidated `redisvl.extensions.*` import paths used below. Verified against upstream `pyproject.toml` at tag `v0.18.2`. |
        | `redis-py` | **5.0** | RedisVL declares `redis>=5.0,<8.0` in its `pyproject.toml`. The library itself is a thin layer over `redis-py`. `HybridQuery` requires `redis-py >= 7.1.0` (the version that ships `FT.HYBRID` support) — see the dedicated row below. |
        | `redis-py` (for `HybridQuery`) | **7.1.0** | `HybridQuery` import gates on `redis-py >= 7.1.0` at import time. Older `redis-py` cannot serialise `FT.HYBRID`. |
        | Redis server (FT.SEARCH / FT.AGGREGATE) | **7.4** | Redis Search ships built-in from Redis 8.0; 7.4 still requires the RediSearch module. |
        | Redis server (`FT.HYBRID` / `HybridQuery`) | **8.4.0** | Hard floor. Older Redis raises `unknown command 'FT.HYBRID'`. Fall back to the pre-filter + KNN pattern via `VectorQuery(filter_expression=...)`. See §11. |
        | Python | 3.9+ | RedisVL 0.18 targets 3.9+. |
        
        **DIALECT default:** RedisVL passes `DIALECT 2` on every query by default — every query class accepts a `dialect=` parameter that defaults to `2` (`VectorQuery`, `FilterQuery`, `RangeQuery`, `VectorRangeQuery`, `CountQuery`, `TextQuery`). You do **not** need to set it explicitly. GEOSHAPE `WITHIN`/`CONTAINS` predicates still require manually overriding to `dialect=3`.
        
        
        ## 3. Connection
        
        redis-py equivalent: see [`python-redis-py.md#2-connection-setup`](./python-redis-py.md#2-connection-setup).
        
        Three connection patterns, in order of preference. All three are equivalent in behaviour — pick the one that fits where the connection lives in your application.
        
        ```python
        from redisvl.index import SearchIndex
        from redis import Redis
        
        # (a) Pass a redis-py client. Use this when the connection is managed elsewhere
        #     (DI container, app factory, test fixture). Mirrors 01_getting_started.ipynb cell 9.
        client = Redis.from_url("redis://localhost:6379")
        index = SearchIndex.from_dict(schema, redis_client=client, validate_on_load=True)
        
        # (b) Pass a URL string. RedisVL constructs the client internally. Mirrors cell 11.
        index = SearchIndex.from_dict(schema, redis_url="redis://localhost:6379")
        
        # (c) Default — connects to redis://localhost:6379 when neither is set.
        index = SearchIndex.from_dict(schema)
        ```
        
        `from_existing(name, ...)` rehydrates a `SearchIndex` from a server-side index that already exists (the schema is read back via `FT.INFO`):
        
        ```python
        index = SearchIndex.from_existing("idx:bicycle", redis_url="redis://localhost:6379")
        ```
        
        `validate_on_load=True` enables schema-shape validation on every `.load()` call — recommended in development, costly in tight write loops.
        
        **Falling through to raw redis-py.** The underlying `redis-py` client is exposed for cases not covered by the high-level API:
        
        ```python
        raw = index.client                       # SyncRedisClient (a redis.Redis instance)
        raw.ft("idx:bicycle").execute_command("FT.SOMETHING", ...)
        ```
        
        When you reach for `index.client`, switch to [`python-redis-py.md`](./python-redis-py.md) for the idioms.
        
        
        ## 4. Schema definition — Python dict
        
        redis-py equivalent: see [`python-redis-py.md#3-schema-imports`](./python-redis-py.md#3-schema-imports) and `#5-create-index--json`. RedisVL declares the schema once as data; raw `redis-py` passes a tuple of `TextField` / `TagField` / etc. constructors plus an `IndexDefinition`.
        
        The canonical schema shape — mirrors `01_getting_started.ipynb` cell 3:
        
        ```python
        schema = {
            "index": {
                "name": "user_simple",
                "prefix": "user_simple_docs",
                # "storage_type": "hash" (default) or "json" — see §6
            },
            "fields": [
                {"name": "user",         "type": "tag"},
                {"name": "credit_score", "type": "tag"},
                {"name": "job",          "type": "text"},
                {"name": "age",          "type": "numeric"},
                {
                    "name": "user_embedding",
                    "type": "vector",
                    "attrs": {
                        "dims": 3,
                        "distance_metric": "cosine",
                        "algorithm": "flat",
                        "datatype": "float32",
                    },
                },
            ],
        }
        ```
        
        The dict goes to `SearchIndex.from_dict(schema)` — see §3 for the connection variants.
        
        **Field types:** `tag`, `text`, `numeric`, `geo`, `vector`. Each maps to the corresponding `FT.CREATE` schema clause.
        
        **Vector field algorithms:** `"flat"` for exact / small-scale (≤ ~1M vectors), `"hnsw"` for ANN, `"svs-vamana"` for SVS Vamana (advanced; Intel-optimised; mention as optional — see `09_svs_vamana.ipynb`).
        
        > The schema above uses 3-dim toy embeddings from the upstream `01_getting_started.ipynb` notebook for brevity; the HNSW attrs block below shows the same schema shape with production-shape 1536 dims. `dims` must match whatever vectorizer or precomputed embedding produces the field's bytes — see §10.
        
        **HNSW attributes** (added under `attrs`):
        
        ```python
        {
            "name": "embedding",
            "type": "vector",
            "attrs": {
                "dims": 1536,
                "distance_metric": "cosine",
                "algorithm": "hnsw",
                "datatype": "float32",
                "m": 16,                    # max bidirectional links per node
                "ef_construction": 200,     # build-time exploration factor
                "ef_runtime": 10,           # default query-time exploration factor
            },
        },
        ```
        
        **JSON storage with nested paths.** Set `storage_type: "json"` at the index level and use JSONPath-style nested field names (RedisVL emits the path-and-alias for you):
        
        ```python
        schema = {
            "index": {"name": "bike_index", "prefix": "bike:", "storage_type": "json"},
            "fields": [
                {"name": "name",            "type": "text"},
                {"name": "metadata.brand",  "type": "tag",     "path": "$.metadata.brand"},
                {"name": "metadata.price",  "type": "numeric", "path": "$.metadata.price"},
                # ...
            ],
        }
        ```
        
        The `name` is the query alias (`@metadata.brand`); the `path` is the underlying JSONPath. For top-level JSON fields the `path` defaults to `$.<name>`.
        
        
        ## 5. Schema definition — YAML
        
        redis-py equivalent: not applicable. Raw `redis-py` has no YAML schema concept; you compose fields in code.
        
        YAML is the recommended schema format for production — the file is checked into source control alongside the application and reused across sync/async/CLI consumers. Canonical shape — mirrors `redis/redis-vl-python` upstream `docs/user_guide/schema.yaml`:
        
        ```yaml
        version: '0.1.0'
        
        index:
          name: vectorizers
          prefix: doc
          storage_type: hash
        
        fields:
          - name: sentence
            type: text
          - name: embedding
            type: vector
            attrs:
              dims: 768
              algorithm: flat
              distance_metric: cosine
        ```
        
        Load with `from_yaml`:
        
        ```python
        from redisvl.index import SearchIndex
        
        index = SearchIndex.from_yaml(
            "schemas/bicycle.yaml",
            redis_url="redis://localhost:6379",
            validate_on_load=True,
        )
        ```
        
        Field shape under `fields:` mirrors the dict form in §4 — same `type`, `attrs`, `path`. The top-level `version:` field is the **schema-format** version, not the data version; upstream `docs/user_guide/schema.yaml` at tag `v0.18.2` pins it to `'0.1.0'`. RedisVL validates this format-version string at parse time — keep it `'0.1.0'` for RedisVL 0.18.x and bump only when a future RedisVL release introduces a new schema-format major.
        
        
        ## 6. Storage type: HASH vs JSON
        
        redis-py equivalent: see [`python-redis-py.md#4-create-index--hash`](./python-redis-py.md#4-create-index--hash) and `#5-create-index--json`.
        
        Mirrors `05_hash_vs_json.ipynb`. Set `storage_type` once in the schema; everything else follows.
        
        ```python
        # HASH storage — default. Each Redis key holds a flat hash; field names are the schema names verbatim.
        hash_schema = {
            "index": {"name": "user-hash", "prefix": "user-hash-docs", "storage_type": "hash"},
            "fields": [
                {"name": "user",            "type": "tag"},
                {"name": "office_location", "type": "geo"},
                {"name": "user_embedding",  "type": "vector",
                 "attrs": {"dims": 3, "distance_metric": "cosine",
                           "algorithm": "flat", "datatype": "float32"}},
            ],
        }
        hindex = SearchIndex.from_dict(hash_schema, redis_url="redis://localhost:6379")
        hindex.create(overwrite=True)
        hindex.storage_type        # -> StorageType.HASH
        
        # JSON storage — same fields, nested JSON document per key. Use JSONPath for nested fields.
        json_schema = {
            "index": {"name": "user-json", "prefix": "user-json-docs", "storage_type": "json"},
            "fields": [
                {"name": "user",            "type": "tag"},
                {"name": "office_location", "type": "geo"},
                {"name": "user_embedding",  "type": "vector",
                 "attrs": {"dims": 3, "distance_metric": "cosine",
                           "algorithm": "flat", "datatype": "float32"}},
            ],
        }
        jindex = SearchIndex.from_dict(json_schema, redis_url="redis://localhost:6379")
        jindex.create(overwrite=True)
        ```
        
        **Choosing between them:**
        
        | Trade-off | HASH | JSON |
        |-----------|------|------|
        | Nested objects (`metadata.brand`) | Flatten yourself before load | Native — use JSONPath in `path:` |
        | Partial document update | `HSET key field value` (RedisVL: `.load([{...}])` rewrites) | `JSON.SET key $.path value` |
        | Vector encoding at load time | `np.ndarray(...).astype(np.float32).tobytes()` | `[0.1, 0.2, ...]` list (RedisVL converts) |
        | Memory footprint per doc | Lower for flat docs | Higher (JSON metadata overhead) |
        | Querying nested arrays | Not supported | `$.tags[*]` projections |
        
        When in doubt for AI/RAG workloads, **JSON**: it preserves the natural shape of LLM outputs and allows nested metadata without flattening conventions. HASH wins when documents are already flat key-value structures.
        
        
        ## 7. Index lifecycle
        
        redis-py equivalent: see [`python-redis-py.md#12-index-management`](./python-redis-py.md#12-index-management). RedisVL wraps the same `FT.CREATE` / `FT.DROPINDEX` / `FT.ALTER` / `FT.ALIAS*` commands behind methods on `SearchIndex`.
        
        ```python
        # Create the index. overwrite=True drops and recreates; drop=True (with overwrite=True)
        # also drops every existing indexed document.
        index.create(overwrite=True, drop=False)
        
        # Existence + introspection
        index.exists()              # -> bool
        index.info()                # -> dict from FT.INFO
        
        # Load documents. Returns the list of full Redis keys written.
        keys = index.load(data)                          # auto-generates document IDs
        keys = index.load(data, id_field="user")         # use a field as the document ID
        keys = index.load(data, ttl=3600)                # set per-document TTL
        
        # Fetch by document ID (the unprefixed ID — RedisVL prepends the index prefix).
        record = index.fetch("john")
        
        # Delete by full key (with prefix) or by document ID (RedisVL adds the prefix).
        index.drop_keys("user_simple_docs:01ABC...")
        index.drop_documents("john")
        index.drop_documents(["mary", "joe"])
        
        # Clear: delete every indexed document but keep the index.
        n_deleted = index.clear()
        
        # Delete the index. drop=True (default) also deletes all indexed documents (FT.DROPINDEX ... DD);
        # drop=False keeps the documents and removes only the index definition.
        index.delete(drop=True)
        ```
        
        `index.load(data)` is the bulk-write entry point. It accepts a list of dicts; each dict's keys must match the schema's field names (or the JSON path aliases for JSON-storage schemas). For HASH-storage vector fields the dict value must be `np.ndarray(...).astype(np.float32).tobytes()` (or use a vectorizer, see §10). For JSON-storage vector fields a plain `list[float]` works.
        
        **Updating existing documents.** RedisVL 0.18.2 does **not** expose a dedicated `update_load(...)` method (the name appears in early proposals; it is not in the shipped API). To update existing indexed documents, call `.load(...)` again with explicit `keys=` matching the existing Redis keys, or with the same `id_field` value:
        
        ```python
        # Re-load the same document IDs. HASH storage merges field-by-field at the Redis
        # level (HSET semantics); JSON storage replaces the document.
        keys = index.load(updated_data, id_field="user")
        
        # Or write to explicit keys (must be full prefixed keys).
        keys = index.load(updated_data, keys=["user_simple_docs:01ABC", "user_simple_docs:01DEF"])
        ```
        
        The `preprocess=` parameter accepts a callable applied to each item before write — useful for normalising shape or computing derived fields. `validate_on_load=True` (set on the index) re-runs schema validation on every `.load()` call.
        
        `paginate(query, page_size=N)` is the recommended way to walk large result sets without holding everything in memory — yields batches of result dicts:
        
        ```python
        from redisvl.query import FilterQuery
        from redisvl.query.filter import FilterExpression
        
        query = FilterQuery(filter_expression=FilterExpression("*"), return_fields=["user", "age", "job"])
        for batch in index.paginate(query, page_size=100):
            for doc in batch:
                process(doc)
        ```
        
        **Mirrors `01_getting_started.ipynb` cells 13, 18, 23, 26, 28, 30, 32, 33.**
        
        
        ## 8. FilterExpression DSL
        
        redis-py equivalent: see [`python-redis-py.md#6-ftsearch-idioms`](./python-redis-py.md#6-ftsearch-idioms) for raw query strings. The full DSL grammar lives in [`../search-syntax-primitives.md`](../search-syntax-primitives.md); this section shows only how RedisVL's typed `FilterExpression` *compiles to* that grammar.
        
        The four filter classes mirror the four indexable scalar types — `Tag`, `Text`, `Num`, `Geo` — plus `Timestamp` (a `Num` subclass that accepts `datetime` objects). All five live in `redisvl.query.filter`. Operators are overloaded: `==`, `!=`, `<`, `>`, `<=`, `>=`, `%` (text wildcard/fuzzy), `&` (AND), `|` (OR), `~` (NOT — used via `!=`).
        
        ### Tag — mirrors `02_complex_filtering.ipynb` cells 8, 10, 12
        
        ```python
        from redisvl.query.filter import Tag
        
        Tag("credit_score") == "high"                       # @credit_score:{high}
        Tag("credit_score") != "high"                       # -@credit_score:{high}
        Tag("credit_score") == ["high", "medium"]           # @credit_score:{high|medium}
        Tag("credit_score") == set(["high", "medium"])      # same; set enforces uniqueness
        Tag("credit_score") == []                           # gracefully -> "*" (no constraint)
        ```
        
        ### Num — mirrors cells 17, 18, 19
        
        ```python
        from redisvl.query.filter import Num
        
        Num("age").between(15, 35)        # @age:[15 35]
        Num("age") == 14                  # @age:[14 14]
        Num("age") != 14                  # -@age:[14 14]
        Num("age") < 18                   # @age:[-inf (18]
        Num("age") >= 18                  # @age:[18 +inf]
        ```
        
        ### Text — mirrors cells 25, 26, 27, 28, 29
        
        ```python
        from redisvl.query.filter import Text
        
        Text("job") == "doctor"           # @job:"doctor"
        Text("job") != "doctor"           # -@job:"doctor"
        Text("job") % "doct*"             # @job:doct*  (wildcard / prefix)
        Text("job") % "%%engine%%"        # @job:%%engine%%  (fuzzy, Levenshtein 2)
        Text("job") % "engineer|doctor"   # @job:(engineer|doctor)
        Text("job") % ""                  # gracefully -> "*"
        ```
        
        ### Geo — mirrors cells 34, 35, 36
        
        ```python
        from redisvl.query.filter import Geo, GeoRadius
        
        Geo("office_location") == GeoRadius(-122.4194, 37.7749, 10, "km")    # @office_location:[-122.4194 37.7749 10 km]
        Geo("office_location") != GeoRadius(-122.4194, 37.7749, 10, "km")    # -@office_location:[...]
        ```
        
        ### Timestamp — mirrors cells 21–23
        
        ```python
        from redisvl.query.filter import Timestamp
        from datetime import datetime
        
        dt = datetime(2025, 3, 16, 13, 45, 39)
        Timestamp("last_updated") > dt                    # @last_updated:[(<epoch> +inf]
        Timestamp("last_updated").between(dt1, dt2)       # @last_updated:[<epoch1> <epoch2>]
        ```
        
        `Timestamp` converts `datetime` to epoch seconds and emits a `NUMERIC` range — the underlying field must be declared `type: numeric` and stored as epoch seconds.
        
        ### Composition — mirrors cells 38, 40, 42
        
        Boolean operators compose any filter into a single expression:
        
        ```python
        t  = Tag("credit_score") == "high"
        lo = Num("age") >= 18
        hi = Num("age") <= 100
        ts = Timestamp("last_updated") > datetime(2025, 3, 16, 13, 45, 39)
        
        combined = t & lo & hi & ts                       # AND
        either   = (Num("age") < 18) | (Num("age") > 93)  # OR
        
        # Defensive composition — empty filters fall back to "*", so partial inputs compose cleanly.
        def make_filter(age=None, credit=None, job=None):
            return (
                (Num("age") > age) &
                (Tag("credit_score") == credit) &
                (Text("job") % job)
            )
        ```
        
        Pass the resulting `FilterExpression` to any query class via `filter_expression=`:
        
        ```python
        from redisvl.query import VectorQuery
        
        v = VectorQuery(
            vector=[0.1, 0.1, 0.5],
            vector_field_name="user_embedding",
            return_fields=["user", "credit_score", "age", "job", "office_location"],
            filter_expression=combined,
        )
        ```
        
        Or swap the filter on an existing query without rebuilding it:
        
        ```python
        v.set_filter(Tag("credit_score") != "high")
        ```
        
        `str(filter_expression)` returns the compiled query string — useful for logging or for verifying what RedisVL emits before sending it to Redis.
        
        
        ## 9. Query classes
        
        redis-py equivalent: see [`python-redis-py.md#6-ftsearch-idioms`](./python-redis-py.md#6-ftsearch-idioms) (FT.SEARCH) and `#7-ftaggregate-idioms` (FT.AGGREGATE). RedisVL's query classes route to either depending on shape.
        
        All seven live under `redisvl.query`. Pass an instance to `index.query(...)` — RedisVL picks the right underlying command:
        
        | Class | Wraps | Use for |
        |-------|-------|---------|
        | `VectorQuery` | FT.SEARCH with `=>[KNN K @field $vec AS score]` | Top-K nearest neighbours, pre-filterable via `filter_expression=`. |
        | `VectorRangeQuery` (also exported as `RangeQuery`) | FT.SEARCH with `=>[VECTOR_RANGE radius $vec]` | All hits within a distance threshold of the query vector. |
        | `FilterQuery` | FT.SEARCH on a `FilterExpression` (no vector) | Plain filtered retrieval; pagination via `index.paginate(query, page_size=N)`. |
        | `CountQuery` | FT.SEARCH `... LIMIT 0 0` | Count documents matching a filter without fetching them. |
        | `TextQuery` | FT.SEARCH with full-text scoring | BM25/TFIDF-scored full-text retrieval over `text` fields. |
        | `AggregationQuery` | FT.AGGREGATE | Group-by + reduce pipelines. Inherits from `redis-py`'s `AggregateRequest` — fluent API matches. |
        | `HybridQuery` | FT.HYBRID (Redis ≥ 8.4.0) | Native blended text + vector ranking. See §11. |
        
        ### VectorQuery — KNN with optional pre-filter
        
        Mirrors `01_getting_started.ipynb` cell 36 + `02_complex_filtering.ipynb` cell 8.
        
        ```python
        from redisvl.query import VectorQuery
        from redisvl.query.filter import Tag, Num
        
        # Top-3 nearest, no filter.
        query = VectorQuery(
            vector=[0.1, 0.1, 0.5],
            vector_field_name="user_embedding",
            return_fields=["user", "age", "job", "credit_score", "vector_distance"],
            num_results=3,
        )
        results = index.query(query)
        
        # Pre-filtered KNN — filter is applied before the vector scan.
        query = VectorQuery(
            vector=[0.1, 0.1, 0.5],
            vector_field_name="user_embedding",
            return_fields=["user", "credit_score", "age", "vector_distance"],
            num_results=3,
            filter_expression=(Tag("credit_score") == "high") & (Num("age").between(18, 60)),
        )
        results = index.query(query)
        ```
        
        `results` is a `list[dict]` — each dict carries the returned fields plus `id` and (when the vector_distance alias is in `return_fields`) the distance score.
        
        ### VectorRangeQuery — within a distance threshold
        
        ```python
        from redisvl.query import VectorRangeQuery
        # Backwards-compatible alias — RangeQuery is a thin subclass kept for older code paths.
        from redisvl.query import RangeQuery  # `class RangeQuery(VectorRangeQuery): pass` in redisvl/query/query.py
        
        range_q = VectorRangeQuery(
            vector=[0.1, 0.1, 0.5],
            vector_field_name="user_embedding",
            return_fields=["user", "vector_distance"],
            distance_threshold=0.5,        # COSINE distance in [0, 2]
            num_results=10,                 # cap on returned results
        )
        results = index.query(range_q)
        ```
        
        Mirrors `docs/user_guide/11_advanced_queries.ipynb` (distance-threshold pattern; the notebook uses the equivalent `filter_expression` + `VectorQuery` shape rather than `VectorRangeQuery` directly — the class is exercised by `tests/integration/test_query.py`).
        
        ### FilterQuery — pure filter retrieval
        
        Mirrors `01_getting_started.ipynb` cell 30.
        
        ```python
        from redisvl.query import FilterQuery
        from redisvl.query.filter import Tag
        
        query = FilterQuery(
            filter_expression=Tag("credit_score") == "high",
            return_fields=["user", "age", "job"],
            num_results=50,
        )
        
        # Paginate large result sets — recommended over num_results for full scans.
        for batch in index.paginate(query, page_size=100):
            for doc in batch:
                process(doc)
        ```
        
        ### CountQuery — count without fetching
        
        ```python
        from redisvl.query import CountQuery
        from redisvl.query.filter import Tag
        
        count = index.query(CountQuery(filter_expression=Tag("brand") == "Nike"))
        # Returns the integer match count; emits FT.SEARCH ... LIMIT 0 0 under the hood.
        ```
        
        Source: `redisvl.query.CountQuery` (`redisvl/query/query.py` — class at module scope). Exercised by `tests/integration/test_query.py`; the user-guide notebooks favour `FilterQuery` over `CountQuery` for didactic reasons (showing the fetched docs), but the class is a stable part of the v0.18.2 public API.
        
        ### TextQuery — full-text scored retrieval
        
        Mirrors `11_advanced_queries.ipynb` cells 8, 10, 13.
        
        ```python
        from redisvl.query import TextQuery
        from redisvl.query.filter import Tag, Num
        
        # Plain text search with BM25STD (default scorer).
        text_query = TextQuery(
            text="running shoes",
            text_field_name="brief_description",
            return_fields=["product_id", "brief_description", "category", "price"],
            num_results=5,
        )
        
        # Add a filter expression to scope the search.
        filtered = TextQuery(
            text="comfortable",
            text_field_name="brief_description",
            filter_expression=Num("price") < 100,
            return_fields=["product_id", "brief_description", "price"],
        )
        
        # Multi-field weighting — search across multiple text fields with per-field weights.
        weighted = TextQuery(
            text="shoes",
            text_field_name={"brief_description": 1.0, "full_description": 0.5},
            return_fields=["product_id", "brief_description"],
        )
        
        # Stopword handling — "english" is the default; pass a custom list or None to disable.
        with_stopwords = TextQuery(text="the best shoes", text_field_name="brief_description", stopwords="english")
        custom_stop    = TextQuery(text="best shoes", text_field_name="brief_description", stopwords=["for", "with"])
        no_stopwords   = TextQuery(text="the best shoes", text_field_name="brief_description", stopwords=None)
        ```
        
        **Scorers:** `TFIDF`, `TFIDF.DOCNORM`, `BM25STD` (default), `BM25STD.NORM`, `BM25STD.TANH`, `DISMAX`, `DOCSCORE`, `HAMMING`. See `11_advanced_queries.ipynb` cells 10–11.
        
        ### AggregationQuery — FT.AGGREGATE shape
        
        `AggregationQuery` lives at `redisvl.query.AggregationQuery` (`redisvl/query/aggregate.py`) and subclasses `redis-py`'s `AggregateRequest`. Its constructor takes a single `query_string` (the FT.AGGREGATE filter expression) and the inherited fluent API matches the one in [`python-redis-py.md#7-ftaggregate-idioms`](./python-redis-py.md#7-ftaggregate-idioms) — `.load()`, `.apply()`, `.group_by()`, `.filter()`, `.sort_by()`, `.limit()`, `.cursor()`.
        
        ```python
        from redisvl.query import AggregationQuery
        from redis.commands.search import reducers
        
        # Top-3 brands by document count, descending — `*` matches all docs.
        agg = (
            AggregationQuery("*")
            .group_by("@brand", reducers.count().alias("n"))
            .sort_by(("@n", "DESC"))
            .limit(0, 3)
        )
        results = index.aggregate(agg)
        ```
        
        Route through `index.aggregate(...)` when you want the RedisVL result-shape normalisation; the underlying command is FT.AGGREGATE on the index's name. The reducer factories live in `redis.commands.search.reducers` (imported from `redis-py`) — RedisVL does not re-export them. `redisvl/query/aggregate.py` declares only the subclass plus `AggregateHybridQuery`, `MultiVectorQuery`, and `Vector`; the fluent surface is inherited verbatim. The user-guide notebooks (`11_advanced_queries.ipynb`) cover the related `AggregateHybridQuery` (cells 34, 37, 42) but do not include a worked `AggregationQuery` example — see `tests/integration/test_aggregation.py` for executable references.
        
        ### SVS Vamana (advanced / optional)
        
        `09_svs_vamana.ipynb` covers Intel's SVS Vamana vector algorithm. Treat it as advanced/optional in v1: it requires a Redis build with SVS support (Intel-optimised) and adds tuning parameters orthogonal to the HNSW/FLAT trade-offs. Declare it via `"algorithm": "svs-vamana"` in the vector field's `attrs`. Most agents should pick FLAT (≤ ~1M vectors) or HNSW (ANN at scale) and only reach for SVS Vamana when explicitly asked.
        
        
        ## 10. Vectorizers
        
        redis-py equivalent: not applicable. Raw `redis-py` has no vectorizer concept — you compute the embedding yourself and pass the raw `bytes` blob into the query (`np.array(...).astype(np.float32).tobytes()`). RedisVL's vectorizers wrap that pattern plus the provider-specific HTTP/SDK call.
        
        ### Canonical example — OpenAI
        
        Mirrors `04_vectorizers.ipynb` cells 4–7:
        
        ```python
        import os
        from redisvl.utils.vectorize import OpenAITextVectorizer
        
        oai = OpenAITextVectorizer(
            model="text-embedding-ada-002",                   # or text-embedding-3-small / -3-large
            api_config={"api_key": os.environ["OPENAI_API_KEY"]},
        )
        
        # Single embedding.
        vec = oai.embed("This is a test sentence.")
        print(len(vec))                                       # 1536 for ada-002 / 3-small
        
        # Batch.
        sentences = ["That is a happy dog", "That is a happy person", "Today is a sunny day"]
        embeddings = oai.embed_many(sentences)
        
        # Async batch — vectorizers expose .aembed_many and .aembed for async use.
        embeddings = await oai.aembed_many(sentences)
        ```
        
        Vectorizers all expose the same surface: `.embed(text)`, `.embed_many(texts)`, `.aembed(text)`, `.aembed_many(texts)`. Pass `as_buffer=True` to `embed_many` to get RedisVL's binary buffer format suitable for direct write into HASH-storage vector fields without the `np.array(...).tobytes()` conversion.
        
        ### Provider table
        
        All providers live under `redisvl.utils.vectorize`. Mirrors `redisvl/utils/vectorize/__init__.py`.
        
        | Class | Provider | Auth | Typical default dim | Upstream notebook cell |
        |-------|----------|------|---------------------|------------------------|
        | `OpenAITextVectorizer` | OpenAI | `api_config={"api_key": ...}` (or `OPENAI_API_KEY`) | 1536 (`ada-002`, `3-small`); 3072 (`3-large`) | `04_vectorizers.ipynb` 4–7 |
        | `AzureOpenAITextVectorizer` | Azure OpenAI | `api_config={"api_key", "api_version", "azure_endpoint"}` | Matches deployment | cells 9–11 |
        | `HFTextVectorizer` | HuggingFace (local `sentence-transformers`) | None (local model) | Model-dependent (768 for `all-mpnet-base-v2`) | cells 13–14 |
        | `VertexAITextVectorizer` (`VertexAIVectorizer`) | Google Vertex AI | `api_config={"project_id", "location", "google_application_credentials"}` | 768 (`text-embedding-005`) | cell 16 |
        | `CohereTextVectorizer` | Cohere | `api_config={"api_key": ...}` (or `COHERE_API_KEY`) | 1024 (`embed-english-v3.0`) | cells 18+ |
        | `BedrockTextVectorizer` (`BedrockVectorizer`) | AWS Bedrock | AWS credentials via boto3 env / profile | 1024 (`amazon.titan-embed-text-v1`) | bedrock cells |
        | `VoyageAITextVectorizer` (`VoyageAIVectorizer`) | VoyageAI | `api_config={"api_key": ...}` | 1024 (`voyage-3`) | voyage cells |
        | `MistralAITextVectorizer` | Mistral | `api_config={"api_key": ...}` | 1024 (`mistral-embed`) | — |
        | `CustomTextVectorizer` (`CustomVectorizer`) | Your own callable | n/a — you pass a `embed` function | Your model | custom-vectorizer section |
        
        RedisVL vectorizers auto-detect the model's dim on first call for known providers — you only need to set `dims` in the schema when the vectorizer doesn't expose it (e.g., custom). When `dims` in the schema disagrees with what the vectorizer produces, `.load()` raises a dimension-mismatch error at write time.
        
        
        ## 11. Hybrid retrieval
        
        redis-py equivalent: see [`python-redis-py.md#10-fthybrid`](./python-redis-py.md#10-fthybrid). RedisVL exposes the same `FT.HYBRID` surface through `HybridQuery` and `AggregateHybridQuery`.
        
        **Version gate:** `HybridQuery` requires **Redis ≥ 8.4.0** and `redis-py >= 7.1.0`. On older Redis or older `redis-py`, fall back to the **pre-filter + KNN** pattern via `VectorQuery(filter_expression=...)` — see below. The upstream `11_advanced_queries.ipynb` gates every `HybridQuery` example on a `HYBRID_SEARCH_AVAILABLE` flag (cells 32, 36, 39, 41).
        
        ### Native FT.HYBRID — `HybridQuery`
        
        Mirrors `11_advanced_queries.ipynb` cell 32:
        
        ```python
        from redisvl.query import HybridQuery
        
        hybrid_query = HybridQuery(
            text="running shoes",
            text_field_name="brief_description",
            vector=[0.1, 0.2, 0.1],                       # query vector
            vector_field_name="text_embedding",
            return_fields=["product_id", "brief_description", "category", "price"],
            num_results=5,
            yield_text_score_as="text_score",
            yield_vsim_score_as="vector_similarity",
            combination_method="LINEAR",                  # or "RRF" (server default)
            linear_alpha=0.3,                             # 30% text, 70% vector
            yield_combined_score_as="hybrid_score",
        )
        
        results = index.query(hybrid_query)
        ```
        
        **Combination methods:**
        
        - `combination_method="RRF"` — Reciprocal Rank Fusion. Rank-based, robust without weight tuning. Knobs: `rrf_window` (default 20), `rrf_constant` (default 60).
        - `combination_method="LINEAR"` — weighted score blend. Knob: `linear_alpha` (text weight; `1 - alpha` is the implicit vector weight).
        
        **Scorers** (text leg): same options as `TextQuery` — `BM25STD` (default), `TFIDF`, `DISMAX`, etc.
        
        **Filter expressions** apply to both legs:
        
        ```python
        filtered_hybrid = HybridQuery(
            text="professional equipment",
            text_field_name="brief_description",
            vector=[0.9, 0.1, 0.05],
            vector_field_name="text_embedding",
            filter_expression=Num("price") > 100,
            combination_method="LINEAR",
            yield_text_score_as="text_score",
            yield_vsim_score_as="vector_similarity",
            yield_combined_score_as="hybrid_score",
        )
        ```
        
        ### Aggregate-shaped hybrid — `AggregateHybridQuery`
        
        When you want the FT.AGGREGATE-shape output (rows, group-by stages) instead of the FT.SEARCH-shape:
        
        ```python
        from redisvl.query import AggregateHybridQuery
        
        agg_hybrid = AggregateHybridQuery(
            text="running shoes",
            text_field_name="brief_description",
            vector=[0.1, 0.2, 0.1],
            vector_field_name="text_embedding",
            return_fields=["product_id", "brief_description", "category", "price"],
            alpha=0.7,                                    # 70% vector, 30% text
            num_results=5,
        )
        results = index.query(agg_hybrid)
        ```
        
        ### Fallback for Redis < 8.4.0 — pre-filter + KNN
        
        When `FT.HYBRID` is unavailable, blend retrieval by applying the text filter as a **pre-filter** on the vector query:
        
        ```python
        from redisvl.query import VectorQuery
        from redisvl.query.filter import Text, Num
        
        query = VectorQuery(
            vector=query_vector,
            vector_field_name="text_embedding",
            return_fields=["product_id", "brief_description", "price", "vector_distance"],
            num_results=10,
            # Pre-filter shrinks the candidate set before the KNN scan.
            filter_expression=(Text("brief_description") % "running") & (Num("price") < 200),
        )
        results = index.query(query)
        ```
        
        This is **not** the same operation as `FT.HYBRID`: it filters by text but ranks purely by vector distance. Use it when (a) the Redis version doesn't support `FT.HYBRID`, or (b) the workload only needs filtered KNN, not blended ranking. The upstream `02_complex_filtering.ipynb` cell 8 demonstrates the pattern in its native shape.
        
        
        ## 12. Async
        
        redis-py equivalent: see [`python-redis-py.md`](./python-redis-py.md) footer (async out of scope for v1).
        
        `AsyncSearchIndex` mirrors `SearchIndex` method-for-method on the search/index/query surface. Async parity is **at full coverage** for the operations relevant to this reference — `create`, `delete`, `load`, `fetch`, `query`, `paginate` (async generator), `aggregate`, `search`, `clear`, `drop_keys`, `drop_documents`, `expire_keys`, `exists`, `info`, `listall`.
        
        ```python
        from redisvl.index import AsyncSearchIndex
        from redis.asyncio import Redis
        
        client = Redis.from_url("redis://localhost:6379")
        index = AsyncSearchIndex.from_dict(schema, redis_client=client)
        
        await index.create(overwrite=True, drop=False)
        await index.load(data)
        
        # Same query classes; same construction.
        from redisvl.query import VectorQuery
        query = VectorQuery(vector=[0.1, 0.1, 0.5], vector_field_name="user_embedding",
                            return_fields=["user", "age"], num_results=3)
        results = await index.query(query)
        
        await index.delete(drop=True)
        ```
        
        Mirrors `01_getting_started.ipynb` cells 41–47.
        
        **Divergences from the sync API:**
        
        - `from_existing(...)` is async on `AsyncSearchIndex` — `await AsyncSearchIndex.from_existing(name, redis_url=...)`. Sync is `SearchIndex.from_existing(name, redis_url=...)`.
        - `connect(...)` and `set_client(...)` are async.
        - `paginate` is an async generator: `async for batch in index.paginate(query, page_size=N):`.
        - No context-manager support equivalent to `SearchIndex.__enter__` / `__exit__` — manage lifetime manually with `await index.disconnect()`.
        
        Vectorizers expose `.aembed(text)` and `.aembed_many(texts)` for the embedding call itself — pair them with `AsyncSearchIndex` for end-to-end async pipelines (see §10).
        
        
        ## 13. LLM primitives (summary level)
        
        Full coverage of the LLM-primitive surface is deferred to a future dedicated spec. The summaries below are intentionally narrow — minimal constructor + the upstream notebook to read for depth. None of these primitives are necessary for the core search/index/query surface this reference covers.
        
        ### SemanticCache — semantic prompt → response cache
        
        Mirrors `03_llmcache.ipynb` cell 5.
        
        ```python
        from redisvl.extensions.cache.llm import SemanticCache
        from redisvl.utils.vectorize import HFTextVectorizer
        
        llmcache = SemanticCache(
            name="llmcache",                                              # underlying search index name
            redis_url="redis://localhost:6379",
            distance_threshold=0.1,                                       # cosine distance [0, 2]; lower = stricter
            vectorizer=HFTextVectorizer("redis/langcache-embed-v2"),
        )
        
        llmcache.store(prompt="What is the capital of France?", response="Paris")
        hit = llmcache.check(prompt="capital city of France?")          # returns cached response on semantic match
        ```
        
        `filterable_fields=[{"name": "user_id", "type": "tag"}]` partitions the cache by tenant/user. Defer to `03_llmcache.ipynb` for filterable-field semantics, TTLs, and metadata.
        
        **LangCache vs SemanticCache:** `SemanticCache` is the in-process Python class shown above. **LangCache** (`13_langcache_semantic_cache.ipynb`) is a separate Redis-hosted product — a managed semantic-cache service on Redis Cloud. They share a vector-search shape but live in different scopes. For LangCache coverage, fall through to the `redis-semantic-cache` skill (a separate skill in this repo) rather than treating it inline as a RedisVL primitive.
        
        ### MessageHistory — durable chat-history with optional semantic recall
        
        Mirrors `07_message_history.ipynb` cells 1, 12.
        
        ```python
        from redisvl.extensions.message_history import MessageHistory, SemanticMessageHistory
        
        # Plain FIFO chat history.
        chat = MessageHistory(name="student tutor")
        chat.add_message({"role": "user", "content": "Explain backprop."})
        recent = chat.get_recent(top_k=8)
        
        # Vector-recall over the same history — fetches semantically similar past turns.
        semantic = SemanticMessageHistory(name="tutor")
        semantic.add_messages(recent)
        relevant = semantic.get_relevant("How does gradient descent relate to backprop?")
        ```
        
        Defer to `07_message_history.ipynb` for session tagging, role filtering, TTLs.
        
        ### SemanticRouter — route a query to a labelled bucket via vector match
        
        Mirrors `08_semantic_router.ipynb` cells 2, 4.
        
        ```python
        from redisvl.extensions.router import SemanticRouter, Route
        from redisvl.utils.vectorize import HFTextVectorizer
        
        tech = Route(
            name="technology",
            references=["what are the latest advancements in AI?", "tell me about the newest gadgets"],
            metadata={"category": "tech"},
            distance_threshold=0.71,
        )
        
        router = SemanticRouter(
            name="topic-router",
            vectorizer=HFTextVectorizer(),
            routes=[tech, ...],
            redis_url="redis://localhost:6379",
            overwrite=True,
        )
        
        match = router("what's new in machine learning?")           # -> Route name + score
        ```
        
        Defer to `08_semantic_router.ipynb` for multi-route disambiguation, `from_dict` / `from_yaml` persistence, and threshold tuning.
        
        ### Other extensions (also summary-only)
        
        - **EmbeddingsCache** (`redisvl.extensions.cache.embeddings.EmbeddingsCache`) — cache for `embed()` calls to avoid recomputation. See `10_embeddings_cache.ipynb`.
        - **Rerankers** (`redisvl.utils.rerank`) — `HFCrossEncoderReranker`, `CohereReranker`, `VoyageAIReranker` for second-stage cross-encoder reranking after initial vector retrieval. See `06_rerankers.ipynb`.
        
        
        ## 14. Common errors & version gotchas
        
        redis-py equivalent: see [`python-redis-py.md#13-common-errors--version-gotchas`](./python-redis-py.md#13-common-errors--version-gotchas).
        
        | Symptom | Likely cause | Fix |
        |---------|--------------|-----|
        | `redis.exceptions.ResponseError: unknown command 'FT.HYBRID'` from `HybridQuery` | Redis server < 8.4.0 or `redis-py` < 7.1.0. | Upgrade both, or fall back to pre-filter + `VectorQuery` (§11). |
        | `_IMPORT_ERROR_MESSAGE = "Hybrid queries require Redis >= 8.4.0 and redis-py>=7.1.0"` | Importing `HybridQuery` against an older `redis-py`. | Upgrade `redis-py`; gate import on `try`/`except ImportError`. |
        | `Vector dimension mismatch` on `.load()` | Vectorizer output dim ≠ schema `dims`. | Set `dims` to match the vectorizer's actual output, or pick a model whose dim matches the schema. |
        | `.load()` raises `ValidationError` | `validate_on_load=True` and a doc has wrong shape (missing field, wrong type). | Either fix the doc or set `validate_on_load=False` (test/dev only). |
        | Vector query returns 0 hits despite obvious matches | HASH-storage vector field was loaded with a `list[float]` instead of `np.array(...).astype(np.float32).tobytes()`. | For HASH: convert to bytes. For JSON: lists work natively. |
        | GEOSHAPE WITHIN/CONTAINS returns syntax error | RedisVL defaults to DIALECT 2; `WITHIN`/`CONTAINS` need DIALECT 3. | Pass `dialect=3` on the query class. |
        | `OPENAI_API_KEY` `AuthenticationError` from `OpenAITextVectorizer` | Missing env var or wrong key passed to `api_config`. | Set `OPENAI_API_KEY` in the env, or pass `api_config={"api_key": "..."}` explicitly. |
        | `AttributeError: 'SearchIndex' object has no attribute 'X'` after a RedisVL minor upgrade | RedisVL renamed/moved an API between minors (`0.x` is pre-1.0). | Pin RedisVL to a known-good version in `requirements.txt`; check the changelog before upgrading. |
        | `ImportError: cannot import name 'HybridQuery' from 'redisvl.query'` | RedisVL < 0.18 (or `redis-py` < 7.1.0 at install time). | Upgrade RedisVL to ≥ 0.18 and `redis-py` to ≥ 7.1.0. |
        | `AsyncSearchIndex.from_existing(...)` raises "coroutine was never awaited" | The sync API was used. | `await AsyncSearchIndex.from_existing(...)` — async-side method is a coroutine. |
        | `index.query(query)` returns rows where every field is `None` | The dict's `return_fields` was empty AND the index is JSON-storage. | Explicitly pass `return_fields=[...]` — RedisVL doesn't auto-return the full JSON doc. |
        | `index.create()` errors `Index already exists` on re-run | `.create()` is not idempotent without `overwrite=True`. | `.create(overwrite=True)` for dev bootstrap; gate on `.exists()` for production. |
        
        **DIALECT defaults:** RedisVL passes `dialect=2` on every query class by default. You do not need to set it. Override to `dialect=3` for GEOSHAPE `WITHIN`/`CONTAINS`. The Redis server default (DIALECT 2 from Redis 8.0+) does not affect RedisVL because RedisVL always sends DIALECT explicitly.
        
        **`rvl` CLI tool:** the `rvl` CLI (`rvl index info`, `rvl stats`, `rvl index list`) is a productivity tool for inspecting indexes from the shell. Deferred from v1 of this reference — agents generating Python code rarely need it. See `cli.ipynb` upstream.
        
        
        ## 15. Upstream examples index
        
        redis-py equivalent: see [`python-redis-py.md#14-upstream-examples-index`](./python-redis-py.md#14-upstream-examples-index).
        
        Curated mapping of operation → notebook + cell. Upstream files live at `https://github.com/redis/redis-vl-python/blob/main/docs/user_guide/<file>`. Source classes live in `redis/redis-vl-python/redisvl/...`.
        
        | Operation | Notebook + cell | Upstream class / method |
        |-----------|-----------------|-------------------------|
        | Schema dict (`index`, `fields`, `attrs`) | `01_getting_started.ipynb` cell 3 | `IndexSchema.from_dict` (`redisvl/schema/schema.py`) |
        | `SearchIndex` construction (client / URL / default) | `01_getting_started.ipynb` cells 9, 11 | `SearchIndex.__init__`, `.from_dict`, `.from_yaml` (`redisvl/index/index.py`) |
        | `index.create(overwrite=True)` | cell 13 | `SearchIndex.create` |
        | `index.load(data)` | cell 18 | `SearchIndex.load` |
        | `index.load(data, id_field="user")` + `index.fetch("john")` | cell 26 | `SearchIndex.load`, `.fetch` |
        | `index.key("john")` | cell 28 | `SearchIndex.key` |
        | `FilterQuery` + `index.paginate(query, page_size=...)` | cell 30 | `redisvl.query.FilterQuery`, `SearchIndex.paginate` |
        | `index.drop_keys` / `.drop_documents` | cells 32–33 | `SearchIndex.drop_keys`, `.drop_documents` |
        | `VectorQuery` KNN | cell 36 | `redisvl.query.VectorQuery` |
        | `VectorRangeQuery` / `RangeQuery` (distance threshold) | not in user-guide notebooks — see `tests/integration/test_query.py` | `redisvl.query.VectorRangeQuery`, `RangeQuery` (`redisvl/query/query.py`) |
        | `CountQuery` (FT.SEARCH ... LIMIT 0 0) | not in user-guide notebooks — see `tests/integration/test_query.py` | `redisvl.query.CountQuery` (`redisvl/query/query.py`) |
        | `AggregationQuery` fluent (`.group_by` / `.reduce` / `.sort_by`) | not in user-guide notebooks — see `tests/integration/test_aggregation.py` | `redisvl.query.AggregationQuery` (`redisvl/query/aggregate.py`), inherits `AggregateRequest` from `redis.commands.search.aggregation` |
        | `AsyncSearchIndex` + async query | cells 41–47 | `AsyncSearchIndex.__init__`, `.query` |
        | Schema mutate: `remove_field` + `add_fields` | cell 45 | `IndexSchema.remove_field`, `.add_fields` |
        | `Tag(field) == value` | `02_complex_filtering.ipynb` cell 8 | `redisvl.query.filter.Tag` |
        | `Tag != / list / set / empty` | cells 10, 12, 13, 15 | `Tag.__eq__`, `__ne__` |
        | `Num.between` / `==` / `!=` | cells 17–19 | `redisvl.query.filter.Num` |
        | `Timestamp > / < / .between` | cells 21–23 | `redisvl.query.filter.Timestamp` |
        | `Text == / != / %` (wildcard, fuzzy, `engineer|doctor`) | cells 25–29 | `redisvl.query.filter.Text` |
        | `Geo == GeoRadius(lon, lat, r, units)` | cells 34–36 | `redisvl.query.filter.Geo`, `GeoRadius` |
        | Boolean composition `&`, `\|` | cells 38, 40 | `FilterExpression.__and__`, `__or__` |
        | `OpenAITextVectorizer.embed` / `.embed_many` / `.aembed_many` | `04_vectorizers.ipynb` cells 5–7 | `redisvl.utils.vectorize.OpenAITextVectorizer` |
        | `AzureOpenAITextVectorizer` setup | cells 9–11 | `redisvl.utils.vectorize.AzureOpenAITextVectorizer` |
        | `HFTextVectorizer` (local sentence-transformers) | cells 13–14 | `redisvl.utils.vectorize.HFTextVectorizer` |
        | `VertexAIVectorizer` setup | cell 16 | `redisvl.utils.vectorize.VertexAIVectorizer` |
        | HASH-storage schema + `.create` | `05_hash_vs_json.ipynb` cells 5–6 | `SearchIndex.from_dict` with `storage_type: hash` |
        | JSON-storage schema + `.create` | cells 16–17 | `SearchIndex.from_dict` with `storage_type: json` |
        | Bike-data + nested JSON metadata | cells 26–27 | (Bicycle-shaped dataset for downstream RAG examples) |
        | `TextQuery` BM25 / TFIDF / weighted / stopwords | `11_advanced_queries.ipynb` cells 8–20 | `redisvl.query.TextQuery` |
        | `FilterQuery` for STOPWORDS-0 raw query | cell 26 | `redisvl.query.FilterQuery` |
        | `HybridQuery` LINEAR + RRF + filter | cells 32, 36, 39, 41 | `redisvl.query.HybridQuery` |
        | `AggregateHybridQuery` | cells 34, 37 | `redisvl.query.AggregateHybridQuery` |
        | `SemanticCache` constructor + `.store` / `.check` | `03_llmcache.ipynb` cell 5 | `redisvl.extensions.cache.llm.SemanticCache` |
        | `SemanticCache` with `filterable_fields` | cells 41, 45 | `SemanticCache(filterable_fields=[...])` |
        | `MessageHistory` / `SemanticMessageHistory` | `07_message_history.ipynb` cells 1, 12 | `redisvl.extensions.message_history.MessageHistory`, `SemanticMessageHistory` |
        | `SemanticRouter` + `Route` | `08_semantic_router.ipynb` cells 2, 4 | `redisvl.extensions.router.SemanticRouter`, `Route` |
        | `SemanticRouter.from_yaml` | cell 21 | `SemanticRouter.from_yaml` |
        | Canonical `schema.yaml` | `docs/user_guide/schema.yaml` | `IndexSchema.from_yaml` |
        | SVS Vamana algorithm (advanced) | `09_svs_vamana.ipynb` | `"algorithm": "svs-vamana"` in vector field `attrs` |
        | `EmbeddingsCache` | `10_embeddings_cache.ipynb` | `redisvl.extensions.cache.embeddings.EmbeddingsCache` |
        | `rvl` CLI (deferred) | `cli.ipynb` | Out of scope for v1 |
        | LangCache (Redis-hosted product) | `13_langcache_semantic_cache.ipynb` | Cross-link to the `redis-semantic-cache` skill |
        
    • aggregate-cursors.md 3.9 KB
      # Paginate Large Aggregations with FT.CURSOR
      
      `FT.AGGREGATE ... LIMIT 0 1000000` materializes the whole result on the server before responding. For large aggregates (millions of groups, long fan-outs), use `WITHCURSOR` and stream batches via `FT.CURSOR READ`. Cursors that aren't read or deleted live until `MAXIDLE` elapses and then are GC'd — explicitly `FT.CURSOR DEL` when you're done.
      
      **Correct:** Open a cursor, drain it in batches, release it.
      
      ```
      # Open the cursor — COUNT 1000 = up to 1000 rows per batch, MAXIDLE in ms
      FT.AGGREGATE idx:bicycle "*"
          GROUPBY 1 @brand
              REDUCE COUNT 0 AS bike_count
          SORTBY 2 @bike_count DESC
          WITHCURSOR COUNT 1000 MAXIDLE 30000
          DIALECT 2
      # → reply: { rows..., cursor_id: 12345 }   (cursor_id = 0 means exhausted)
      
      # Pull the next batch
      FT.CURSOR READ idx:bicycle 12345 COUNT 1000
      # → reply: { rows..., cursor_id: 12345 or 0 }
      
      # Release explicitly when you stop early — don't wait for MAXIDLE
      FT.CURSOR DEL idx:bicycle 12345
      ```
      
      ## Cursor lifecycle
      
      - `COUNT n` — max rows per response (the server may return fewer).
      - `MAXIDLE ms` — server discards the cursor after this idle time. Default is server-config-dependent (typically 30s).
      - A returned `cursor_id` of `0` means the result set is fully drained.
      - Cursors are scoped to a specific index; the read/del calls take both `<index>` and `<cursor_id>`.
      
      **Incorrect:** Leaking cursors or trying to paginate aggregates with `LIMIT offset n` for large `n`.
      
      ```
      # Bad: LIMIT 1000000 5000 — server must compute and skip the first million rows
      FT.AGGREGATE idx:bicycle "*" GROUPBY 1 @brand REDUCE COUNT 0 AS n
          SORTBY 2 @n DESC
          LIMIT 1000000 5000
          DIALECT 2
      
      # Bad: Open WITHCURSOR, take first batch, never call FT.CURSOR DEL.
      # Cursor leaks until MAXIDLE; long-running ETL jobs accumulate them.
      ```
      
      ## When to use cursors
      
      **Use when:**
      
      - Aggregations expected to return > ~10k rows.
      - Streaming results into an ETL/export pipeline.
      - Background analytics where you want bounded memory at both ends.
      
      **Skip when:**
      
      - Top-N analytics (`SORTBY ... LIMIT 0 100`) — the result fits in one response.
      - Real-time dashboard queries where you only show the top page.
      
      ## Client mirrors
      
      ```python
      # redis-py — STEP_START aggregate_cursor
      from redis import Redis
      from redis.commands.search.aggregation import AggregateRequest
      from redis.commands.search.reducers import count
      
      r = Redis()
      req = (AggregateRequest("*")
             .group_by("@brand", count().alias("bike_count"))
             .sort_by(("@bike_count", "DESC"))
             .with_cursor(count=1000, max_idle=30000)
             .dialect(2))
      res = r.ft("idx:bicycle").aggregate(req)
      # process res.rows ...
      while res.cursor and res.cursor.cid:
          res = r.ft("idx:bicycle").aggregate(res.cursor)
          # process res.rows ...
      # STEP_END
      ```
      
      ```java
      // Jedis — STEP_START aggregate_cursor
      import redis.clients.jedis.UnifiedJedis;
      import redis.clients.jedis.search.aggr.AggregationBuilder;
      import redis.clients.jedis.search.aggr.AggregationResult;
      import redis.clients.jedis.search.aggr.Reducers;
      
      try (UnifiedJedis jedis = new UnifiedJedis("redis://localhost:6379")) {
          AggregationBuilder agg = new AggregationBuilder("*")
              .groupBy("@brand", Reducers.count().as("bike_count"))
              .cursor(1000, 30000)
              .dialect(2);
          AggregationResult res = jedis.ftAggregate("idx:bicycle", agg);
          long cursorId = res.getCursorId();
          while (cursorId != 0) {
              res = jedis.ftCursorRead("idx:bicycle", cursorId, 1000);
              cursorId = res.getCursorId();
          }
          // jedis.ftCursorDel("idx:bicycle", cursorId) if exiting early
      }
      // STEP_END
      ```
      
      ## Upstream sources
      
      - No direct upstream example — authored from official Redis Search command documentation.
      - Reference: [FT.AGGREGATE WITHCURSOR](https://redis.io/docs/latest/commands/ft.aggregate/), [FT.CURSOR READ](https://redis.io/docs/latest/commands/ft.cursor-read/), [FT.CURSOR DEL](https://redis.io/docs/latest/commands/ft.cursor-del/)
      
    • aggregate-pipeline.md 15 KB
      # Build FT.AGGREGATE Pipelines in the Correct Stage Order
      
      `FT.AGGREGATE` runs stages in the order you write them, like a Unix pipeline. The canonical order is `LOAD → APPLY → FILTER → GROUPBY/REDUCE → APPLY → SORTBY → LIMIT`. Swapping stages doesn't error — it silently changes what your query computes. For paginating large aggregates, see [aggregate-cursors.md](aggregate-cursors.md).
      
      **Correct:** Canonical pipeline against the Bicycle dataset — load needed fields, project a derived field, filter, group, sort, limit.
      
      ```
      # Average price per brand for mountain bicycles, top 5 brands
      FT.AGGREGATE idx:bicycle "@type:{mountain}"
          LOAD 3 @brand @price @condition
          APPLY "@price * 0.9" AS sale_price
          FILTER "@condition == 'new'"
          GROUPBY 1 @brand
              REDUCE COUNT 0 AS bike_count
              REDUCE AVG 1 @price AS avg_price
              REDUCE AVG 1 @sale_price AS avg_sale_price
          SORTBY 2 @avg_price DESC
          LIMIT 0 5
          DIALECT 2
      ```
      
      ## Stages, in order
      
      | Stage | Purpose | Notes |
      |-------|---------|-------|
      | `LOAD n @f1 @f2 ...` | Hydrate fields from the source doc into the pipeline. | Only loaded fields are visible to later stages. `LOAD *` pulls everything (expensive). |
      | `APPLY <expr> AS alias` | Project a computed field. | Operates row-by-row before grouping. |
      | `FILTER <expr>` | Drop rows that fail a predicate. | Filters *pipeline rows*, not the underlying index. Index-level filters belong in the query string. |
      | `GROUPBY n @f1 ... REDUCE <fn> ...` | Collapse rows that share group keys. | Reducers: `COUNT`, `COUNT_DISTINCT`, `SUM`, `AVG`, `MIN`, `MAX`, `STDDEV`, `QUANTILE`, `TOLIST`, `FIRST_VALUE`, `RANDOM_SAMPLE`. |
      | `APPLY` (post-group) | Compute derived fields over reducer output. | E.g. `APPLY "@bike_count / @brand_count" AS share`. |
      | `SORTBY n @f1 ASC ...` | Order the result. | The `n` is the count of (field, direction) tokens. |
      | `LIMIT offset num` | Slice the result. | For result sets > 1000 rows, use `WITHCURSOR` (see [aggregate-cursors.md](aggregate-cursors.md)). |
      
      > **Common errors per stage** — see "Counting tokens, not fields" and "FILTER and LOAD discipline" below for nargs miscount on `LOAD`/`GROUPBY`/`SORTBY`/`REDUCE COUNT`, missing `@` on pipeline field references, missing `ASC`/`DESC` on `SORTBY`, and FILTER-before-LOAD errors.
      
      ## Common reducers — quick reference
      
      ```
      REDUCE COUNT 0 AS n                          # count rows in group
      REDUCE COUNT_DISTINCT 1 @user_id AS uniq     # distinct values of @user_id
      REDUCE SUM 1 @price AS total
      REDUCE AVG 1 @price AS mean
      REDUCE MIN 1 @price AS lo
      REDUCE MAX 1 @price AS hi
      REDUCE QUANTILE 2 @price 0.95 AS p95
      REDUCE TOLIST 1 @model AS models             # collect into a list
      REDUCE FIRST_VALUE 1 @model BY @price DESC AS top_model
      ```
      
      ## Counting tokens, not fields
      
      The most frequent class of `FT.AGGREGATE` parse errors is treating `<nargs>` as "number of semantic fields" when Redis counts *tokens that follow*. Same root cause, four shapes.
      
      **Every pipeline field reference starts with `@`.** The `@` is part of the field token in `LOAD`, `GROUPBY`, `SORTBY`, `APPLY`, and `FILTER` — not just in the query string. Inside expressions like `@field >= 5` or `substr(@date, 0, 4)`, the `@` is still required.
      
      ```
      # Bad: missing @ on pipeline field references
      GROUPBY 1 category                       # "Unknown property 'category'. Did you mean '@category'?"
      APPLY substr(date, 0, 4) AS year         # "Unknown symbol 'date'"
      FILTER "date >= '2022-01'"               # "Unknown symbol 'date'"
      
      # Good
      GROUPBY 1 @category
      APPLY substr(@date, 0, 4) AS year
      FILTER "@date >= '2022-01'"
      ```
      
      **`REDUCE` always follows a `GROUPBY`. For a whole-result aggregate, use `GROUPBY 0`.**
      
      ```
      # Bad: REDUCE without a preceding GROUPBY — "Unknown argument 'REDUCE' at position 1"
      FT.AGGREGATE idx:bicycle "@type:{mountain}" REDUCE AVG 1 @price AS avg_price DIALECT 2
      
      # Good: single-row aggregate over all matched docs
      FT.AGGREGATE idx:bicycle "@type:{mountain}"
          GROUPBY 0
              REDUCE AVG 1 @price AS avg_price
          DIALECT 2
      ```
      
      **`REDUCE COUNT 0` — the `0` is mandatory even though `COUNT` takes no args.** `<nargs>` is the count of arguments to the reducer, regardless of whether the reducer "really" needs any.
      
      ```
      # Bad: "Bad arguments for COUNT: could not convert ..."
      GROUPBY 0 REDUCE COUNT AS count
      
      # Good
      GROUPBY 0 REDUCE COUNT 0 AS count
      ```
      
      **`LOAD <n>` also counts tokens, not fields. `path AS alias` = 3 tokens.** Same counting rule as `RETURN`. An aliased JSONPath (`$.path AS alias`) needs nargs=3; an unaliased load needs nargs=1.
      
      ```
      # Bad: nargs=1 but the LOAD has 3 tokens — "Unknown argument 'AS' at position 4"
      LOAD 1 $.beers[?(@.abv >= 0.07)] AS match
      LOAD 1 $.brewery_id AS brewery_id
      
      # Good: 1 aliased path = 3 tokens
      LOAD 3 $.beers[?(@.abv >= 0.07)] AS match
      
      # Good: 2 unaliased + 1 aliased = 2 + 3 = 5 tokens
      LOAD 5 @date @subject $.event.ts AS ts
      ```
      
      **`SORTBY <n>` counts tokens, not fields. Each sort entry is `@field ASC|DESC` = 2 tokens. Always supply `ASC` or `DESC`.**
      
      ```
      # Bad: SORTBY 1 @count DESC — Redis consumes 1 token, then "Unknown argument 'DESC'"
      SORTBY 1 @count DESC
      
      # Bad: SORTBY 2 @brand DESC @price ASC — said 2, gave 4, trailing tokens become unknown args
      SORTBY 2 @brand DESC @price ASC
      
      # Good: one sort entry = 2 tokens
      SORTBY 2 @count DESC
      
      # Good: two sort entries = 4 tokens
      SORTBY 4 @count DESC @brand ASC
      ```
      
      A single pipeline can contain at most **one** `SORTBY` step ("Multiple SORTBY steps are not allowed"). For a secondary sort, extend the same token list rather than writing two `SORTBY` clauses.
      
      **For top-N, prefer `SORTBY … MAX N` over a trailing `LIMIT 0 N`.** `MAX` lives inside `SORTBY`, is more efficient, and does not compose with `LIMIT` — pick one.
      
      ```
      # Good (preferred): in-sort limit
      SORTBY 2 @count DESC MAX 5
      
      # Equivalent but less efficient
      SORTBY 2 @count DESC
          LIMIT 0 5
      
      # Bad: combining MAX and LIMIT — redundant; pick one
      SORTBY 2 @count DESC MAX 5 LIMIT 0 5
      ```
      
      ## FILTER and LOAD discipline
      
      Pipeline `FILTER` operates on *pipeline rows*, which only contain attributes that were declared `SORTABLE` in the schema (auto-projected) or explicitly `LOAD`ed. Plain TEXT/TAG fields are not auto-projected.
      
      **Prefer query-string filters over pipeline `FILTER` whenever possible.** The query string runs against the index; pipeline `FILTER` runs after candidates are materialized.
      
      ```
      # Bad: FILTER on a TEXT field that wasn't loaded — "Unknown symbol 'date'"
      FT.AGGREGATE idx:observations "@code:\"heart rate\""
          GROUPBY 0 REDUCE COUNT 0 AS count
          FILTER "@date >= '2023-01-01'"
          DIALECT 2
      
      # Good: express the date filter in the query string (it hits the index directly)
      FT.AGGREGATE idx:observations "@date:2023* @code:\"heart rate\""
          GROUPBY 0 REDUCE COUNT 0 AS count
          DIALECT 2
      
      # Also valid: LOAD the field first, then FILTER on it in the pipeline
      FT.AGGREGATE idx:observations "@code:\"heart rate\""
          LOAD 1 @date
          FILTER "@date >= '2023-01-01'"
          GROUPBY 0 REDUCE COUNT 0 AS count
          DIALECT 2
      ```
      
      **Downstream stages must reference the exact alias that was `LOAD`ed.** Loading a JSONPath without `AS` projects it under the path string, not its basename.
      
      ```
      # Bad: loaded $.subject, then referenced @timestamp
      #       → "Property '@timestamp' not loaded nor in schema"
      LOAD 1 $.subject
      SORTBY 2 @timestamp DESC
      
      # Good: load every alias you'll reference; rename with AS when needed
      LOAD 2 @date @subject
      SORTBY 2 @date DESC
      
      # Good: explicit AS for a nested JSONPath
      LOAD 2 $.subject AS subject $.event.ts AS ts
      SORTBY 2 @ts DESC
      ```
      
      **Nested JSON array predicates.** For "how many parents have at least one child matching X," use a JSONPath predicate + `exists`:
      
      ```
      # How many breweries have at least one beer with ABV >= 0.07?
      FT.AGGREGATE idx:breweries "*"
          LOAD 1 $.beers[?(@.abv >= 0.07)] AS match
          FILTER "exists(@match)"
          GROUPBY 0 REDUCE COUNT 0 AS qualifying_breweries
          DIALECT 2
      ```
      
      ## Reducer by intent
      
      Match the reducer to the *shape* of the question.
      
      ```
      # "how many"                  → GROUPBY 0 REDUCE COUNT 0 AS n
      # "how many distinct X"       → GROUPBY 1 @x  GROUPBY 0 REDUCE COUNT 0 AS n
      #                                (or single-step: REDUCE COUNT_DISTINCT 1 @x AS n)
      # "list distinct X"           → GROUPBY 0 REDUCE TOLIST 1 @x AS list
      # "sum / total"               → REDUCE SUM 1 @field AS total
      # "average per Y"             → GROUPBY 1 @y REDUCE AVG 1 @field AS avg
      # "top row by stat"           → REDUCE FIRST_VALUE 4 @other_field BY @stat DESC AS top
      # "p95 / quantile"            → REDUCE QUANTILE 2 @field 0.95 AS p95
      # "min / max within group"    → REDUCE MIN 1 @field  /  REDUCE MAX 1 @field
      ```
      
      ## Multi-step pipeline patterns
      
      These compose multiple `GROUPBY` + `REDUCE` steps. The pipeline is sequential: each stage's output becomes the next stage's input, so the order of stages matters and the field names must thread through.
      
      **Top-N per group (per-group count, then global sort + cap).** "Top N most frequent X per Y" — group by both first, then re-group by Y and pick the top X.
      
      ```
      # Q: "Top brand per state by bike count"
      FT.AGGREGATE idx:bicycle "*"
          GROUPBY 2 @state @brand
              REDUCE COUNT 0 AS cnt
          GROUPBY 1 @state
              REDUCE FIRST_VALUE 4 @brand BY @cnt DESC AS top_brand
              REDUCE MAX 1 @cnt AS top_cnt
          SORTBY 2 @top_cnt DESC
          DIALECT 2
      ```
      
      **Distinct values with their counts (single group + count).** Two-column return: the value and how many docs have it.
      
      ```
      # Q: "How many bikes per category"
      FT.AGGREGATE idx:bicycle "*"
          GROUPBY 1 @category
              REDUCE COUNT 0 AS n
          SORTBY 2 @n DESC
          DIALECT 2
      ```
      
      **Count distinct (two-stage)** — when you need *how many unique values*, not a per-value list. The first `GROUPBY` collapses duplicates; the second counts the resulting rows.
      
      ```
      # Q: "How many distinct brands sell mountain bikes?"
      FT.AGGREGATE idx:bicycle "@type:{mountain}"
          GROUPBY 1 @brand
          GROUPBY 0 REDUCE COUNT 0 AS distinct_brand_count
          DIALECT 2
      ```
      
      **Bucket-by-derived-field (APPLY before GROUPBY).** When the bucket isn't a stored field — extract it with `APPLY`, then group on the alias.
      
      ```
      # Q: "Bites per year"
      FT.AGGREGATE idx:bites "*"
          LOAD 1 @DateOfBite
          APPLY year(@DateOfBite) AS year
          GROUPBY 1 @year REDUCE COUNT 0 AS n
          SORTBY 2 @year ASC
          DIALECT 2
      ```
      
      **Filter-after-derive (year extraction + filter).** When the question is "in YEAR X" but the field is a timestamp — `APPLY year(@ts) AS year` then `FILTER "@year == X"`. Don't try to express this in the query string; the query DSL has no `year()`.
      
      ```
      # Q: "How many bites in 2016 by breed"
      FT.AGGREGATE idx:bites "@Breed:rottweiler"
          LOAD 1 @DateOfBite
          APPLY year(@DateOfBite) AS year
          FILTER "@year == 2016"
          GROUPBY 0 REDUCE COUNT 0 AS n
          DIALECT 2
      ```
      
      **Multi-stage rule:** every stage produces a flat row of `(name, value)` pairs. Downstream stages only see names that earlier stages emitted — either a `LOAD`ed field, a `GROUPBY` key, a `REDUCE … AS alias`, or an `APPLY … AS alias`. Reference fields via their *current* alias, not the source path.
      
      ## APPLY functions
      
      `APPLY` accepts a fixed allowlist of math, string, and time functions. **`round`, `now()`, and `date()` do not exist** — invoking them returns `Unknown function name`.
      
      | Category | Functions |
      |----------|-----------|
      | Math | `ceil`, `floor`, `abs`, `log`, `exp`, `sqrt`, `pow`, `mod` |
      | String | `substr`, `format`, `upper`, `lower`, `matched_terms`, `contains`, `startswith`, `strlen` |
      | Time | `parse_time`, `day`, `month`, `year`, `monthofyear`, `dayofweek`, `dayofmonth`, `dayofyear`, `hour`, `minute`, `timefmt` |
      | Geo | `geodistance` |
      
      **No `round` — emulate via `floor` / `ceil`.**
      
      ```
      # Round @weight to 2 decimal places
      APPLY floor(@weight * 100) / 100 AS weight_rounded
      ```
      
      **`substr(s, start, length)` — 0-indexed start, length is the number of characters to take (not the end position).** ISO dates `YYYY-MM-DD`:
      
      ```
      APPLY substr(@date, 0, 4) AS year      # YYYY
      APPLY substr(@date, 5, 2) AS month     # MM
      APPLY substr(@date, 8, 2) AS day       # DD
      APPLY substr(@date, 0, 7) AS yyyy_mm   # YYYY-MM
      ```
      
      **`contains` for TEXT substring filtering** — the query-string form `-@field:value` does *not* negate substrings on TEXT. Use `contains` in a pipeline `FILTER`:
      
      ```
      # "Cities NOT containing 'ile'"
      FT.AGGREGATE idx:cities "*"
          LOAD 1 @city
          FILTER "!contains(@city, 'ile')"
          DIALECT 2
      ```
      
      **Incorrect:** Filtering *after* grouping when you meant to filter the source rows; mismatched `n` count on `GROUPBY`/`SORTBY`; loading every field "just in case."
      
      ```
      # Bad: FILTER after GROUPBY filters group rows, not source rows.
      # Intent was "only new bikes," but here you keep all groups and trim brand rows by mean price.
      FT.AGGREGATE idx:bicycle "*"
          GROUPBY 1 @brand REDUCE AVG 1 @price AS avg_price
          FILTER "@condition == 'new'"     # @condition no longer exists post-group!
          DIALECT 2
      
      # Bad: GROUPBY count mismatched — RESP parse error or surprising grouping
      FT.AGGREGATE idx:bicycle "*"
          GROUPBY 2 @brand               # said 2 fields but only listed 1
              REDUCE COUNT 0 AS n
          DIALECT 2
      
      # Bad: LOAD * inflates the pipeline payload on every doc
      FT.AGGREGATE idx:bicycle "*" LOAD * GROUPBY 1 @brand REDUCE COUNT 0 AS n DIALECT 2
      ```
      
      ## Client mirrors
      
      ```python
      # redis-py — STEP_START aggregate_pipeline
      # Mirrors doctests/query_agg.py
      from redis import Redis
      from redis.commands.search.aggregation import AggregateRequest
      from redis.commands.search.reducers import count, avg, sort_by
      
      r = Redis()
      req = (
          AggregateRequest("@type:{mountain}")
          .load("@brand", "@price", "@condition")
          .apply(sale_price="@price * 0.9")
          .filter("@condition == 'new'")
          .group_by("@brand", count().alias("bike_count"), avg("@price").alias("avg_price"))
          .sort_by(("@avg_price", "DESC"))
          .limit(0, 5)
          .dialect(2)
      )
      results = r.ft("idx:bicycle").aggregate(req)
      # STEP_END
      ```
      
      ```java
      // Jedis — STEP_START aggregate_pipeline
      // Mirrors QueryAggExample.java
      import redis.clients.jedis.UnifiedJedis;
      import redis.clients.jedis.search.aggr.AggregationBuilder;
      import redis.clients.jedis.search.aggr.Reducers;
      import redis.clients.jedis.search.aggr.SortedField;
      
      try (UnifiedJedis jedis = new UnifiedJedis("redis://localhost:6379")) {
          AggregationBuilder agg = new AggregationBuilder("@type:{mountain}")
              .load("@brand", "@price", "@condition")
              .apply("@price * 0.9", "sale_price")
              .filter("@condition == 'new'")
              .groupBy("@brand",
                  Reducers.count().as("bike_count"),
                  Reducers.avg("@price").as("avg_price"))
              .sortBy(SortedField.desc("@avg_price"))
              .limit(0, 5)
              .dialect(2);
          jedis.ftAggregate("idx:bicycle", agg);
      }
      // STEP_END
      ```
      
      ## Upstream sources
      
      - redis-py: [`doctests/query_agg.py`](https://github.com/redis/redis-py/blob/master/doctests/query_agg.py)
      - Jedis: [`QueryAggExample.java`](https://github.com/redis/jedis/blob/master/src/test/java/io/redis/examples/QueryAggExample.java)
      - Reference: [FT.AGGREGATE](https://redis.io/docs/latest/commands/ft.aggregate/), [Aggregations](https://redis.io/docs/latest/develop/interact/search-and-query/advanced-concepts/aggregations/)
      
    • algorithm-choice.md 4 KB
      # Choose HNSW vs FLAT Based on Requirements
      
      `HNSW` (Hierarchical Navigable Small World) is the production default: approximate nearest neighbour with tunable recall, sub-millisecond queries even on millions of vectors. `FLAT` is exact brute-force: 100% recall but linear scan cost — fine for thousands of vectors, not for millions.
      
      | Algorithm | Speed | Accuracy | Memory | Best for |
      |-----------|-------|----------|--------|----------|
      | HNSW | Fast (approximate) | ~95%+ recall, tunable | Higher | Large datasets (> 10k vectors) |
      | FLAT | Slow (exact) | 100% (exact) | Lower | Small datasets, accuracy-critical |
      
      **Correct: HNSW** — use for large-scale production workloads.
      
      ```
      # HNSW with tunable M and EF_CONSTRUCTION
      FT.CREATE idx:bicycle ON HASH PREFIX 1 bicycle:
          SCHEMA
              description_embeddings VECTOR HNSW 10
                  TYPE FLOAT32
                  DIM 1536
                  DISTANCE_METRIC COSINE
                  M 16
                  EF_CONSTRUCTION 200
      ```
      
      **Correct: FLAT** — use when exact results are required and the dataset is small.
      
      ```
      # FLAT — exact brute-force search, guaranteed accuracy
      FT.CREATE idx:bicycle_small ON HASH PREFIX 1 bicycle_small:
          SCHEMA
              description_embeddings VECTOR FLAT 6
                  TYPE FLOAT32
                  DIM 1536
                  DISTANCE_METRIC COSINE
      ```
      
      ## Tuning HNSW recall vs latency
      
      - `M` (default 16) — graph connections per node. Higher = better recall, more memory. Practical range 8–64.
      - `EF_CONSTRUCTION` (default 200) — build-time exploration depth. Higher = better graph quality, slower index build.
      - `EF_RUNTIME` — per-query exploration depth. Set on the query itself (`...=>[KNN 10 @vec $vec EF_RUNTIME 200 AS score]`), not at index time. Higher = better recall, slower query.
      
      ## When to use FLAT
      
      - Dataset under ~10k vectors and won't grow much.
      - Recall must be exactly 100% (e.g., regulatory or evaluation/baseline use cases).
      - You need predictable, deterministic results regardless of insert order.
      
      ## When to use HNSW
      
      - Production semantic search, RAG retrieval, recommendation.
      - Datasets above ~10k vectors where linear scan becomes expensive.
      - Any case where 95%+ recall is acceptable.
      
      **Incorrect:** FLAT on a million-vector index, or under-tuning HNSW and then blaming recall.
      
      ```
      # Bad: FLAT on 1M vectors — every query becomes a 1M-vector linear scan
      FT.CREATE idx:big_vectors ON HASH PREFIX 1 doc:
          SCHEMA embedding VECTOR FLAT 6 TYPE FLOAT32 DIM 1536 DISTANCE_METRIC COSINE
      
      # Bad: HNSW with default M=16 and EF_CONSTRUCTION=200 on a recall-critical workload —
      # then logging poor recall instead of raising EF_RUNTIME at query time.
      ```
      
      ## Client mirrors
      
      ```python
      # redis-py — STEP_START vector_algorithm
      from redis import Redis
      from redis.commands.search.field import VectorField
      r = Redis()
      hnsw = VectorField("description_embeddings", algorithm="HNSW",
          attributes={"TYPE": "FLOAT32", "DIM": 1536, "DISTANCE_METRIC": "COSINE",
                      "M": 16, "EF_CONSTRUCTION": 200})
      flat = VectorField("description_embeddings", algorithm="FLAT",
          attributes={"TYPE": "FLOAT32", "DIM": 1536, "DISTANCE_METRIC": "COSINE"})
      # STEP_END
      ```
      
      ```java
      // Jedis — STEP_START vector_algorithm
      import redis.clients.jedis.search.schemafields.VectorField;
      import java.util.Map;
      VectorField hnsw = VectorField.builder()
          .fieldName("description_embeddings")
          .algorithm(VectorField.VectorAlgorithm.HNSW)
          .attributes(Map.of("TYPE", "FLOAT32", "DIM", 1536,
                             "DISTANCE_METRIC", "COSINE",
                             "M", 16, "EF_CONSTRUCTION", 200))
          .build();
      VectorField flat = VectorField.builder()
          .fieldName("description_embeddings")
          .algorithm(VectorField.VectorAlgorithm.FLAT)
          .attributes(Map.of("TYPE", "FLOAT32", "DIM", 1536, "DISTANCE_METRIC", "COSINE"))
          .build();
      // STEP_END
      ```
      
      RedisVL schema-dict examples for HNSW and FLAT live in [clients/python-redisvl.md](clients/python-redisvl.md).
      
      ## Upstream sources
      
      - Reference: [Vector Reference](https://redis.io/docs/latest/develop/interact/search-and-query/advanced-concepts/vectors/)
      
    • command-selection.md 9.2 KB
      # Choose the Right FT Command for the Job
      
      **Output contract for query-builder calls:** the generated command is the *entire* response. Emit only the JSON array of strings — no prose, no explanation, no `**Explanation:**` section, no code fences, no leading/trailing whitespace. The token *after* the closing `]` must be EOF. Any commentary will be either ignored (best case) or treated as part of the command (worst case).
      
      The first decision before any query syntax is *which command to run*. Redis Search exposes three query commands with different design intents — picking the wrong one means rewriting the query later when you discover the command cannot express what you need.
      
      | Command | Use when... | Mental model | Min. Redis |
      |---------|-------------|--------------|------------|
      | `FT.SEARCH` | Straightforward document retrieval — agent wants matching docs back. | Ready-to-use: returns matching documents directly. | 2.0 module / 8.0 built-in |
      | `FT.AGGREGATE` | Faceting, analytics, computed fields, grouped or reshaped output. | Declarative result shaping: explicit `LOAD`, `APPLY`, `GROUPBY`, `REDUCE`, `SORTBY`. | 2.0 module / 8.0 built-in |
      | `FT.HYBRID` | Relevance must blend lexical (text) and semantic (vector) ranking with explicit fusion. | Declarative hybrid retrieval: `SEARCH` leg + `VSIM` leg + `COMBINE` fusion (RRF or LINEAR). | **8.4.0** (Redis Open Source) |
      
      **Correct:** Pick the command that matches the shape of the answer you need.
      
      ```
      # FT.SEARCH — "give me matching bicycles"
      FT.SEARCH idx:bicycle "@type:{mountain} @price:[100 500]"
          LIMIT 0 10
          RETURN 3 model brand price
          DIALECT 2
      
      # FT.AGGREGATE — "what is the average price per brand?"
      FT.AGGREGATE idx:bicycle "@type:{mountain}"
          GROUPBY 1 @brand
          REDUCE AVG 1 @price AS avg_price
          SORTBY 2 @avg_price DESC
          DIALECT 2
      
      # FT.HYBRID (Redis ≥ 8.4.0) — "blend lexical relevance with vector similarity"
      FT.HYBRID idx:bicycle
          SEARCH "mountain bicycle"
          VSIM @description_embeddings $query_vec
          KNN 2 K 10
          COMBINE RRF 10                          # RRF <count> — number of fused results to keep
          PARAMS 2 query_vec "<vector_blob>"
          DIALECT 2
      ```
      
      **Version gate — FT.HYBRID requires Redis ≥ 8.4.0.** For older Redis, fall back to the pre-filter + KNN pattern via `FT.SEARCH` (see [vector-query.md](vector-query.md)):
      
      ```
      # Fallback for Redis < 8.4.0 — pre-filter + KNN inside FT.SEARCH
      FT.SEARCH idx:bicycle "(@type:{mountain})=>[KNN 10 @description_embeddings $query_vec AS score]"
          SORTBY score
          PARAMS 2 query_vec "<vector_blob>"
          DIALECT 2
      ```
      
      **When to use FT.HYBRID's COMBINE modes:**
      
      - `COMBINE RRF` — Reciprocal Rank Fusion, rank-based fusion. Robust default; no tuning required.
      - `COMBINE LINEAR ALPHA <a> BETA <b>` — weighted score blend. Use when you have calibrated scores and want explicit control over the lexical/vector trade-off.
      
      **Incorrect:** Using `FT.SEARCH` and then post-processing in the client to compute groups, averages, or score fusion. That work belongs inside Redis — pushing it client-side defeats the index.
      
      ```python
      # Bad: pulling raw docs and grouping in Python — defeats the index, blows up over the wire.
      docs = r.ft("idx:bicycle").search("@type:{mountain}").docs
      brands = collections.Counter(d.brand for d in docs)
      ```
      
      ## Decision tree
      
      1. Need computed fields, grouping, or custom output shape? → `FT.AGGREGATE`.
      2. Need blended lexical + vector ranking with explicit fusion? → `FT.HYBRID` (Redis ≥ 8.4.0).
      3. Otherwise (including filter-narrowed vector search) → `FT.SEARCH`.
      
      ## Question-phrase cheatsheet — route by the *shape* of the answer, not the verb
      
      Use `FT.AGGREGATE` when the question contains:
      
      | Phrase | Pipeline shape |
      |--------|----------------|
      | *"how many X"* / *"find the count of X"* / *"count of X"* / *"total count of X"* | `GROUPBY 0 REDUCE COUNT 0 AS n` |
      | *"how many distinct X"* | `GROUPBY 1 @x  GROUPBY 0 REDUCE COUNT 0` *(or `REDUCE COUNT_DISTINCT 1 @x`)* |
      | *"list distinct X"* / *"what are the unique X"* / *"create a list of X"* / *"list of X with Y"* / *"enumerate X"* | `GROUPBY 0 REDUCE TOLIST 1 @x AS list` |
      | *"average per Y"* / *"sum per Y"* / *"min/max per Y"* | `GROUPBY 1 @y REDUCE AVG\|SUM\|MIN\|MAX 1 @field` |
      | *"average / min / max of X across all"* / *"earliest / latest"* | `GROUPBY 0 REDUCE AVG\|MIN\|MAX 1 @field` (no per-bucket grouping) |
      | *"top N by stat"* | `GROUPBY 1 @x REDUCE … AS stat  SORTBY 2 @stat DESC MAX N` |
      | *"breakdown by Y"* / *"per month"* / *"per state"* / *"aggregated by Y"* | `GROUPBY 1 @bucket REDUCE COUNT 0` |
      
      ## Count-question routing
      
      **Phrasing decides the command. Default to FT.AGGREGATE for any "how many" / "count" question.**
      
      | Question phrasing | Command |
      |---|---|
      | *"how many X …"* / *"find the count of X"* / *"count of X"* / *"total count of X"* | **`FT.AGGREGATE` … `GROUPBY 0 REDUCE COUNT 0`** (default) |
      | *"return the number of X …"* (literally that phrasing) | `FT.SEARCH … LIMIT 0 0` — count comes from the response header |
      | *"how many distinct X"* / *"count of distinct X"* | **`FT.AGGREGATE`** — two-step `GROUPBY 1 @x` then `GROUPBY 0 REDUCE COUNT 0` |
      | *"how many X per Y"* / *"count of X by Y"* / *"breakdown by Y"* | **`FT.AGGREGATE`** — `GROUPBY 1 @y REDUCE COUNT 0` |
      
      **Why the default leans `FT.AGGREGATE`:** the gold dataset reserves `FT.SEARCH … LIMIT 0 0` only for the literal phrasing *"return the number of X"*. Every other count phrasing — *"how many"*, *"find the count"*, *"count of"* — uses `FT.AGGREGATE GROUPBY 0 REDUCE COUNT 0`. Both forms return the same total numerically, but the result-row shapes differ, so a coin-flip is wrong half the time.
      
      ```
      # Q: "How many beers in Michigan?"
      # Bad: bare LIMIT 0 0 — returns a count in the header, but the gold uses AGGREGATE
      FT.SEARCH beers "@state:{MI}" LIMIT 0 0
      
      # Good: explicit aggregate, single-row result
      FT.AGGREGATE beers "@state:{MI}" GROUPBY 0 REDUCE COUNT 0
      ```
      
      **"Create a list of X with property Y" → `TOLIST`, not `GROUPBY 1 @x` with COUNT.** Grouping by `@x` with a `REDUCE COUNT` *partitions rows and adds a count column*; `TOLIST` *collects the values into a single list row*. The two return different shapes.
      
      ```
      # Q: "Create a list of subjects with active cases of rhinitis or asthma"
      # Bad: groups by subject and counts — returns N rows
      FT.AGGREGATE conditions "@code:{active} @problem:(rhinitis|asthma)" GROUPBY 1 @subject REDUCE COUNT 0 AS count
      
      # Good: collapses to one row containing the list of subjects
      FT.AGGREGATE conditions "@code:{active} @problem:(rhinitis|asthma)" GROUPBY 0 REDUCE TOLIST 1 @subject AS List
      ```
      
      **`GROUPBY` without a `REDUCE` is a valid distinct-values projection.** For *"list N distinct values of X"* (no count needed), the canonical form is bare `GROUPBY 1 @x LIMIT 0 N` — adding a `REDUCE COUNT` changes the row shape and breaks result-equality.
      
      ```
      # Q: "List eleven countries"
      # Bad: adds an unrequested count column — different output shape
      FT.AGGREGATE cities "*" GROUPBY 1 @country REDUCE COUNT 0 AS count LIMIT 0 11
      
      # Good: pure projection of distinct values
      FT.AGGREGATE cities "*" GROUPBY 1 @country LIMIT 0 11
      ```
      
      Use `FT.SEARCH` for:
      
      - *"give me bicycles where …"* / *"show matching …"* / *"return the X and Y for …"* → raw document retrieval.
      - *Look-up by exact ID* (e.g., *"return the X for benefits ID '...'"*) → `FT.SEARCH idx "@id:{...}" RETURN <n> ...`. **Never** `FT.AGGREGATE` for single-record retrieval.
      
      ## Cross-links
      
      - Syntax of the query expression: [query-syntax.md](query-syntax.md)
      - KNN, range, and pre-filter vector queries: [vector-query.md](vector-query.md)
      - Aggregate pipeline stages: [aggregate-pipeline.md](aggregate-pipeline.md)
      
      ## Client mirrors
      
      ```python
      # redis-py — STEP_START command_selection
      # Mirrors doctests/search_quickstart.py
      from redis import Redis
      
      r = Redis()
      # FT.SEARCH for retrieval
      results = r.ft("idx:bicycle").search("@type:{mountain} @price:[100 500]")
      # FT.AGGREGATE for grouped analytics
      from redis.commands.search.aggregation import AggregateRequest, Reducers
      agg = AggregateRequest("@type:{mountain}").group_by("@brand", Reducers.avg("@price").alias("avg_price"))
      totals = r.ft("idx:bicycle").aggregate(agg)
      # STEP_END
      ```
      
      ```java
      // Jedis — STEP_START command_selection
      // Mirrors SearchQuickstartExample.java
      import redis.clients.jedis.UnifiedJedis;
      import redis.clients.jedis.search.Query;
      import redis.clients.jedis.search.aggr.AggregationBuilder;
      import redis.clients.jedis.search.aggr.Reducers;
      
      try (UnifiedJedis jedis = new UnifiedJedis("redis://localhost:6379")) {
          jedis.ftSearch("idx:bicycle", new Query("@type:{mountain} @price:[100 500]"));
          AggregationBuilder agg = new AggregationBuilder("@type:{mountain}")
              .groupBy("@brand", Reducers.avg("@price").as("avg_price"));
          jedis.ftAggregate("idx:bicycle", agg);
      }
      // STEP_END
      ```
      
      ## Upstream sources
      
      - redis-py: [`doctests/search_quickstart.py`](https://github.com/redis/redis-py/blob/master/doctests/search_quickstart.py)
      - Jedis: [`SearchQuickstartExample.java`](https://github.com/redis/jedis/blob/master/src/test/java/io/redis/examples/SearchQuickstartExample.java)
      - Reference: [FT.HYBRID](https://redis.io/docs/latest/commands/ft.hybrid/), [FT.SEARCH](https://redis.io/docs/latest/commands/ft.search/), [FT.AGGREGATE](https://redis.io/docs/latest/commands/ft.aggregate/)
      
    • debugging.md 4.6 KB
      # Debug Queries with FT.EXPLAIN, FT.PROFILE, FT.INFO
      
      Three commands cover ~95% of search debugging: `FT.EXPLAIN` shows how the parser interpreted the query expression, `FT.PROFILE` measures stage-by-stage execution, and `FT.INFO` reports on the index itself (size, doc count, indexing failures, configuration). Reach for them *before* tweaking schema or rewriting queries.
      
      **Correct:** Run the right diagnostic for the symptom.
      
      ```
      # Symptom: "my query returns nothing" — see how the parser actually read it
      FT.EXPLAIN idx:bicycle "@brand:{Giant-Cycles}"
      #  → INTERSECT { @brand:TAG{Giant} NOT TAG{Cycles} }   ← the hyphen was treated as NOT!
      
      # Stemming surprise — see token expansion
      FT.EXPLAIN idx:bicycle "running shoes"
      #  → INTERSECT { UNION{run, running} UNION{shoe, shoes} }
      
      # Symptom: "slow query" — full stage timing
      FT.PROFILE idx:bicycle SEARCH QUERY "@type:{mountain} @price:[100 500]" LIMIT 0 20
      
      # Same for aggregate
      FT.PROFILE idx:bicycle AGGREGATE QUERY "@type:{mountain}"
          GROUPBY 1 @brand REDUCE COUNT 0 AS n
      
      # Symptom: "I changed the schema and queries look weird" — inspect the index
      FT.INFO idx:bicycle
      ```
      
      ## What to look for in `FT.INFO`
      
      | Field | Means | What to do if it's off |
      |-------|-------|------------------------|
      | `num_docs` | Indexed doc count. | If lower than expected, check `hash_indexing_failures`. |
      | `num_records` | Total indexed terms (across all fields). | High vs `num_docs` may indicate over-indexing TEXT. |
      | `hash_indexing_failures` | Documents that failed indexing. | Inspect a failing doc with `JSON.GET` or `HGETALL`; usually a type mismatch on a NUMERIC field, or non-FLOAT32 vector blob. |
      | `inverted_sz_mb` | Memory used by the inverted index. | If large, consider `NOOFFSETS`, `NOFREQS`, `NOHL` (see [ft-create-options.md](ft-create-options.md)). |
      | `indexing` | `1` if a background indexing job is running. | Wait for `0` before benchmarking. |
      | `percent_indexed` | Progress of initial scan. | `1.0` = fully indexed. |
      | `gc_stats` | Garbage-collector activity. | Frequent runs usually mean lots of deletes/updates. |
      | `attributes` | Per-field schema. | Verify a field is actually present at the alias you're querying. |
      
      ## Reading `FT.PROFILE` output
      
      - Top-level `Total profile time` is the wall-clock cost.
      - The `Iterators profile` tree shows which query clause did how much work; a giant `Counter` on a TEXT term means it matched a huge fraction of docs.
      - `Parsing time` + `Pipeline creation time` + `Iterators profile` should account for ~all the time. If `Iterators profile` is small but `Total` is large, the bottleneck is post-processing (SORT, RETURN, LIMIT).
      
      **Incorrect:** Editing schema or guessing at perf fixes before running diagnostics.
      
      ```
      # Bad: "let me just add SORTABLE to every field and see what happens"
      # Worse: "let me re-create the index" before checking hash_indexing_failures
      ```
      
      ## Common errors and what they mean
      
      | Error | Likely cause |
      |-------|--------------|
      | `Unknown index name` | Typo, or index dropped. List with `FT._LIST`. |
      | `Syntax error at offset N` | Unbalanced `()` / `{}` / `[]`, or unescaped `-`/`.` inside a TAG. |
      | `Vector index initialization failed` | DIM mismatch, wrong TYPE, or non-array path. |
      | `Document already in index` | Duplicate key on `FT.ADD` (legacy); not produced by modern HSET/JSON.SET flow. |
      | `Document is already in index` after `JSON.SET` | Same key indexed by two indexes with overlapping prefixes — narrow the prefixes. |
      
      ## Client mirrors
      
      ```python
      # redis-py — STEP_START debugging
      from redis import Redis
      r = Redis()
      # Parser trace
      print(r.ft("idx:bicycle").explain("@brand:{Giant-Cycles}"))
      # Timing
      print(r.ft("idx:bicycle").profile_search("@type:{mountain}", limit=(0, 20)))
      # Index health
      info = r.ft("idx:bicycle").info()
      print(info["num_docs"], info["hash_indexing_failures"], info["percent_indexed"])
      # STEP_END
      ```
      
      ```java
      // Jedis — STEP_START debugging
      import redis.clients.jedis.UnifiedJedis;
      import redis.clients.jedis.search.Query;
      
      try (UnifiedJedis jedis = new UnifiedJedis("redis://localhost:6379")) {
          String explain = jedis.ftExplain("idx:bicycle", new Query("@brand:{Giant-Cycles}"));
          System.out.println(explain);
          System.out.println(jedis.ftProfileSearch("idx:bicycle", null,
              new Query("@type:{mountain}").limit(0, 20)));
          System.out.println(jedis.ftInfo("idx:bicycle"));
      }
      // STEP_END
      ```
      
      ## Upstream sources
      
      - No direct upstream example — authored from official Redis Search command documentation.
      - Reference: [FT.EXPLAIN](https://redis.io/docs/latest/commands/ft.explain/), [FT.PROFILE](https://redis.io/docs/latest/commands/ft.profile/), [FT.INFO](https://redis.io/docs/latest/commands/ft.info/)
      
    • dialect.md 2.6 KB
      # Use DIALECT 2 for Query Syntax
      
      Pass `DIALECT 2` on every `FT.SEARCH` / `FT.AGGREGATE` / `FT.HYBRID` call. From Redis 8 onward, **DIALECT 2 is the only supported value** — dialects 1, 3, and 4 are deprecated and removed in current Redis Open Source. Vector query attributes (the `=>[KNN ...]` form) require DIALECT 2 to parse.
      
      **Correct:** Specify DIALECT 2 explicitly, or rely on modern client defaults.
      
      ```
      # In raw commands, specify DIALECT 2 at the end
      FT.SEARCH idx:bicycle "@model:hyperion" DIALECT 2
      
      FT.AGGREGATE idx:bicycle "@type:{mountain}"
          GROUPBY 1 @brand
          REDUCE COUNT 0 AS bike_count
          DIALECT 2
      ```
      
      **Note on Redis 8 and DIALECT:** Redis 8 (built-in Redis Search) accepts only DIALECT 2. The `DEFAULT_DIALECT` `FT.CONFIG` knob no longer accepts other values. Older Redis 7.x / RediSearch-module deployments still respect dialect 1; if you target both, set `DIALECT 2` explicitly so behavior is identical across versions.
      
      **Why DIALECT 2:**
      
      - Required for vector search (`=>[KNN ...]` attribute syntax).
      - Required for `PARAMS` placeholder binding.
      - Predictable handling of special characters and NULL-like missing fields.
      - The only dialect that will be supported going forward.
      
      **Incorrect:** Relying on the server-side default with a client library that pins an older dialect.
      
      ```
      # Bad: omitting DIALECT in a vector query with a legacy redis-py — falls back to DIALECT 1 and rejects =>[KNN ...]
      FT.SEARCH idx:bicycle "*=>[KNN 10 @embedding $vec AS score]" PARAMS 2 vec "..."
      ```
      
      ## Client mirrors
      
      ```python
      # redis-py — STEP_START dialect
      # Mirrors doctests/search_quickstart.py
      from redis import Redis
      r = Redis()
      # Modern redis-py defaults to DIALECT 2; set explicitly when in doubt
      r.ft("idx:bicycle").search("@model:hyperion", dialect=2)
      # STEP_END
      ```
      
      ```java
      // Jedis — STEP_START dialect
      // Mirrors SearchQuickstartExample.java
      import redis.clients.jedis.UnifiedJedis;
      import redis.clients.jedis.search.FTSearchParams;
      import redis.clients.jedis.search.SearchResult;
      
      try (UnifiedJedis jedis = new UnifiedJedis("redis://localhost:6379")) {
          SearchResult res = jedis.ftSearch("idx:bicycle",
              "@model:hyperion",
              FTSearchParams.searchParams().dialect(2));
      }
      // STEP_END
      ```
      
      ## Upstream sources
      
      - redis-py: [`doctests/search_quickstart.py`](https://github.com/redis/redis-py/blob/master/doctests/search_quickstart.py)
      - Jedis: [`SearchQuickstartExample.java`](https://github.com/redis/jedis/blob/master/src/test/java/io/redis/examples/SearchQuickstartExample.java)
      - Reference: [Query Dialects](https://redis.io/docs/latest/develop/interact/search-and-query/advanced-concepts/dialects/)
      
    • field-types.md 3.7 KB
      # Choose the Correct Field Type
      
      Each field type has different capabilities and performance characteristics. Use the narrowest type that supports your access pattern — `TAG` is roughly 10× faster than `TEXT` for exact-match filtering, and `NUMERIC SORTABLE` is the only fast path for range sorts.
      
      | Field Type | Use When | Notes |
      |------------|----------|-------|
      | `TEXT` | Full-text search needed | Tokenized, stemmed; **not** for exact match |
      | `TAG` | Exact match, filtering | Faster than TEXT; add `SORTABLE UNF` for fastest tag queries |
      | `NUMERIC` | Range queries, sorting | Prices, counts, timestamps |
      | `GEO` | Lat/long point queries | Single points (stores, users) |
      | `GEOSHAPE` | Polygon / area queries | Delivery zones, regions |
      | `VECTOR` | Similarity search | HNSW or FLAT; see [algorithm-choice.md](algorithm-choice.md) |
      
      **Correct:** Use TAG for exact matching (Bicycle dataset).
      
      ```
      FT.CREATE idx:bicycle ON HASH PREFIX 1 bicycle:
          SCHEMA
              model        TEXT WEIGHT 2.0
              description  TEXT
              brand        TAG
              condition    TAG
              price        NUMERIC SORTABLE
      
      # Query: exact-match TAG filter on brand
      FT.SEARCH idx:bicycle "@brand:{Velorim} @condition:{new}" DIALECT 2
      ```
      
      **Incorrect:** Using TEXT when you don't need full-text features.
      
      ```
      # Overkill: TEXT for brand/condition adds unnecessary tokenization
      FT.CREATE idx:bicycle ON HASH PREFIX 1 bicycle:
          SCHEMA
              model       TEXT
              brand       TEXT
              condition   TEXT
      ```
      
      **Correct:** Use GEO for points, GEOSHAPE for areas.
      
      ```
      # GEO for point locations (stores, users)
      FT.CREATE idx:bicycle ON HASH PREFIX 1 bicycle:
          SCHEMA
              store_location GEO
      
      # GEOSHAPE for areas (delivery zones, boundaries)
      FT.CREATE idx:zones ON JSON PREFIX 1 zone:
          SCHEMA
              $.boundary AS boundary GEOSHAPE
      ```
      
      For JSON-path fields (`$.path AS alias`), see [json-indexing.md](json-indexing.md). For vector fields, see [algorithm-choice.md](algorithm-choice.md).
      
      ## Client mirrors
      
      ```python
      # redis-py — STEP_START field_types
      # Mirrors doctests/search_quickstart.py
      from redis import Redis
      from redis.commands.search.field import TextField, TagField, NumericField, GeoField
      from redis.commands.search.indexDefinition import IndexDefinition, IndexType
      
      r = Redis()
      schema = (
          TextField("model", weight=2.0),
          TextField("description"),
          TagField("brand"),
          TagField("condition"),
          NumericField("price", sortable=True),
          GeoField("store_location"),
      )
      r.ft("idx:bicycle").create_index(
          schema,
          definition=IndexDefinition(prefix=["bicycle:"], index_type=IndexType.HASH))
      # STEP_END
      ```
      
      ```java
      // Jedis — STEP_START field_types
      // Mirrors SearchQuickstartExample.java
      import redis.clients.jedis.UnifiedJedis;
      import redis.clients.jedis.search.FTCreateParams;
      import redis.clients.jedis.search.IndexDataType;
      import redis.clients.jedis.search.schemafields.*;
      
      try (UnifiedJedis jedis = new UnifiedJedis("redis://localhost:6379")) {
          jedis.ftCreate("idx:bicycle",
              FTCreateParams.createParams().on(IndexDataType.HASH).prefix("bicycle:"),
              TextField.of("model").weight(2.0),
              TextField.of("description"),
              TagField.of("brand"),
              TagField.of("condition"),
              NumericField.of("price").sortable(),
              GeoField.of("store_location"));
      }
      // STEP_END
      ```
      
      ## Upstream sources
      
      - redis-py: [`doctests/search_quickstart.py`](https://github.com/redis/redis-py/blob/master/doctests/search_quickstart.py)
      - Jedis: [`SearchQuickstartExample.java`](https://github.com/redis/jedis/blob/master/src/test/java/io/redis/examples/SearchQuickstartExample.java)
      - Reference: [Redis Search Field Types](https://redis.io/docs/latest/develop/interact/search-and-query/indexing/geoindex/)
      
    • ft-create-options.md 6.2 KB
      # Tune FT.CREATE Options for Memory and Indexing Cost
      
      `FT.CREATE` ships sensible defaults that pay for features most apps want — offsets for highlighting, frequencies for scoring, per-document field map for `FT.AGGREGATE LOAD`. On a very large index, those costs add up. Several flags let you opt out where you don't need them, and a few flags change the *behavior* of index creation itself (`SKIPINITIALSCAN`, `TEMPORARY`).
      
      **Correct:** Pick the flags whose trade-offs match your workload.
      
      ```
      # A lean index — no highlight, no field-frequency scoring, no field map
      FT.CREATE idx:logs ON HASH PREFIX 1 log:
          NOOFFSETS                       # don't store term offsets → no HIGHLIGHT/SUMMARIZE/phrase queries
          NOHL                            # disable highlight payload (subset of NOOFFSETS savings)
          NOFREQS                         # don't store term frequencies → lighter scoring
          NOFIELDS                        # don't store per-doc field bitmap → no @field-scoped queries
          SCHEMA
              message TEXT
      
      # Only index new documents (skip the initial scan over existing keys)
      FT.CREATE idx:events ON HASH PREFIX 1 event:
          SKIPINITIALSCAN
          SCHEMA
              topic TAG
              ts NUMERIC SORTABLE
      
      # Pre-allocate room for FT.ALTER (cannot grow beyond MAXTEXTFIELDS slots later)
      FT.CREATE idx:bicycle ON HASH PREFIX 1 bicycle:
          MAXTEXTFIELDS                   # reserves capacity for adding TEXT fields later
          SCHEMA
              model TEXT
      
      # Custom stopword list (or disable entirely with STOPWORDS 0)
      FT.CREATE idx:books ON HASH PREFIX 1 book:
          STOPWORDS 0                     # disable stopword filtering altogether
          SCHEMA
              title TEXT
              description TEXT
      
      # Auto-expire the index if idle (in seconds) — useful for transient indexes
      FT.CREATE idx:session_search ON HASH PREFIX 1 sess:
          TEMPORARY 3600
          SCHEMA
              user_id TAG
              last_query TEXT
      ```
      
      ## Trade-off table
      
      | Flag | Saves | Costs |
      |------|-------|-------|
      | `NOOFFSETS` | Term offsets — can be 30–50% of TEXT-heavy index size. | Disables `HIGHLIGHT`, `SUMMARIZE`, and phrase queries with `$slop`/`$inorder`. |
      | `NOHL` | Highlight payload only. | Disables `HIGHLIGHT` (offsets still kept for phrase queries). |
      | `NOFREQS` | Per-term frequency counters. | Scoring quality degrades; BM25 / TFIDF can't differentiate doc relevance well. |
      | `NOFIELDS` | Per-document field bitmap. | Disables `@field:` scoping on queries — every term searches all TEXT fields. |
      | `SKIPINITIALSCAN` | Time + IO of scanning existing keys. | Existing matching documents are not in the index — only new HSET/JSON.SET. |
      | `MAXTEXTFIELDS` | n/a (reserves capacity). | Slightly larger empty-index footprint. Use only if you'll add fields via `FT.ALTER`. |
      | `STOPWORDS 0` | Stopword filtering. | Common words (the, and, of) are now searchable and inflate the inverted index. |
      | `TEMPORARY <sec>` | n/a (sets a TTL on the index). | Index is reaped after `<sec>` of idleness — must be re-created. |
      
      ## `SKIPINITIALSCAN` — when to use it
      
      **Use when:**
      
      - Creating an index for a new feature where existing documents are irrelevant.
      - Setting up an index ahead of a data load that will fully populate it.
      - The dataset is too large for initial scan latency to be acceptable.
      - Event-driven architectures that only care about new events going forward.
      
      **Don't use when:**
      
      - You need historical documents to appear in search immediately.
      - Migrating an existing dataset to a new schema (the new index must include all existing docs).
      - Most general-purpose search use cases.
      
      The default behavior (without `SKIPINITIALSCAN`) indexes all existing matching keys, which is usually what you want.
      
      **Incorrect:** Disabling features you actually use, or combining mutually destructive flags.
      
      ```
      # Bad: NOOFFSETS on an index that highlights snippets in the UI.
      FT.CREATE idx:blog ON HASH PREFIX 1 post:
          NOOFFSETS
          SCHEMA title TEXT body TEXT
      # Later — fails or returns no highlights:
      FT.SEARCH idx:blog "redis" HIGHLIGHT FIELDS 1 body
      
      # Bad: NOFIELDS with field-scoped queries — every @-prefixed term becomes a global term
      FT.CREATE idx:logs ON HASH PREFIX 1 log: NOFIELDS SCHEMA service TAG message TEXT
      FT.SEARCH idx:logs "@service:{api}"     # no longer effective
      
      # Bad: SKIPINITIALSCAN when migrating data into a new index
      FT.CREATE idx:v2 ON HASH PREFIX 1 product: SKIPINITIALSCAN SCHEMA name TEXT
      # Existing product:* keys are never indexed; queries return only new docs.
      ```
      
      ## Client mirrors
      
      ```python
      # redis-py — STEP_START ft_create_options
      from redis import Redis
      from redis.commands.search.field import TextField, TagField, NumericField
      from redis.commands.search.indexDefinition import IndexDefinition, IndexType
      
      r = Redis()
      # SKIPINITIALSCAN — only new events get indexed
      r.ft("idx:events").create_index(
          (TagField("topic"), NumericField("ts", sortable=True)),
          definition=IndexDefinition(prefix=["event:"], index_type=IndexType.HASH),
          skip_initial_scan=True,
      )
      # Lean log index
      r.ft("idx:logs").create_index(
          (TextField("message"),),
          definition=IndexDefinition(prefix=["log:"], index_type=IndexType.HASH),
          no_term_offsets=True, no_field_flags=True, no_term_frequencies=True,
      )
      # STEP_END
      ```
      
      ```java
      // Jedis — STEP_START ft_create_options
      import redis.clients.jedis.UnifiedJedis;
      import redis.clients.jedis.search.FTCreateParams;
      import redis.clients.jedis.search.IndexDataType;
      import redis.clients.jedis.search.schemafields.*;
      
      try (UnifiedJedis jedis = new UnifiedJedis("redis://localhost:6379")) {
          jedis.ftCreate("idx:events",
              FTCreateParams.createParams().on(IndexDataType.HASH).prefix("event:").skipInitialScan(),
              TagField.of("topic"), NumericField.of("ts").sortable());
      
          jedis.ftCreate("idx:logs",
              FTCreateParams.createParams().on(IndexDataType.HASH).prefix("log:")
                  .noOffsets().noFields().noFrequencies(),
              TextField.of("message"));
      }
      // STEP_END
      ```
      
      ## Upstream sources
      
      - redis-py: [`doctests/search_quickstart.py`](https://github.com/redis/redis-py/blob/master/doctests/search_quickstart.py)
      - Jedis: [`SearchQuickstartExample.java`](https://github.com/redis/jedis/blob/master/src/test/java/io/redis/examples/SearchQuickstartExample.java)
      - Reference: [FT.CREATE](https://redis.io/docs/latest/commands/ft.create/)
      
    • hybrid-search.md 5 KB
      # Combine Lexical and Vector Search Correctly
      
      Two patterns address two different needs:
      
      - **Filter-narrowed vector search** (works on every Redis with vector support): write a normal `FT.SEARCH` with a TAG/NUMERIC pre-filter on the left side of the `=>[KNN ...]` clause. The pre-filter shrinks the candidate set; KNN then runs only over survivors.
      - **Blended lexical + vector ranking with explicit fusion** (Redis ≥ 8.4.0): use `FT.HYBRID`, which runs a `SEARCH` leg and a `VSIM` leg in parallel and fuses their rankings via Reciprocal Rank Fusion (`COMBINE RRF`) or a weighted score blend (`COMBINE LINEAR`).
      
      **Correct: pre-filtered KNN** (works on all Redis 8.x and the RediSearch module).
      
      ```
      # Filter to mountain bikes under $500, then KNN over the survivors
      FT.SEARCH idx:bicycle "(@type:{mountain} @price:[100 500])=>[KNN 10 @description_embeddings $vec AS score]"
          SORTBY score
          PARAMS 2 vec "<vector_blob>"
          RETURN 4 model brand price score
          DIALECT 2
      ```
      
      **Correct: FT.HYBRID** — requires Redis ≥ 8.4.0.
      
      ```
      # Blend lexical ("mountain bicycle") + vector similarity with RRF fusion
      FT.HYBRID idx:bicycle
          SEARCH "mountain bicycle"
          VSIM @description_embeddings $vec
          KNN 2 K 10
          COMBINE RRF 10                         # RRF <count> — number of fused results to keep
          PARAMS 2 vec "<vector_blob>"
          LIMIT 0 10
          DIALECT 2
      
      # Weighted (LINEAR) — α weights the SEARCH score, β the VSIM score
      FT.HYBRID idx:bicycle
          SEARCH "mountain bicycle" YIELD_SCORE_AS lex_score
          VSIM @description_embeddings $vec YIELD_SCORE_AS vec_score
          KNN 2 K 20
          COMBINE LINEAR 4 ALPHA 0.4 BETA 0.6
          PARAMS 2 vec "<vector_blob>"
          DIALECT 2
      ```
      
      ## When to use which
      
      | Goal | Use |
      |------|-----|
      | "Find vectors near $vec, but only within category X and price < $500." | Pre-filtered KNN inside `FT.SEARCH` (works everywhere). |
      | "Rank documents by a blend of lexical relevance and semantic similarity." | `FT.HYBRID` (Redis ≥ 8.4.0). |
      | "Same goal but on Redis < 8.4.0." | Run two separate queries client-side and fuse the rankings yourself (rough fallback; loses cross-leg score calibration). |
      
      **Incorrect:** Running an unfiltered KNN and then filtering client-side, or assuming `FT.HYBRID` exists on older Redis.
      
      ```
      # Bad: KNN across the whole index, then filter client-side — burns vector work on rows you'd drop.
      FT.SEARCH idx:bicycle "*=>[KNN 1000 @description_embeddings $vec AS score]"
          SORTBY score
          PARAMS 2 vec "<vector_blob>"
          DIALECT 2
      # Then in the application: drop any row where type != "mountain" or price not in [100, 500].
      ```
      
      ```python
      # Bad (client mirror): same anti-pattern in Python — fetch 1000, filter in memory.
      results = r.ft("idx:bicycle").search(
          Query("*=>[KNN 1000 @description_embeddings $vec AS score]")
          .sort_by("score").dialect(2),
          query_params={"vec": vec_blob})
      mountain = [r for r in results.docs if r.type == "mountain" and 100 <= int(r.price) <= 500]
      ```
      
      ## Performance notes
      
      - Pre-filter with `TAG` and `NUMERIC` fields — these are cheap and dramatically cut the KNN candidate set.
      - For `FT.HYBRID`, the `KNN <count> K <k>` clause inside `VSIM` controls how many vector neighbours feed the fusion stage; the outer `LIMIT` controls how many results you return.
      - `COMBINE RRF` needs no tuning; `COMBINE LINEAR` needs calibrated α/β — start at 0.5/0.5 and adjust based on relevance evals.
      
      ## Client mirrors
      
      ```python
      # redis-py — STEP_START hybrid_search
      # Mirrors doctests/query_combined.py
      import numpy as np
      from redis import Redis
      from redis.commands.search.query import Query
      
      r = Redis()
      vec_blob = np.array(query_embedding, dtype=np.float32).tobytes()
      q = (Query("(@type:{mountain} @price:[100 500])=>[KNN 10 @description_embeddings $vec AS score]")
           .sort_by("score").return_fields("model", "brand", "price", "score")
           .dialect(2).paging(0, 10))
      r.ft("idx:bicycle").search(q, query_params={"vec": vec_blob})
      # STEP_END
      ```
      
      ```java
      // Jedis — STEP_START hybrid_search
      import redis.clients.jedis.UnifiedJedis;
      import redis.clients.jedis.search.Query;
      
      try (UnifiedJedis jedis = new UnifiedJedis("redis://localhost:6379")) {
          Query q = new Query(
              "(@type:{mountain} @price:[100 500])=>[KNN 10 @description_embeddings $vec AS score]")
              .setSortBy("score", true)
              .returnFields("model", "brand", "price", "score")
              .addParam("vec", vecBlob)
              .dialect(2)
              .limit(0, 10);
          jedis.ftSearch("idx:bicycle", q);
      }
      // STEP_END
      ```
      
      RedisVL `VectorQuery` with filter expressions and the `HybridQuery` wrapper for `FT.HYBRID` live in [clients/python-redisvl.md](clients/python-redisvl.md).
      
      ## Upstream sources
      
      - redis-py: [`doctests/query_combined.py`](https://github.com/redis/redis-py/blob/master/doctests/query_combined.py)
      - Jedis: [`VectorSearchExample.java`](https://github.com/redis/jedis/blob/master/src/test/java/io/redis/examples/VectorSearchExample.java)
      - Reference: [Hybrid Queries](https://redis.io/docs/latest/develop/interact/search-and-query/query/combined/), [FT.HYBRID](https://redis.io/docs/latest/commands/ft.hybrid/)
      
    • index-creation.md 6.9 KB
      # Index Only Fields You Query
      
      Create indexes with only the fields you need to search, filter, or sort on. Every indexed field costs memory on every write, even if no query ever touches it. Always set a `PREFIX` so `FT.CREATE` doesn't try to index every key in the database.
      
      **Correct:** Index specific fields and constrain by prefix (Bicycle dataset, HASH).
      
      ```
      FT.CREATE idx:bicycle ON HASH PREFIX 1 bicycle:
          SCHEMA
              model        TEXT WEIGHT 2.0
              description  TEXT
              brand        TAG
              condition    TAG
              price        NUMERIC SORTABLE
              store_location GEO
      ```
      
      For JSON documents, see [json-indexing.md](json-indexing.md) — the same principles apply, but paths use the `$.path AS alias` form. For FT.CREATE flag options (`SKIPINITIALSCAN`, `NOOFFSETS`, `NOFIELDS`, etc.) and their memory trade-offs, see [ft-create-options.md](ft-create-options.md).
      
      ## Vector fields
      
      A vector field needs three things stated correctly at index time: `TYPE` (almost always `FLOAT32`), `DIM` (must equal your embedding model's output size), and `DISTANCE_METRIC` (`COSINE`, `L2`, or `IP`). Mismatching any of these silently produces wrong results or refuses inserts — there is no runtime warning.
      
      For the algorithm choice (HNSW vs FLAT) and tuning, see [algorithm-choice.md](algorithm-choice.md).
      
      ```
      # Canonical HASH index with text fields + a vector field (1536-dim OpenAI-style embeddings)
      FT.CREATE idx:bicycle ON HASH PREFIX 1 bicycle:
          SCHEMA
              model         TEXT WEIGHT 2.0
              brand         TAG
              description   TEXT
              condition     TAG
              price         NUMERIC SORTABLE
              description_embeddings VECTOR HNSW 6
                  TYPE FLOAT32
                  DIM 1536
                  DISTANCE_METRIC COSINE
      ```
      
      **JSON variant** — JSONPath plus `AS alias` (see [json-indexing.md](json-indexing.md)):
      
      ```
      FT.CREATE idx:bicycle ON JSON PREFIX 1 bicycle:
          SCHEMA
              $.description_embeddings AS description_embeddings VECTOR HNSW 6
                  TYPE FLOAT32
                  DIM 1536
                  DISTANCE_METRIC COSINE
      ```
      
      ### Required vector attributes
      
      | Attribute | Values | Notes |
      |-----------|--------|-------|
      | `TYPE` | `FLOAT32`, `FLOAT64`, `BFLOAT16`, `FLOAT16` | `FLOAT32` is the standard. Lower-precision types save memory on very large indexes. |
      | `DIM` | integer | Must match the embedding model exactly — 1536 for OpenAI `text-embedding-3-small` / `ada-002`, 3072 for `text-embedding-3-large`, 768 for many open-source models. |
      | `DISTANCE_METRIC` | `COSINE`, `L2`, `IP` | Match the metric your embedding model was trained for. Normalized embeddings work with all three but `COSINE` is the typical choice. |
      
      **Verifying the index after creation:**
      
      ```
      FT.INFO idx:bicycle
      # Look for "attributes" — confirm vector field shows correct DIM/TYPE/DISTANCE_METRIC.
      # Check num_docs vs source key count, and hash_indexing_failures.
      ```
      
      **Incorrect:** Over-indexing every field "just in case," creating an index without a prefix, DIM mismatch on vectors, or inlining the vector blob in the query (use `PARAMS` — see [vector-query.md](vector-query.md)).
      
      ```
      # Bad: every field indexed, regardless of whether queries use it
      FT.CREATE idx:bicycle ON HASH PREFIX 1 bicycle:
          SCHEMA
              model TEXT description TEXT brand TEXT subcategory TEXT
              sku TEXT cost NUMERIC margin NUMERIC supplier_id TAG ...
      
      # Bad: no prefix — every hash in the database gets indexed
      FT.CREATE idx:everything ON HASH SCHEMA model TEXT
      
      # Bad: DIM mismatch — inserts silently truncated/padded, queries return junk
      FT.CREATE idx:bicycle ON HASH PREFIX 1 bicycle:
          SCHEMA description_embeddings VECTOR HNSW 6 TYPE FLOAT32 DIM 768 DISTANCE_METRIC COSINE
      # ... but the embeddings inserted are 1536 floats
      
      # Bad: L2 on normalized embeddings — works but obscures interpretability (use COSINE)
      ```
      
      ## Tips
      
      - Start with the minimum required fields; add via `FT.ALTER` (subject to `MAXTEXTFIELDS` capacity) as new query patterns emerge.
      - Use `FT.INFO` to monitor `inverted_sz_mb` and `num_records`.
      - Always specify a prefix to avoid indexing unrelated keys.
      - Consider field-type alternatives: TAG beats TEXT for exact-match filters; SORTABLE on NUMERIC fields you'll use in `SORTBY`.
      
      ## Client mirrors
      
      ```python
      # redis-py — STEP_START create_index
      # Mirrors doctests/search_quickstart.py + search_vss.py
      from redis import Redis
      from redis.commands.search.field import TextField, TagField, NumericField, GeoField, VectorField
      from redis.commands.search.indexDefinition import IndexDefinition, IndexType
      
      r = Redis()
      schema = (
          TextField("model", weight=2.0),
          TextField("description"),
          TagField("brand"),
          TagField("condition"),
          NumericField("price", sortable=True),
          GeoField("store_location"),
          VectorField("description_embeddings",
                      algorithm="HNSW",
                      attributes={"TYPE": "FLOAT32", "DIM": 1536, "DISTANCE_METRIC": "COSINE"}),
      )
      r.ft("idx:bicycle").create_index(
          schema,
          definition=IndexDefinition(prefix=["bicycle:"], index_type=IndexType.HASH))
      # STEP_END
      ```
      
      ```java
      // Jedis — STEP_START create_index
      // Mirrors SearchQuickstartExample.java + VectorSearchExample.java
      import redis.clients.jedis.UnifiedJedis;
      import redis.clients.jedis.search.FTCreateParams;
      import redis.clients.jedis.search.IndexDataType;
      import redis.clients.jedis.search.schemafields.*;
      import java.util.Map;
      
      try (UnifiedJedis jedis = new UnifiedJedis("redis://localhost:6379")) {
          jedis.ftCreate("idx:bicycle",
              FTCreateParams.createParams().on(IndexDataType.HASH).prefix("bicycle:"),
              TextField.of("model").weight(2.0),
              TextField.of("description"),
              TagField.of("brand"),
              TagField.of("condition"),
              NumericField.of("price").sortable(),
              GeoField.of("store_location"),
              VectorField.builder()
                  .fieldName("description_embeddings")
                  .algorithm(VectorField.VectorAlgorithm.HNSW)
                  .attributes(Map.of("TYPE", "FLOAT32", "DIM", 1536, "DISTANCE_METRIC", "COSINE"))
                  .build());
      }
      // STEP_END
      ```
      
      RedisVL higher-level schema-from-dict and `SearchIndex` usage are covered in [clients/python-redisvl.md](clients/python-redisvl.md).
      
      ## Upstream sources
      
      - redis-py: [`doctests/search_quickstart.py`](https://github.com/redis/redis-py/blob/master/doctests/search_quickstart.py), [`doctests/search_vss.py`](https://github.com/redis/redis-py/blob/master/doctests/search_vss.py)
      - Jedis: [`SearchQuickstartExample.java`](https://github.com/redis/jedis/blob/master/src/test/java/io/redis/examples/SearchQuickstartExample.java), [`VectorSearchExample.java`](https://github.com/redis/jedis/blob/master/src/test/java/io/redis/examples/VectorSearchExample.java)
      - Reference: [FT.CREATE](https://redis.io/docs/latest/commands/ft.create/), [Indexing](https://redis.io/docs/latest/develop/interact/search-and-query/indexing/), [Vector Reference](https://redis.io/docs/latest/develop/interact/search-and-query/advanced-concepts/vectors/)
      
    • index-management.md 3.9 KB
      # Manage Indexes for Zero-Downtime Updates
      
      Use index *aliases* so applications query a stable name while you swap the underlying index on schema changes. `FT.ALTER` can append fields to an existing index but cannot change a field's type, options, or remove it — anything beyond *adding* a field requires building a new index and swapping the alias.
      
      **Correct:** Build the new index in parallel, then atomically swap the alias.
      
      ```
      # 1. Build the new version of the index from scratch
      FT.CREATE idx:bicycle_v2 ON HASH PREFIX 1 bicycle:
          SCHEMA
              model TEXT WEIGHT 2.0
              brand TAG
              price NUMERIC SORTABLE
      
      # Wait until percent_indexed = 1.0
      FT.INFO idx:bicycle_v2
      
      # 2. Point the application alias at the new index in one atomic step
      FT.ALIASUPDATE bicycle idx:bicycle_v2
      
      # 3. Drop the old version
      FT.DROPINDEX idx:bicycle_v1
      ```
      
      **Adding a field is in-place — use `FT.ALTER`:**
      
      ```
      # Add a TEXT field with WEIGHT to an existing index — no rebuild needed
      FT.ALTER idx:bicycle SCHEMA ADD subtitle TEXT WEIGHT 1.5
      ```
      
      ## `FT.ALTER` limitations — when you must rebuild
      
      | Change | Can FT.ALTER do it? |
      |--------|---------------------|
      | Add a new field | Yes — `FT.ALTER ... SCHEMA ADD ...` |
      | Remove a field | **No** — must rebuild. |
      | Change a field's type (TEXT → TAG, etc.) | **No** — must rebuild. |
      | Change `SORTABLE`, `NOSTEM`, `WEIGHT`, `PHONETIC` | **No** — must rebuild. |
      | Change the index `PREFIX` | **No** — must rebuild. |
      | Change `LANGUAGE`, `STOPWORDS`, `NOFIELDS`, `NOOFFSETS` | **No** — must rebuild. |
      | Grow beyond `MAXTEXTFIELDS` capacity | **No** — must rebuild (set `MAXTEXTFIELDS` upfront on indexes you expect to grow). |
      
      ## Useful management commands
      
      ```
      # List every search index
      FT._LIST
      
      # Inspect schema, doc count, indexing progress, memory
      FT.INFO idx:bicycle
      
      # Create an alias up front (so application code always uses the alias)
      FT.ALIASADD bicycle idx:bicycle_v1
      
      # Atomic swap when a v2 is ready
      FT.ALIASUPDATE bicycle idx:bicycle_v2
      
      # Drop an index (non-blocking)
      FT.DROPINDEX idx:bicycle_v1
      
      # Drop the index AND delete every indexed document
      FT.DROPINDEX idx:bicycle_v1 DD
      ```
      
      **Incorrect:** Dropping the live index before the new one is ready, or relying on a hard-coded index name in application code.
      
      ```
      # Bad: drop-and-recreate while traffic is hitting the index
      FT.DROPINDEX idx:bicycle
      FT.CREATE idx:bicycle ...            # queries during the rebuild return errors
      
      # Bad: application queries idx:bicycle_v1 directly — no painless way to roll forward
      ```
      
      ## Client mirrors
      
      ```python
      # redis-py — STEP_START index_management
      from redis import Redis
      r = Redis()
      # Build new version, then swap the alias atomically
      r.ft("idx:bicycle_v2").create_index(...)
      r.ft().aliasupdate("bicycle", "idx:bicycle_v2")
      r.ft("idx:bicycle_v1").dropindex(delete_documents=False)
      # Add a single field in-place
      r.ft("idx:bicycle").alter_schema_add(["subtitle", "TEXT", "WEIGHT", "1.5"])
      # STEP_END
      ```
      
      ```java
      // Jedis — STEP_START index_management
      import redis.clients.jedis.UnifiedJedis;
      import redis.clients.jedis.search.schemafields.TextField;
      
      try (UnifiedJedis jedis = new UnifiedJedis("redis://localhost:6379")) {
          // Atomic alias swap
          jedis.ftAliasUpdate("bicycle", "idx:bicycle_v2");
          jedis.ftDropIndex("idx:bicycle_v1");
          // Add a field in place
          jedis.ftAlter("idx:bicycle", TextField.of("subtitle").weight(1.5));
      }
      // STEP_END
      ```
      
      ## Upstream sources
      
      - redis-py: [`doctests/search_quickstart.py`](https://github.com/redis/redis-py/blob/master/doctests/search_quickstart.py)
      - Jedis: [`SearchQuickstartExample.java`](https://github.com/redis/jedis/blob/master/src/test/java/io/redis/examples/SearchQuickstartExample.java)
      - Reference: [FT.ALIASADD / FT.ALIASUPDATE / FT.ALIASDEL](https://redis.io/docs/latest/commands/ft.aliasadd/), [FT.ALTER](https://redis.io/docs/latest/commands/ft.alter/), [FT.DROPINDEX](https://redis.io/docs/latest/commands/ft.dropindex/)
      
    • json-indexing.md 6.2 KB
      # Index JSON Documents with JSONPath and Aliases
      
      For JSON documents, the schema declares `ON JSON` and each field is a JSONPath plus an `AS <alias>`. The alias is what you query against (`@alias:...`) — without `AS`, Redis Search generates one from the path that is awkward to type and easy to typo. Array elements (`$.tags[*]`) and nested objects (`$.address.city`) work seamlessly.
      
      **Correct:** Index a JSON Bicycle catalog: TEXT, TAG, NUMERIC, an array of TAGs, and a vector.
      
      ```
      # Source documents
      JSON.SET bicycle:0 $ '{
        "model": "Hyperion",
        "brand": "Velorim",
        "description": "Lightweight mountain bicycle for trail riding",
        "price": 1299,
        "condition": "new",
        "categories": ["mountain", "trail", "lightweight"],
        "store_location": "-122.4,37.7",
        "description_embeddings": [/* 1536 floats */]
      }'
      
      # Index — each path declared with AS <alias>, alias is what queries reference
      FT.CREATE idx:bicycle ON JSON PREFIX 1 bicycle:
          SCHEMA
              $.model              AS model             TEXT  WEIGHT 2.0
              $.brand              AS brand             TAG
              $.description        AS description       TEXT
              $.price              AS price             NUMERIC SORTABLE
              $.condition          AS condition         TAG
              $.categories[*]      AS categories        TAG
              $.store_location     AS store_location    GEO
              $.description_embeddings AS description_embeddings VECTOR HNSW 6
                  TYPE FLOAT32
                  DIM 1536
                  DISTANCE_METRIC COSINE
      ```
      
      **Query against the aliases, not the paths:**
      
      ```
      FT.SEARCH idx:bicycle "@brand:{Velorim} @categories:{mountain} @price:[100 1500]"
          DIALECT 2
      ```
      
      ## JSONPath syntax that works inside FT.CREATE
      
      | Pattern | Meaning | Example |
      |---------|---------|---------|
      | `$.field` | Scalar at the top level. | `$.price AS price NUMERIC` |
      | `$.nested.field` | Scalar inside a nested object. | `$.address.city AS city TAG` |
      | `$.array[*]` | Each element of an array as a TAG/TEXT value. | `$.tags[*] AS tags TAG` |
      | `$.array[*].field` | A field from each object in an array. | `$.variants[*].sku AS skus TAG` |
      
      **Incorrect:** Omitting `AS` (forces awkward generated aliases), trying to query the raw path, or pointing a vector field at a non-array JSON value.
      
      ```
      # Bad: no AS — field is queryable as @"$.price" which is fragile and ugly.
      FT.CREATE idx:bicycle ON JSON PREFIX 1 bicycle:
          SCHEMA
              $.price NUMERIC
      
      # Bad: querying by JSON path instead of alias — wrong field identifier
      FT.SEARCH idx:bicycle "@$.price:[100 500]"   # use @price:[100 500]
      ```
      
      ## JSON + vector pairing
      
      - Embeddings must be stored as a JSON array of numbers.
      - `TYPE FLOAT32` + `DIM` must match the embedding model exactly (e.g., 1536 for OpenAI `text-embedding-3-small`, 768 for many open-source models).
      - `JSON.SET ... '[...]' '$.embedding'` accepts the array; the indexer encodes to FLOAT32 on read.
      
      **Gotcha:** an array path indexed as `TAG` makes every element a discrete tag. The same path indexed as `TEXT` would *tokenize* each element. For categorical filters, prefer `TAG`.
      
      ## Schema attribute vs raw JSONPath — which alias do I reference?
      
      The rule is symmetric: query by the schema attribute name, not the JSONPath. If the schema declared `$.author AS author`, queries use `@author`. If a JSONPath was *not* declared in the schema (or declared without `AS`), the field is not directly queryable — in `FT.AGGREGATE` you must `LOAD <n> $.path AS Alias` before referencing `@Alias` downstream, and in `FT.SEARCH` `SORTBY @author` requires `author` to be in the schema as `SORTABLE`.
      
      ```
      # Schema-declared with AS alias — query by the alias
      $.author AS author TEXT SORTABLE
      → FT.SEARCH idx:books "@author:Asimov" SORTBY author ASC DIALECT 2
      
      # Not in schema — must LOAD it first, then reference the loaded alias
      FT.AGGREGATE idx:books "*"
          LOAD 1 $.author AS Author
          GROUPBY 1 @Author REDUCE COUNT 0 AS n
          DIALECT 2
      ```
      
      ## Client mirrors
      
      ```python
      # redis-py — STEP_START json_indexing
      # Mirrors doctests/home_json.py + dt_json.py
      from redis import Redis
      from redis.commands.search.field import TextField, TagField, NumericField, VectorField
      from redis.commands.search.indexDefinition import IndexDefinition, IndexType
      
      r = Redis()
      schema = (
          TextField("$.model", as_name="model", weight=2.0),
          TagField("$.brand", as_name="brand"),
          TextField("$.description", as_name="description"),
          NumericField("$.price", as_name="price", sortable=True),
          TagField("$.categories[*]", as_name="categories"),
          VectorField("$.description_embeddings", as_name="description_embeddings",
                      algorithm="HNSW",
                      attributes={"TYPE": "FLOAT32", "DIM": 1536, "DISTANCE_METRIC": "COSINE"}),
      )
      r.ft("idx:bicycle").create_index(schema, definition=IndexDefinition(prefix=["bicycle:"], index_type=IndexType.JSON))
      # STEP_END
      ```
      
      ```java
      // Jedis — STEP_START json_indexing
      // Mirrors JsonExample.java + HomeJsonExample.java
      import redis.clients.jedis.UnifiedJedis;
      import redis.clients.jedis.search.FTCreateParams;
      import redis.clients.jedis.search.IndexDataType;
      import redis.clients.jedis.search.schemafields.*;
      
      try (UnifiedJedis jedis = new UnifiedJedis("redis://localhost:6379")) {
          jedis.ftCreate("idx:bicycle",
              FTCreateParams.createParams().on(IndexDataType.JSON).prefix("bicycle:"),
              TextField.of("$.model").as("model").weight(2.0),
              TagField.of("$.brand").as("brand"),
              TextField.of("$.description").as("description"),
              NumericField.of("$.price").as("price").sortable(),
              TagField.of("$.categories[*]").as("categories"));
      }
      // STEP_END
      ```
      
      ## Upstream sources
      
      - redis-py: [`doctests/home_json.py`](https://github.com/redis/redis-py/blob/master/doctests/home_json.py), [`dt_json.py`](https://github.com/redis/redis-py/blob/master/doctests/dt_json.py)
      - Jedis: [`HomeJsonExample.java`](https://github.com/redis/jedis/blob/master/src/test/java/io/redis/examples/HomeJsonExample.java), [`JsonExample.java`](https://github.com/redis/jedis/blob/master/src/test/java/io/redis/examples/JsonExample.java)
      - Reference: [Index JSON documents](https://redis.io/docs/latest/develop/interact/search-and-query/indexing/json/), [JSONPath](https://redis.io/docs/latest/develop/data-types/json/path/)
      
    • query-optimization.md 3.9 KB
      # Write Performant Queries
      
      This reference is performance-focused — syntax details live in [query-syntax.md](query-syntax.md), vector queries in [vector-query.md](vector-query.md), aggregate pipelines in [aggregate-pipeline.md](aggregate-pipeline.md). The lever is the same in every case: narrow the candidate set as early as possible, return as little as possible, and use indexed sort paths.
      
      **Correct:** Pre-filter, sort on `SORTABLE` fields, return only what you use.
      
      ```
      # Specific filters drop the candidate set before any scoring
      FT.SEARCH idx:bicycle "@type:{mountain} @price:[100 500]"
          SORTBY price ASC                       # price is SORTABLE NUMERIC → near-free
          LIMIT 0 20
          RETURN 3 model brand price
          DIALECT 2
      
      # Pre-filtered vector query — TAG + NUMERIC cut 99% of vectors before KNN
      FT.SEARCH idx:bicycle "(@type:{mountain} @price:[100 500])=>[KNN 10 @description_embeddings $vec AS score]"
          SORTBY score
          PARAMS 2 vec "<vector_blob>"
          RETURN 4 model brand price score
          DIALECT 2
      ```
      
      ## The performance levers — in priority order
      
      1. **Narrow with TAG / NUMERIC predicates first.** They're cheaper than TEXT scoring and cut candidate counts dramatically. See [query-syntax.md](query-syntax.md).
      2. **`SORTBY` on `SORTABLE` fields.** Non-sortable sorting falls back to a row-by-row sort over the page. Mark `NUMERIC SORTABLE` and `TAG SORTABLE` on any field you'll order by.
      3. **`LIMIT 0 n` aggressively.** Default page size returns 10; raising to 1000 is fine, raising to 100000 will hurt.
      4. **`RETURN n f1 f2 ...`** stops Redis from materializing fields you'll throw away. Combine with `NOCONTENT` when you only need keys.
      5. **`NOSTEM` and `TAG` over `TEXT` for identifiers.** Tokenization is expensive and easy to misconfigure (see [text-tokenization.md](text-tokenization.md)).
      6. **Profile, don't guess.** `FT.PROFILE` reports per-stage timing; `FT.EXPLAIN` shows how the parser interpreted the query (see [debugging.md](debugging.md)).
      
      ```
      # Diagnose a slow query
      FT.PROFILE idx:bicycle SEARCH QUERY "@type:{mountain}" LIMIT 0 20
      
      # See whether stemming/expansion is bloating the term list
      FT.EXPLAIN idx:bicycle "running shoes"
      ```
      
      **Incorrect:** Wildcard scans, deep pagination, sorting non-SORTABLE fields, dumping the full doc.
      
      ```
      # Bad: wildcard scan over the whole index
      FT.SEARCH idx:bicycle "*" LIMIT 0 10000
      
      # Bad: deep offset pagination — server scans+sorts offset+page rows
      FT.SEARCH idx:bicycle "*" LIMIT 100000 20
      
      # Bad: SORTBY on a non-SORTABLE TEXT field at high LIMIT
      FT.SEARCH idx:bicycle "*" SORTBY description ASC LIMIT 0 1000
      
      # Bad: returning every field when only 3 are used downstream
      FT.AGGREGATE idx:bicycle "*" LOAD *
      ```
      
      ## Client mirrors
      
      ```python
      # redis-py — STEP_START query_perf
      from redis import Redis
      from redis.commands.search.query import Query
      r = Redis()
      q = (Query("@type:{mountain} @price:[100 500]")
           .sort_by("price", asc=True)
           .return_fields("model", "brand", "price")
           .paging(0, 20)
           .dialect(2))
      r.ft("idx:bicycle").search(q)
      # STEP_END
      ```
      
      ```java
      // Jedis — STEP_START query_perf
      import redis.clients.jedis.UnifiedJedis;
      import redis.clients.jedis.search.Query;
      try (UnifiedJedis jedis = new UnifiedJedis("redis://localhost:6379")) {
          Query q = new Query("@type:{mountain} @price:[100 500]")
              .setSortBy("price", true)
              .returnFields("model", "brand", "price")
              .limit(0, 20)
              .dialect(2);
          jedis.ftSearch("idx:bicycle", q);
      }
      // STEP_END
      ```
      
      ## Upstream sources
      
      - redis-py: [`doctests/search_quickstart.py`](https://github.com/redis/redis-py/blob/master/doctests/search_quickstart.py)
      - Jedis: [`SearchQuickstartExample.java`](https://github.com/redis/jedis/blob/master/src/test/java/io/redis/examples/SearchQuickstartExample.java)
      - Reference: [Query Syntax](https://redis.io/docs/latest/develop/interact/search-and-query/query/), [FT.PROFILE](https://redis.io/docs/latest/commands/ft.profile/)
      
    • query-syntax.md 9.7 KB
      # Master Redis Search Query Syntax
      
      The Redis Search query DSL composes operators (AND, OR, NOT, optional), field-scoped predicates (`@field:value`), and delimiter-specific value forms (TAG `{}`, NUMERIC `[]`, TEXT phrase `""`). Most "empty result" bugs come from picking the wrong delimiter or forgetting to escape special characters in TAG values.
      
      Before writing the query expression, anchor terminology in [search-syntax-primitives.md](search-syntax-primitives.md) (Query Term, Field Identifier, Delimiters, Operators).
      
      **Correct:** Operator and delimiter reference, against the canonical Bicycle dataset.
      
      ```
      # Field scoping — TEXT (free-text, tokenized + stemmed)
      FT.SEARCH idx:bicycle "@description:wireless"                 DIALECT 2
      
      # TAG — exact match with { }; pipe = OR
      FT.SEARCH idx:bicycle "@condition:{new|refurbished}"          DIALECT 2
      
      # NUMERIC range — inclusive [], exclusive ( prefix, +inf/-inf supported
      FT.SEARCH idx:bicycle "@price:[100 500]"                      DIALECT 2
      FT.SEARCH idx:bicycle "@price:[(100 (500]"                    DIALECT 2
      FT.SEARCH idx:bicycle "@price:[-inf 200]"                     DIALECT 2
      
      # TEXT phrase — quotes for exact ordering
      FT.SEARCH idx:bicycle "\"mountain bicycle\""                  DIALECT 2
      
      # TEXT prefix / suffix / infix wildcards
      FT.SEARCH idx:bicycle "@model:bik*"                           DIALECT 2
      FT.SEARCH idx:bicycle "@model:*ike*"                          DIALECT 2
      
      # Fuzzy match — %term% (1 edit), %%term%% (2 edits), %%%term%%% (3 edits)
      FT.SEARCH idx:bicycle "@model:%bicycle%"                      DIALECT 2
      
      # Boolean — implicit AND (space), | OR, - NOT, ~ optional, () grouping
      FT.SEARCH idx:bicycle "@type:{mountain} -@condition:{used}"   DIALECT 2
      FT.SEARCH idx:bicycle "(@type:{mountain}|@type:{road}) @price:[-inf 500]" DIALECT 2
      
      # GEO — point + radius
      FT.SEARCH idx:bicycle "@store_location:[-122.4 37.7 50 km]"   DIALECT 2
      
      # GEOSHAPE — WITHIN polygon (DIALECT 3+, but FT.CREATE marks the field)
      FT.SEARCH idx:zones "@boundary:[WITHIN $poly]" PARAMS 2 poly "POLYGON((...))" DIALECT 3
      ```
      
      ## TAG escaping rules
      
      These are the single biggest source of empty-result bugs. TAG values are *not* tokenized; hyphens, dots, commas, `@`, `:`, and spaces inside a tag must be escaped with a leading backslash, and the whole value lives inside `{}`.
      
      ```
      # TAG with hyphen — must escape
      FT.SEARCH idx:bicycle "@brand:{Giant\\-Cycles}"               DIALECT 2
      
      # TAG with dot — must escape
      FT.SEARCH idx:bicycle "@email:{user\\@example\\.com}"         DIALECT 2
      
      # TAG with space — escape the space (or use double-quotes inside the braces)
      FT.SEARCH idx:bicycle "@brand:{Trek\\ Bicycles}"              DIALECT 2
      
      # TAG with embedded colons (FHIR-style urn:uuid:...) — escape every : and -
      FT.SEARCH idx:obs "@subject:{urn\\:uuid\\:fa70e7dd\\-03aa\\-6885\\-ca29\\-c65c38dab633}" DIALECT 2
      ```
      
      **TAG comparisons are case-sensitive AND require exact-value match (no substring).** Mirror the casing of values exactly as they appear in the schema's `top_values` or sample documents — Redis Search does not auto-fold TAG case and does not substring-match TAG values.
      
      - `@Breed:{Pit}` will NOT match an indexed `@Breed:{pitbull}`. If you see `pitbull` in the sample data, query exactly `@Breed:{pitbull}` (or `@Breed:pitbull` for TEXT fields). Do not infer a more-specific or more-generic variant.
      - For multi-word breeds / categories, look at the actual schema value: `Pit Bull` and `pitbull` are different values and will not match each other.
      
      **TAG IDs (UUIDs, FHIR `urn:uuid:…`, hyphenated codes) MUST be escaped.** Every `:`, `-`, `.`, and space inside `{...}` needs a leading backslash. UUIDs almost always contain hyphens — forgetting to escape returns zero results or a syntax error.
      
      ```
      # Bad: unescaped hyphens in a UUID — "Syntax error at offset 13 near ..."
      FT.SEARCH explanationofbenefits "@id:{96771ad4-d132-3aa6-3a79-36a03ded158e}"
      
      # Good: every hyphen escaped
      FT.SEARCH explanationofbenefits "@id:{96771ad4\\-d132\\-3aa6\\-3a79\\-36a03ded158e}"
      
      # Good: FHIR-style urn:uuid:... — escape every : and -
      FT.SEARCH idx:obs "@subject:{urn\\:uuid\\:fa70e7dd\\-03aa\\-6885\\-ca29\\-c65c38dab633}"
      ```
      
      **When the question gives you a literal ID string, the command type is FT.SEARCH** (look-up by key), and the value goes inside `{...}` with every `-`/`:`/`.` escaped.
      
      ## Multi-word TEXT — phrase vs AND-of-words
      
      Unquoted multi-word values in a `@field:` clause split on whitespace and AND the terms *across* the index, not scoped to the field. Use `"…"` for an exact phrase or `(…)` for word-AND scoped to the field.
      
      ```
      # Bad: unquoted — parses as @reason:sleep AND apnea (apnea is unfielded!)
      FT.SEARCH idx:dx "@reason:sleep apnea"             DIALECT 2
      
      # Good: exact phrase, words in order, adjacent
      FT.SEARCH idx:dx "@reason:\"sleep apnea\""         DIALECT 2
      
      # Good: both words required, any order, no adjacency constraint
      FT.SEARCH idx:dx "@reason:(sleep apnea)"           DIALECT 2
      ```
      
      ## Dates indexed as TEXT
      
      Hyphens are token breaks in TEXT, so an unescaped `@date:2022-07` parses as `2022 AND -07`. Escape hyphens and use a prefix wildcard for "month of" / "year of" queries; alternation lives inside `(...)`.
      
      ```
      # All dates in July 2022 — escape - and use a trailing * for the day
      FT.SEARCH idx:events "@date:2022\\-07*"            DIALECT 2
      
      # Q1 2022 — alternation of escaped prefixes inside parens
      FT.SEARCH idx:events "@date:(2022\\-01*|2022\\-02*|2022\\-03*)"   DIALECT 2
      
      # Bad: unescaped hyphen — parses as 2022 AND -07
      FT.SEARCH idx:events "@date:2022-07"
      
      # Bad: per-field alternation — becomes a UNION of three different field clauses, not a date OR
      FT.SEARCH idx:events "@date:2010 | @date:2011 | @date:2012"
      ```
      
      **The `*` does NOT distribute across alternation — every alternative carries its own trailing `*`.** This is the single most common date-alternation bug.
      
      ```
      # Bad: bare years inside alternation — matches the literal tokens "2018" / "2019", not "any date in 2018/2019"
      FT.SEARCH idx:events "@date:(2018|2019)"
      
      # Bad: hyphen-escaped but no wildcard — matches the literal "2022-01" / "2022-02" only
      FT.SEARCH idx:events "@date:(2022\\-01|2022\\-02)"
      
      # Good: every alternative gets its own trailing *
      FT.SEARCH idx:events "@date:(2018*|2019*)"                        # any date in 2018 or 2019
      FT.SEARCH idx:events "@date:(2022\\-01*|2022\\-02*|2022\\-03*)"   # any date in Jan/Feb/Mar 2022
      ```
      
      The same alternation rule applies inside `FT.AGGREGATE` query strings — every TEXT field where you want a prefix-match disjunction, not just dates.
      
      **Incorrect:** Using `()` for TAG values, `{}` for TEXT, forgetting to escape hyphens, or mixing delimiters.
      
      ```
      # Bad: () around a TAG value — parses as a TEXT clause, returns nothing
      FT.SEARCH idx:bicycle "@condition:(new)"
      
      # Bad: unescaped hyphen in a TAG — RQE treats the dash as NOT
      FT.SEARCH idx:bicycle "@brand:{Giant-Cycles}"   # returns 0 results
      
      # Bad: NUMERIC values inside {} — silently empty
      FT.SEARCH idx:bicycle "@price:{100 500}"
      
      # Bad: parens unbalanced for numeric range — "Syntax error near +inf"
      # The OUTER brackets of a numeric range are always [ ]. To make a bound
      # exclusive, prefix the VALUE with ( inside the brackets.
      FT.SEARCH idx:bicycle "@abv:(0.08 +inf]"
      
      # Good: exclusive lower, inclusive upper
      FT.SEARCH idx:bicycle "@abv:[(0.08 +inf]"
      
      # Good: both bounds exclusive
      FT.SEARCH idx:bicycle "@abv:[(0.08 (1.0]"
      ```
      
      | Delimiter | Use | Example |
      |-----------|-----|---------|
      | `( )` | TEXT phrase grouping / boolean grouping | `(@type:{product} \| @type:{post})` |
      | `{ }` | TAG exact-match (with `\|` for alternatives) | `@condition:{new\|refurbished}` |
      | `[ ]` | NUMERIC range, GEO, GEOSHAPE, VECTOR_RANGE | `@price:[100 500]`, `@price:[-inf 200]` |
      | `" "` | exact phrase match in TEXT | `"mountain bicycle"` |
      
      ## Client mirrors
      
      ```python
      # redis-py — STEP_START query_syntax
      # Mirrors doctests/query_ft.py + query_em.py
      from redis import Redis
      r = Redis()
      # TAG with escaped hyphen
      r.ft("idx:bicycle").search(r"@brand:{Giant\-Cycles}")
      # NUMERIC range
      r.ft("idx:bicycle").search("@price:[100 500]")
      # Boolean: type mountain OR road, exclude used
      r.ft("idx:bicycle").search("(@type:{mountain}|@type:{road}) -@condition:{used}")
      # STEP_END
      ```
      
      ```java
      // Jedis — STEP_START query_syntax
      // Mirrors QueryFtExample.java + QueryEmExample.java
      import redis.clients.jedis.UnifiedJedis;
      import redis.clients.jedis.search.Query;
      try (UnifiedJedis jedis = new UnifiedJedis("redis://localhost:6379")) {
          // TAG with escaped hyphen — note Java requires double-escaping the backslash
          jedis.ftSearch("idx:bicycle", new Query("@brand:{Giant\\-Cycles}"));
          jedis.ftSearch("idx:bicycle", new Query("@price:[100 500]"));
          jedis.ftSearch("idx:bicycle",
              new Query("(@type:{mountain}|@type:{road}) -@condition:{used}"));
      }
      // STEP_END
      ```
      
      ## Upstream sources
      
      - redis-py: [`doctests/query_ft.py`](https://github.com/redis/redis-py/blob/master/doctests/query_ft.py), [`query_em.py`](https://github.com/redis/redis-py/blob/master/doctests/query_em.py), [`query_geo.py`](https://github.com/redis/redis-py/blob/master/doctests/query_geo.py), [`query_range.py`](https://github.com/redis/redis-py/blob/master/doctests/query_range.py)
      - Jedis: [`QueryFtExample.java`](https://github.com/redis/jedis/blob/master/src/test/java/io/redis/examples/QueryFtExample.java), [`QueryEmExample.java`](https://github.com/redis/jedis/blob/master/src/test/java/io/redis/examples/QueryEmExample.java), [`QueryGeoExample.java`](https://github.com/redis/jedis/blob/master/src/test/java/io/redis/examples/QueryGeoExample.java)
      - Reference: [Query Syntax](https://redis.io/docs/latest/develop/interact/search-and-query/query/), [Escaping](https://redis.io/docs/latest/develop/interact/search-and-query/query/#tokenization)
      
    • rag-pattern.md 5.1 KB
      # Implement RAG Retrieval Against Redis Correctly
      
      A RAG pipeline against Redis is three steps: (1) store documents + embeddings in a HASH or JSON index, (2) embed the user's question with the same model, (3) run a KNN query that returns the top-k passages and their distance. Step 3 is where most quality bugs live — see [vector-query.md](vector-query.md) for the canonical query form.
      
      **Correct: minimal end-to-end pipeline.** The retrieval step is CLI-form first; the embedding/LLM steps are deliberately client-side.
      
      ```
      # 1. Index, built once
      FT.CREATE idx:bicycle ON HASH PREFIX 1 bicycle:
          SCHEMA
              description TEXT
              type TAG
              price NUMERIC SORTABLE
              description_embeddings VECTOR HNSW 6 TYPE FLOAT32 DIM 1536 DISTANCE_METRIC COSINE
      
      # 2. Documents inserted with HSET (or JSON.SET for JSON indexes).
      #    The vector field holds the raw FLOAT32 little-endian blob.
      
      # 3. Retrieval — pre-filtered KNN, score aliased, only the fields the LLM needs returned
      FT.SEARCH idx:bicycle "(@type:{mountain})=>[KNN 5 @description_embeddings $query_vec AS score]"
          SORTBY score
          PARAMS 2 query_vec "<query_vector_blob>"
          RETURN 3 description type score
          DIALECT 2
      ```
      
      ## End-to-end pattern (redis-py)
      
      ```python
      # redis-py — STEP_START rag_pipeline
      # Distilled from doctests/search_vss.py
      import numpy as np
      from redis import Redis
      from redis.commands.search.query import Query
      
      r = Redis()
      
      def embed(text: str) -> bytes:
          # Replace with your model — must produce the SAME dim as the index (1536 here)
          return np.array(embed_model.encode(text), dtype=np.float32).tobytes()
      
      def retrieve(question: str, k: int = 5, type_filter: str = "mountain"):
          q = (Query(f"(@type:{{{type_filter}}})=>[KNN {k} @description_embeddings $vec AS score]")
               .sort_by("score").return_fields("description", "type", "score")
               .dialect(2).paging(0, k))
          return r.ft("idx:bicycle").search(q, query_params={"vec": embed(question)})
      
      passages = retrieve("lightweight mountain bicycle for trails")
      context = "\n\n".join(d.description for d in passages.docs)
      # Pass `context` + question to your LLM of choice.
      # STEP_END
      ```
      
      ## End-to-end pattern (Jedis)
      
      ```java
      // Jedis — STEP_START rag_pipeline
      import redis.clients.jedis.UnifiedJedis;
      import redis.clients.jedis.search.Query;
      
      try (UnifiedJedis jedis = new UnifiedJedis("redis://localhost:6379")) {
          byte[] vec = embed("lightweight mountain bicycle for trails"); // FLOAT32 little-endian
          Query q = new Query("(@type:{mountain})=>[KNN 5 @description_embeddings $vec AS score]")
              .setSortBy("score", true)
              .returnFields("description", "type", "score")
              .addParam("vec", vec)
              .dialect(2)
              .limit(0, 5);
          var result = jedis.ftSearch("idx:bicycle", q);
          // Build the prompt from result.getDocuments() and call your LLM.
      }
      // STEP_END
      ```
      
      ## Retrieval-quality checklist
      
      - **Match the metric to the model.** Most modern text embedding models pair best with `COSINE`. Normalize embeddings if the model isn't already producing unit vectors and you use `COSINE`.
      - **Pre-filter** with TAG/NUMERIC before `=>[KNN ...]` when the user supplies categorical or range constraints — see [vector-query.md](vector-query.md).
      - **Return only what the LLM consumes** (the score alias + the passage text). Returning the embedding wastes bandwidth.
      - **Chunk long documents** to a size near the embedding model's effective context (e.g., 200–500 tokens) before indexing — retrieval quality drops sharply on chunks too large for the embedding model.
      - **Re-embed the corpus** after a model change — you cannot mix embeddings from different models in the same index.
      - **Batch inserts** rather than one call per record (e.g., redis-py pipeline or RedisVL `index.load([...])`).
      
      **Incorrect:** Returning everything and filtering client-side, mismatched embedding models, or skipping the pre-filter.
      
      ```python
      # Bad: client-side filter wastes vector work
      results = r.ft("idx:bicycle").search(
          Query("*=>[KNN 1000 @description_embeddings $vec AS score]")
          .sort_by("score").dialect(2),
          query_params={"vec": vec_blob})
      mountain = [d for d in results.docs if d.type == "mountain"][:5]
      
      # Bad: question embedded with model A, corpus embedded with model B — distances meaningless
      ```
      
      ## Cross-links
      
      - KNN syntax in depth: [vector-query.md](vector-query.md)
      - Vector index configuration: [index-creation.md](index-creation.md)
      - Hybrid retrieval (pre-filter vs FT.HYBRID): [hybrid-search.md](hybrid-search.md)
      
      RedisVL `SearchIndex.load()` for bulk doc + embedding insertion and `VectorQuery` end-to-end pipelines live in [clients/python-redisvl.md](clients/python-redisvl.md).
      
      ## Upstream sources
      
      - redis-py: [`doctests/search_vss.py`](https://github.com/redis/redis-py/blob/master/doctests/search_vss.py)
      - Jedis: [`VectorSearchExample.java`](https://github.com/redis/jedis/blob/master/src/test/java/io/redis/examples/VectorSearchExample.java)
      - Reference: [Redis RAG Quickstart](https://redis.io/docs/latest/develop/get-started/rag/), [Vector Search](https://redis.io/docs/latest/develop/interact/search-and-query/advanced-concepts/vectors/)
      
    • result-shaping.md 5.9 KB
      # Shape Search Results with RETURN, SORTBY, HIGHLIGHT, SUMMARIZE
      
      By default `FT.SEARCH` returns full documents — expensive when you only need a few fields, or a count, or a UI-ready snippet. The result-shaping clauses (`RETURN`, `NOCONTENT`, `LIMIT`, `SORTBY`, `HIGHLIGHT`, `SUMMARIZE`) trim the response server-side and pre-format text for display.
      
      **Correct:** Shape the response to exactly what the caller needs.
      
      ```
      # Count only — no documents returned
      FT.SEARCH idx:bicycle "@type:{mountain}" LIMIT 0 0 DIALECT 2
      
      # IDs only — NOCONTENT skips the field payload
      FT.SEARCH idx:bicycle "@type:{mountain}" NOCONTENT LIMIT 0 20 DIALECT 2
      
      # Specific fields only — RETURN n field1 field2 ...
      FT.SEARCH idx:bicycle "@type:{mountain}"
          RETURN 3 model brand price
          LIMIT 0 20
          DIALECT 2
      
      # Sort by an indexed field — requires SORTABLE on the field at FT.CREATE time
      FT.SEARCH idx:bicycle "@type:{mountain}"
          SORTBY price ASC
          LIMIT 0 10
          RETURN 3 model brand price
          DIALECT 2
      
      # Highlight matched terms with HTML tags
      FT.SEARCH idx:bicycle "wireless"
          HIGHLIGHT FIELDS 1 description TAGS "<b>" "</b>"
          DIALECT 2
      
      # Summarize: extract up to 3 fragments of 20 tokens each from @description
      FT.SEARCH idx:bicycle "wireless"
          SUMMARIZE FIELDS 1 description FRAGS 3 LEN 20 SEPARATOR " ... "
          DIALECT 2
      ```
      
      ## RETURN counts tokens, not fields
      
      `nargs = 3·(aliased paths) + 1·(unaliased paths)`. `RETURN <nargs> <args>` consumes exactly `<nargs>` whitespace-separated tokens. A plain field is 1 token; an aliased JSONPath (`<path> AS <alias>`) is 3 tokens. Setting `nargs` to the number of *fields* you want back is the most common single bug in real LLM-generated `FT.SEARCH` calls.
      
      ```
      # Bad: nargs counted as paths — "Unknown argument 'AS' at position 4"
      FT.SEARCH idx:breweries "*" RETURN 1 $.beers[*].name AS beer_names
      
      # Bad: two aliased paths but nargs=2 — "RETURN path AS name - must be accompanied with NAME"
      FT.SEARCH idx:breweries "*" RETURN 2 $.status AS status $.reason AS reason
      
      # Good: no alias, 1 token per path
      FT.SEARCH idx:breweries "*" RETURN 1 $.beers[*].name
      
      # Good: one aliased path = 3 tokens (path + AS + alias)
      FT.SEARCH idx:breweries "*" RETURN 3 $.beers[*].name AS beer_names
      
      # Good: two aliased paths = 6 tokens
      FT.SEARCH idx:breweries "*" RETURN 6 $.status AS status $.reason AS reason
      
      # Good: mixed — 1 unaliased + 1 aliased = 1 + 3 = 4 tokens
      FT.SEARCH idx:breweries "*" RETURN 4 $.id $.status AS status
      ```
      
      ## Why these matter
      
      - `RETURN n` is the single biggest perf win for wide schemas — typical 50% latency cut when you stop sending unused fields.
      - `SORTBY` on a non-`SORTABLE` field falls back to a row-by-row sort over the result page; on a `SORTABLE NUMERIC` field it's near-free.
      - `NOCONTENT` is what `FT.SEARCH` wants when you only need the matching keys (e.g., to pipeline a follow-up `MGET`).
      - `LIMIT 0 0` is the canonical count idiom — total appears in position 0 of the reply.
      - `HIGHLIGHT` and `SUMMARIZE` only operate on TEXT fields and assume the field was indexed without `NOOFFSETS`.
      
      ## FT.SEARCH `SORTBY` has NO nargs
      
      The form is `SORTBY <field> [ASC|DESC]`. This is different from `FT.AGGREGATE`'s `SORTBY <nargs> <field> <DIR> …`. Mixing them produces `Unknown argument 'author' at position 3`-style errors.
      
      ```
      # Bad: applying the FT.AGGREGATE token-count form inside FT.SEARCH
      FT.SEARCH idx:doc "*" SORTBY 2 author ASC      # "Unknown argument 'author' at position 3"
      
      # Good: plain field + direction
      FT.SEARCH idx:doc "*" SORTBY author ASC
      ```
      
      **Incorrect:** Pagination with deep offsets, sorting non-SORTABLE fields at high LIMIT, fetching full docs to throw away most fields.
      
      ```
      # Bad: deep pagination — server must scan + sort offset+page rows
      FT.SEARCH idx:bicycle "*" LIMIT 100000 20
      
      # Bad: SORTBY a TEXT field that wasn't marked SORTABLE — falls back to in-page sort
      FT.SEARCH idx:bicycle "*" SORTBY description ASC LIMIT 0 1000
      
      # Bad: fetching the entire doc when only 3 fields are used in the UI
      FT.SEARCH idx:bicycle "*" LIMIT 0 50
      ```
      
      ## Pagination patterns
      
      - Up to a few thousand rows: `LIMIT offset n` is fine.
      - Beyond that, switch to **search-after** patterns (sort by a stable cursor like `@id` or `@created_at`, then `FILTER @id > $last` on the next page).
      - For `FT.AGGREGATE` over very large result sets, use `WITHCURSOR` (see [aggregate-cursors.md](aggregate-cursors.md)).
      
      ## Client mirrors
      
      ```python
      # redis-py — STEP_START result_shaping
      from redis import Redis
      from redis.commands.search.query import Query
      
      r = Redis()
      q = (Query("@type:{mountain}")
           .return_fields("model", "brand", "price")
           .sort_by("price", asc=True)
           .paging(0, 20)
           .dialect(2))
      results = r.ft("idx:bicycle").search(q)
      # Count-only
      total = r.ft("idx:bicycle").search(Query("@type:{mountain}").paging(0, 0).dialect(2)).total
      # STEP_END
      ```
      
      ```java
      // Jedis — STEP_START result_shaping
      import redis.clients.jedis.UnifiedJedis;
      import redis.clients.jedis.search.Query;
      
      try (UnifiedJedis jedis = new UnifiedJedis("redis://localhost:6379")) {
          Query q = new Query("@type:{mountain}")
              .returnFields("model", "brand", "price")
              .setSortBy("price", true)
              .limit(0, 20)
              .dialect(2);
          jedis.ftSearch("idx:bicycle", q);
      
          Query countOnly = new Query("@type:{mountain}").limit(0, 0).dialect(2);
          long total = jedis.ftSearch("idx:bicycle", countOnly).getTotalResults();
      }
      // STEP_END
      ```
      
      ## Upstream sources
      
      - redis-py: covered across the upstream `doctests/query_*.py` set, including [`doctests/query_ft.py`](https://github.com/redis/redis-py/blob/master/doctests/query_ft.py)
      - Jedis: covered across the upstream `Query*Example.java` set, including [`QueryFtExample.java`](https://github.com/redis/jedis/blob/master/src/test/java/io/redis/examples/QueryFtExample.java)
      - Reference: [FT.SEARCH](https://redis.io/docs/latest/commands/ft.search/), [Highlighting](https://redis.io/docs/latest/develop/interact/search-and-query/advanced-concepts/highlight/)
      
    • search-syntax-primitives.md 6.6 KB
      # Redis Search Query Syntax Primitives
      
      This reference is the canonical vocabulary for the Redis Search query DSL. Action-oriented references ([query-syntax.md](query-syntax.md), [vector-query.md](vector-query.md), [aggregate-pipeline.md](aggregate-pipeline.md), etc.) link here by anchor instead of redefining terms — read this once to anchor terminology, then use the rest for "how to do X."
      
      The terms below describe what Redis Search understands when it parses the query string passed to `FT.SEARCH`, `FT.AGGREGATE`, or `FT.HYBRID`.
      
      ## Query Expression
      <a id="query-expression"></a>
      
      The complete text input submitted to Redis Search that defines the search criteria — terms, fields, operators, and modifiers combined to retrieve relevant documents.
      
      ```
      "hello world @category:{electronics} @price:[100 500]"
      ```
      
      ## Query Term
      <a id="query-term"></a>
      
      A single word or phrase that represents a discrete unit of search. Terms can be simple words, quoted phrases, prefixes with wildcards, fuzzy matches, or vector clauses. The canonical shape of a query term is **field identifier → delimiter → term**.
      
      ```
      smartphone
      @description:wireless
      @category:{ele*}
      ```
      
      ## Field Identifier
      <a id="field-identifier"></a>
      
      A prefix that scopes a query term to a specific indexed field. Without a field identifier, Redis Search searches across all TEXT fields. The syntax is `@<alias>:` where `<alias>` is the field name (or `AS` alias for JSON paths).
      
      ```
      @description:wireless
      @category:{electronics}
      @price:[100 500]
      ```
      
      ## Query Delimiters
      <a id="query-delimiters"></a>
      
      The bracket type tells Redis Search what kind of match to perform — **this is the single most common source of "empty result" bugs**.
      
      | Delimiter | Use | Example |
      |-----------|-----|---------|
      | `( )` | TEXT phrase / boolean grouping | `(@type:{product} \| @type:{post})` |
      | `{ }` | TAG exact-match (with `\|` for alternatives) | `@category:{electronics\|books}` |
      | `[ ]` | NUMERIC range, GEO, GEOSHAPE, VECTOR_RANGE | `@price:[100 500]`, `@price:[-inf 200]` |
      | `" "` | exact phrase match in TEXT | `"red shoes"` |
      
      Putting `()` around a TAG value or `{}` around a NUMERIC value silently returns zero results.
      
      ## Query Attributes
      <a id="query-attributes"></a>
      
      Modifiers attached to a term or group via the `=> { $key: value; ... }` form. They include text-search modifiers like `$weight`, `$slop`, and `$inorder`, and the vector-query attribute form `=>[KNN k @field $vec AS score]`.
      
      ```
      (foo bar) => { $weight: 2.0; $slop: 1; $inorder: false }
      *=>[KNN 10 @embedding $vec AS score]
      ```
      
      ## Weight
      <a id="weight"></a>
      
      A multiplier applied to a term or group of terms to increase their contribution to the document's relevance score. Higher weight = more influence.
      
      ```
      (foo bar) => { $weight: 2.0 }
      ```
      
      Field-level weight is set at index time via `TEXT WEIGHT n`; query-level weight overrides per-query.
      
      ## Scoring
      <a id="scoring"></a>
      
      The numerical calculation that assigns a relevance value to each document based on how well it matches the query. Scoring combines factors like term frequency, inverse document frequency (BM25 or TFIDF), field weights, and explicit boosts.
      
      The default scorer is BM25 on Redis 8 (TFIDF historically). Use `WITHSCORES` to return the score per result.
      
      ## Ranking
      <a id="ranking"></a>
      
      The ordering of search results by their scores. Scoring is the math; ranking is the application of that math to determine output order. By default `FT.SEARCH` returns documents ranked by descending score.
      
      ## Sorting
      <a id="sorting"></a>
      
      Explicit ordering by a field value rather than relevance, specified with `SORTBY <field> [ASC|DESC]`. To use `SORTBY` efficiently the field must be declared `SORTABLE` at index time; otherwise Redis Search falls back to an in-page sort.
      
      ## Grouping
      <a id="grouping"></a>
      
      The process of collecting documents that share field values, implemented in `FT.AGGREGATE` via `GROUPBY n @f1 @f2 ... REDUCE <fn> ... AS <alias>`. Reducers include `COUNT`, `COUNT_DISTINCT`, `SUM`, `AVG`, `MIN`, `MAX`, `STDDEV`, `QUANTILE`, `TOLIST`, `FIRST_VALUE`, `RANDOM_SAMPLE`.
      
      ## Similarity
      <a id="similarity"></a>
      
      The degree of approximate matching allowed:
      
      - **Fuzzy text** — `%term%` (Levenshtein distance 1), `%%term%%` (distance 2), `%%%term%%%` (distance 3).
      - **Phonetic** — `PHONETIC <matcher>` on a TEXT field at index time enables sound-alike matching (e.g., `smyth` ↔ `Smith`).
      - **Vector** — distance between embeddings under `COSINE`, `L2`, or `IP` metric, queried with `=>[KNN ...]` or `[VECTOR_RANGE ...]`.
      
      ## Filtering
      <a id="filtering"></a>
      
      Narrowing the candidate set by NUMERIC, TAG, or GEO criteria rather than text relevance. Filtering happens in the query expression itself (the left side of a query like `(@type:{mountain} @price:[100 500])=>[KNN ...]`) and prunes documents *before* the more expensive scoring stages.
      
      ```
      @price:[-inf 200]               # numeric range
      @brand:{Velorim|Trek}           # tag membership
      @store_location:[-122.4 37.7 50 km]   # geo radius
      ```
      
      ## Operators
      <a id="operators"></a>
      
      Operators combine multiple query terms in a single expression:
      
      | Operator | Symbol | Meaning |
      |----------|--------|---------|
      | AND | space (implicit) | all terms must match |
      | OR | `\|` | any term matches |
      | NOT | `-` (prefix) | exclude documents containing the term |
      | OPTIONAL | `~` (prefix) | optional; contributes to score when present |
      
      ```
      @type:{mountain} -@condition:{used}                    # AND, with negation
      @brand:{Velorim} | @brand:{Trek}                        # OR
      (@type:{mountain}|@type:{road}) @price:[-inf 500]      # grouped boolean
      ~"trail riding"                                         # optional phrase, boosts score
      ```
      
      **The `@` prefix is also required after operators in `FT.AGGREGATE` pipeline stages** — every field reference inside `LOAD`, `GROUPBY`, `SORTBY`, `APPLY`, and `FILTER` starts with `@`, including inside expressions like `@field >= 5` or `substr(@date, 0, 4)`. The `@` is part of the field token, not just a query-expression marker.
      
      ## Cross-references
      
      - Operator-by-operator with escaping rules: [query-syntax.md](query-syntax.md)
      - Vector-query attribute form: [vector-query.md](vector-query.md)
      - Aggregate pipeline stages: [aggregate-pipeline.md](aggregate-pipeline.md)
      - Tokenization, stemming, stopwords: [text-tokenization.md](text-tokenization.md)
      - Result shaping (`RETURN`, `SORTBY`, `HIGHLIGHT`, `SUMMARIZE`): [result-shaping.md](result-shaping.md)
      
      Reference: [Query Syntax](https://redis.io/docs/latest/develop/interact/search-and-query/query/), [Aggregations](https://redis.io/docs/latest/develop/interact/search-and-query/advanced-concepts/aggregations/)
      
    • text-tokenization.md 4.8 KB
      # Control Tokenization with NOSTEM, LANGUAGE, STOPWORDS, PHONETIC
      
      TEXT fields are tokenized, stemmed, and stopword-filtered at index time. Defaults work for English prose, but they silently drop matches when you index product SKUs, code identifiers, or non-English text. Tokenization is the most common reason `FT.EXPLAIN` shows a token expansion you didn't expect.
      
      **Correct:** Pick tokenization options per field, based on the kind of text in it.
      
      ```
      # A schema mixing prose, identifiers, and a non-English field
      FT.CREATE idx:bicycle ON HASH PREFIX 1 bicycle:
          SCHEMA
              # Prose — stem so "running" matches "run"
              description    TEXT WEIGHT 1.0
              # Model codes — don't stem, don't tokenize aggressively
              model          TEXT NOSTEM
              # Brand name — boost it in scoring
              brand          TEXT WEIGHT 3.0
              # Phonetic match for misspellings ("smyth" → "Smith")
              owner_name     TEXT PHONETIC dm:en
      
      # Index-wide options
      FT.CREATE idx:bicycle_de ON HASH PREFIX 1 bicycle:
          LANGUAGE german                              # default stemmer for all TEXT fields
          STOPWORDS 3 der die und                      # custom stopword list (0 disables)
          SCHEMA
              description TEXT
      ```
      
      ## Option reference
      
      | Option | Scope | Effect |
      |--------|-------|--------|
      | `NOSTEM` | per TEXT field | Skip stemming. Use for SKUs, model codes, identifiers — anything where `running` ≠ `run`. |
      | `WEIGHT n` | per TEXT field | Multiplier on TF/IDF contribution. Default 1.0; raise for high-signal fields like `title` or `brand`. |
      | `LANGUAGE <lang>` | index-wide (or per-doc) | Selects the stemmer. Defaults to `english`. Supported: english, arabic, chinese, danish, dutch, finnish, french, german, hungarian, italian, norwegian, portuguese, romanian, russian, spanish, swedish, tamil, turkish. |
      | `STOPWORDS n w1 w2 ...` | index-wide | Override the default English stopword list. `STOPWORDS 0` disables stopword removal entirely (necessary when stopwords are meaningful in your domain, e.g., `"to be"`). |
      | `PHONETIC <matcher>` | per TEXT field | Index phonetic codes for fuzzy-name matching. Matchers: `dm:en` (English), `dm:fr`, `dm:pt`, `dm:es`. |
      
      ## Diagnose tokenization with `FT.EXPLAIN`
      
      ```
      FT.EXPLAIN idx:bicycle "running shoes"
      # → INTERSECT { UNION{run, running} UNION{shoe, shoes} }
      # Stemming is expanding the terms. If "running" should be literal, mark the field NOSTEM.
      ```
      
      **Incorrect:** Using TEXT for identifiers (loses recall on SKUs), forgetting to disable stopwords for short queries that include them, or setting LANGUAGE on the wrong layer.
      
      ```
      # Bad: SKU as TEXT without NOSTEM — "BIKE-2024" gets tokenized + stemmed
      FT.CREATE idx:bicycle ON HASH PREFIX 1 bicycle:
          SCHEMA
              sku TEXT                          # use NOSTEM, or use TAG
      
      # Bad: querying "to be" against an index with default stopwords
      FT.SEARCH idx:books "to be or not to be"
      # → effectively searches "" — every stopword is dropped.
      
      # Bad: putting LANGUAGE on a single field — it is an index-wide option
      FT.CREATE idx:bicycle ON HASH
          SCHEMA description TEXT LANGUAGE french      # this is rejected
      ```
      
      ## Choosing TEXT vs TAG
      
      - TEXT: prose, descriptions, anything users type into a search box.
      - TAG: identifiers, categories, statuses, anything where exact match is what you want and tokenization is harmful.
      - A SKU like `BIKE-2024-XL` is almost always better as TAG.
      
      ## Client mirrors
      
      ```python
      # redis-py — STEP_START tokenization
      from redis import Redis
      from redis.commands.search.field import TextField, TagField
      r = Redis()
      schema = (
          TextField("description"),
          TextField("model", no_stem=True),
          TextField("brand", weight=3.0),
          TextField("owner_name", phonetic_matcher="dm:en"),
          TagField("sku"),                              # SKU as TAG, not stemmed TEXT
      )
      r.ft("idx:bicycle").create_index(schema)
      # STEP_END
      ```
      
      ```java
      // Jedis — STEP_START tokenization
      import redis.clients.jedis.UnifiedJedis;
      import redis.clients.jedis.search.FTCreateParams;
      import redis.clients.jedis.search.schemafields.*;
      
      try (UnifiedJedis jedis = new UnifiedJedis("redis://localhost:6379")) {
          jedis.ftCreate("idx:bicycle",
              FTCreateParams.createParams(),
              TextField.of("description"),
              TextField.of("model").noStem(),
              TextField.of("brand").weight(3.0),
              TextField.of("owner_name").phonetic("dm:en"),
              TagField.of("sku"));
      }
      // STEP_END
      ```
      
      ## Upstream sources
      
      - No direct upstream example — authored from official Redis Search command and tokenization documentation.
      - Reference: [Stemming](https://redis.io/docs/latest/develop/interact/search-and-query/advanced-concepts/stemming/), [Stopwords](https://redis.io/docs/latest/develop/interact/search-and-query/advanced-concepts/stopwords/), [Phonetic Matching](https://redis.io/docs/latest/develop/interact/search-and-query/advanced-concepts/phonetic_matching/)
      
    • vector-query.md 4.7 KB
      # Run KNN, Range, and Pre-Filtered Vector Queries
      
      Vector queries live inside `FT.SEARCH` as a `=>[KNN ...]` or `[VECTOR_RANGE ...]` clause. The query *expression* on the left side acts as a pre-filter; the vector clause then runs over the surviving candidate set, not the entire index. Forgetting to pre-filter is the most common cause of slow or low-recall vector queries.
      
      `DIALECT 2` is required for the `=>[KNN ...]` attribute form. The vector blob is bound through `PARAMS` rather than inlined.
      
      **Correct:** KNN, range, and hybrid pre-filter forms against the canonical Bicycle dataset (vector field `description_embeddings`, dim 1536).
      
      ```
      # Pure KNN — 10 nearest neighbours, no pre-filter
      FT.SEARCH idx:bicycle "*=>[KNN 10 @description_embeddings $vec AS score]"
          SORTBY score
          PARAMS 2 vec "<vector_blob>"
          DIALECT 2
      
      # Pre-filtered KNN — narrow by TAG + NUMERIC first, then KNN over survivors
      FT.SEARCH idx:bicycle "(@type:{mountain} @price:[100 500])=>[KNN 10 @description_embeddings $vec AS score]"
          SORTBY score
          PARAMS 2 vec "<vector_blob>"
          RETURN 4 model brand price score
          DIALECT 2
      
      # Range query — every doc within radius 0.5 (COSINE distance)
      FT.SEARCH idx:bicycle "@description_embeddings:[VECTOR_RANGE 0.5 $vec]=>{$yield_distance_as: dist}"
          SORTBY dist
          PARAMS 2 vec "<vector_blob>"
          DIALECT 2
      
      # Tune recall vs latency per query — HNSW only
      FT.SEARCH idx:bicycle "*=>[KNN 10 @description_embeddings $vec EF_RUNTIME 200 AS score]"
          SORTBY score
          PARAMS 2 vec "<vector_blob>"
          DIALECT 2
      ```
      
      **Why this matters:**
      
      - `AS score` aliases the distance so you can `SORTBY` and `RETURN` it.
      - `PARAMS` binds the binary vector blob — never inline it in the query string.
      - The pre-filter prefix `(@type:{mountain} @price:[100 500])` is applied *before* the vector search, slashing the work for HNSW.
      - `EF_RUNTIME` raises HNSW search effort per-query; the index-time `EF_CONSTRUCTION` is independent.
      
      **Incorrect:** Inlining the vector, omitting `DIALECT 2`, or running a wide-open KNN when you could pre-filter.
      
      ```
      # Bad: no PARAMS — vector blob does not survive RESP encoding cleanly
      FT.SEARCH idx:bicycle "*=>[KNN 10 @description_embeddings <raw-bytes>]" DIALECT 2
      
      # Bad: forgot DIALECT 2 — older default rejects the attribute form
      FT.SEARCH idx:bicycle "*=>[KNN 10 @description_embeddings $vec AS score]" PARAMS 2 vec "..."
      
      # Bad: KNN over the whole index when a TAG pre-filter would cut 99% of candidates
      FT.SEARCH idx:bicycle "*=>[KNN 10 @description_embeddings $vec AS score]"
          PARAMS 2 vec "..." DIALECT 2
      ```
      
      **Hybrid lexical + vector ranking with explicit fusion (Redis ≥ 8.4.0):** Use `FT.HYBRID` — see [command-selection.md](command-selection.md). The pre-filter pattern above is still the right tool for *filter-narrowed* vector search; `FT.HYBRID` is for *blended ranking* with RRF or LINEAR fusion.
      
      ## Client mirrors
      
      ```python
      # redis-py — STEP_START vector_query
      # Mirrors doctests/search_vss.py + query_combined.py
      import numpy as np
      from redis import Redis
      from redis.commands.search.query import Query
      
      r = Redis()
      vec_blob = np.array(query_embedding, dtype=np.float32).tobytes()
      q = (
          Query("(@type:{mountain} @price:[100 500])=>[KNN 10 @description_embeddings $vec AS score]")
          .sort_by("score").return_fields("model", "brand", "price", "score")
          .dialect(2).paging(0, 10)
      )
      results = r.ft("idx:bicycle").search(q, query_params={"vec": vec_blob})
      # STEP_END
      ```
      
      ```java
      // Jedis — STEP_START vector_query
      // Mirrors VectorSearchExample.java
      import redis.clients.jedis.UnifiedJedis;
      import redis.clients.jedis.search.Query;
      import java.nio.ByteBuffer;
      import java.nio.ByteOrder;
      
      byte[] vecBlob = floatArrayToBytes(queryEmbedding);  // little-endian FLOAT32
      try (UnifiedJedis jedis = new UnifiedJedis("redis://localhost:6379")) {
          Query q = new Query(
              "(@type:{mountain} @price:[100 500])=>[KNN 10 @description_embeddings $vec AS score]")
              .setSortBy("score", true)
              .returnFields("model", "brand", "price", "score")
              .addParam("vec", vecBlob)
              .dialect(2)
              .limit(0, 10);
          jedis.ftSearch("idx:bicycle", q);
      }
      // STEP_END
      ```
      
      ## Upstream sources
      
      - redis-py: [`doctests/search_vss.py`](https://github.com/redis/redis-py/blob/master/doctests/search_vss.py), [`query_combined.py`](https://github.com/redis/redis-py/blob/master/doctests/query_combined.py)
      - Jedis: [`VectorSearchExample.java`](https://github.com/redis/jedis/blob/master/src/test/java/io/redis/examples/VectorSearchExample.java)
      - Reference: [Vector Search](https://redis.io/docs/latest/develop/interact/search-and-query/advanced-concepts/vectors/), [Vector Queries](https://redis.io/docs/latest/develop/interact/search-and-query/query/vector-search/)
      
  • SKILL.md 11.2 KB
    ---
    name: redis-search
    description: Redis Search guidance covering FT.CREATE schema design, field type selection (TEXT, TAG, NUMERIC, GEO, GEOSHAPE, VECTOR, JSON path), DIALECT 2 query syntax, FT.SEARCH / FT.AGGREGATE / FT.HYBRID command selection, vector similarity with HNSW or FLAT, hybrid retrieval combining lexical and vector ranking, RAG pipelines, zero-downtime index updates via aliases, and debugging with FT.PROFILE and FT.EXPLAIN. Use when defining a search index on Hash or JSON documents, writing FT.SEARCH queries with filters, sorting, aggregation, or vector KNN, tuning HNSW parameters, building a RAG retrieval pipeline, or troubleshooting slow or empty search results.
    license: MIT
    metadata:
      author: Redis, Inc.
      version: "1.0.0"
    ---
    
    # Redis Search
    
    Single source of guidance for Redis Search — the retrieval surface that spans lexical, numeric, geo, JSON-path, and vector queries. Vector fields are part of the same `FT.CREATE` machinery as TEXT/TAG/NUMERIC fields, and `FT.HYBRID` blends lexical and vector ranking in one command, so this skill covers them together.
    
    ## When to apply
    
    - Creating, modifying, or reviewing a Redis Search index (`FT.CREATE`, `FT.ALTER`).
    - Writing or optimizing `FT.SEARCH`, `FT.AGGREGATE`, or `FT.HYBRID` queries.
    - Picking between `TEXT`, `TAG`, `NUMERIC`, `GEO`, `GEOSHAPE`, `VECTOR`, or JSON-path fields.
    - Defining a `VECTOR` field, choosing HNSW vs FLAT, tuning HNSW parameters.
    - Building a retrieval-augmented generation (RAG) pipeline.
    - Rolling out a new index schema without downtime.
    - Troubleshooting empty results, slow queries, or tokenization issues with `FT.EXPLAIN`, `FT.PROFILE`, `FT.INFO`.
    
    ## 1. Pick the right command
    
    Three query commands. Reach for the narrowest one that fits.
    
    | Command | When to use | Mental model | Minimum Redis |
    |---|---|---|---|
    | **FT.SEARCH** | Document retrieval, ranked or sorted. Best default. | Returns matching docs directly. | 2.0 (module) / 8.0 (built-in) |
    | **FT.AGGREGATE** | Faceting, computed fields, custom output shape, analytics. | Declarative pipeline: `LOAD`, `APPLY`, `GROUPBY`, `REDUCE`, `SORTBY`. | 2.0 / 8.0 |
    | **FT.HYBRID** | Blend lexical (BM25) with vector similarity, with configurable fusion. | Pipeline with explicit `SEARCH` + `VSIM` legs and a `COMBINE` fusion stage. | **8.4.0** |
    
    ```
    # FT.SEARCH — most common
    FT.SEARCH idx:products "@category:{electronics} @price:[100 500]" LIMIT 0 20 RETURN 3 name price category
    
    # FT.AGGREGATE — top categories by avg price
    FT.AGGREGATE idx:products "*" GROUPBY 1 @category REDUCE AVG 1 @price AS avg_price SORTBY 2 @avg_price DESC
    
    # FT.HYBRID (Redis ≥ 8.4) — lexical + vector fusion
    FT.HYBRID idx:docs
      SEARCH "@title:transformers" SCORER BM25 YIELD_SCORE_AS lexscore
      VSIM embedding $vec KNN count 1 K 50 YIELD_SCORE_AS vecscore
      COMBINE RRF 2 CONSTANT 60
      PARAMS 2 vec "..."
      DIALECT 2
    ```
    
    For Redis < 8.4 the lexical+vector blend is approximated with `FT.SEARCH` pre-filter + `=>[KNN ...]`. See [references/command-selection.md](references/command-selection.md) and [references/hybrid-search.md](references/hybrid-search.md).
    
    ## 2. Schema basics — `FT.CREATE`
    
    `FT.CREATE` indexes Hash or JSON documents matching a `PREFIX`. Always set `PREFIX`. Use `DIALECT 2` (the default since Redis 8; required for vector queries).
    
    ```
    FT.CREATE idx:products ON HASH PREFIX 1 product:
        SCHEMA
            name TEXT WEIGHT 2.0
            category TAG SORTABLE
            price NUMERIC SORTABLE
            location GEO
            embedding VECTOR HNSW 6
                TYPE FLOAT32
                DIM 1536
                DISTANCE_METRIC COSINE
    ```
    
    Pick the narrowest field type that supports your access pattern:
    
    | Field type | Use when | Notes |
    |---|---|---|
    | `TEXT` | Full-text search | Tokenized + stemmed; **not** for exact match |
    | `TAG` | Exact match / filtering | Add `SORTABLE UNF` for fastest tag queries |
    | `NUMERIC` | Range queries, sorting | Prices, counts, timestamps |
    | `GEO` | Lat/long points | Stores, users |
    | `GEOSHAPE` | Polygon / area queries | Delivery zones, regions |
    | `VECTOR` | Similarity search | HNSW or FLAT; see §4 |
    | JSON `$.path AS alias` | Nested JSON fields | `ON JSON`; see [references/json-indexing.md](references/json-indexing.md) |
    
    The classic mistake is `TEXT` for a category or status field "because it's a string" — `TAG` is roughly 10× faster for exact-match filtering.
    
    See [references/index-creation.md](references/index-creation.md), [references/field-types.md](references/field-types.md), [references/dialect.md](references/dialect.md), [references/ft-create-options.md](references/ft-create-options.md), [references/json-indexing.md](references/json-indexing.md).
    
    ## 3. Common queries
    
    Narrow with filters; return only what you need.
    
    ```
    # Tag filter + numeric range, sorted by price
    FT.SEARCH idx:products "@category:{electronics} @price:[100 500]"
        SORTBY price ASC
        LIMIT 0 20
        RETURN 3 name price category
    
    # Text + tag filter
    FT.SEARCH idx:products "wireless headphones @category:{audio}"
    
    # Negation and OR
    FT.SEARCH idx:products "@category:{audio} -@brand:{generic} (@price:[0 100] | @on_sale:{true})"
    ```
    
    Operators worth remembering: space = AND, `|` = OR, `-` = NOT, `~` = optional (scoring boost), `=>{$weight: N}` = boost. Escape hyphens and special characters inside TAG values (`@sku:{ABC\\-123}`). See [references/query-syntax.md](references/query-syntax.md) and [references/search-syntax-primitives.md](references/search-syntax-primitives.md) for the DSL vocabulary.
    
    For tokenization gotchas (stemming, stopwords, language) see [references/text-tokenization.md](references/text-tokenization.md). For result shaping (`SORTBY`, `RETURN`, `HIGHLIGHT`, `SUMMARIZE`, `NOCONTENT`) see [references/result-shaping.md](references/result-shaping.md). For performance levers (pre-filters, `SORTABLE` fields, tight `RETURN`, `FT.PROFILE`) see [references/query-optimization.md](references/query-optimization.md).
    
    ## 4. Vector basics
    
    Three vector settings have to match the embedding model exactly:
    
    - **`DIM`** — output dimensionality (e.g. 1536 for OpenAI `text-embedding-3-small`). Mismatch produces silent garbage.
    - **`DISTANCE_METRIC`** — `COSINE` for normalized text embeddings (common case), `IP` for unnormalized inner-product, `L2` for raw Euclidean.
    - **`TYPE`** — usually `FLOAT32`. Use `FLOAT16` or quantized variants only when memory is the binding constraint.
    
    ```
    # Index
    FT.CREATE idx:docs ON HASH PREFIX 1 doc:
        SCHEMA
            content TEXT
            embedding VECTOR HNSW 6 TYPE FLOAT32 DIM 1536 DISTANCE_METRIC COSINE
    
    # Pure KNN query (top 5 by cosine similarity)
    FT.SEARCH idx:docs "*=>[KNN 5 @embedding $vec AS score]"
        PARAMS 2 vec "..."
        SORTBY score
        DIALECT 2
    ```
    
    | Algorithm | Speed | Accuracy | Memory | Use for |
    |---|---|---|---|---|
    | **HNSW** | Fast (approximate) | ~95%+ recall (tunable) | Higher | Production: >10k vectors, latency-sensitive |
    | **FLAT** | Slow (exact) | 100% | Lower | Small corpora (<10k), exact-match required |
    
    HNSW tuning levers: `M` (16–64, connections per node), `EF_CONSTRUCTION` (100–500, build quality), `EF_RUNTIME` (query-time candidate list).
    
    See [references/vector-query.md](references/vector-query.md), [references/algorithm-choice.md](references/algorithm-choice.md).
    
    ## 5. Hybrid retrieval
    
    Two distinct patterns get called "hybrid." Pick by intent.
    
    **Filter-then-vector** (any Redis version) — apply attribute filters so the engine narrows the search space *before* the vector comparison.
    
    ```
    FT.SEARCH idx:docs "(@category:{tech} @date:[2024 +inf])=>[KNN 10 @embedding $vec AS score]"
        PARAMS 2 vec "..."
        SORTBY score
        DIALECT 2
    ```
    
    **Lexical + vector fusion** (Redis ≥ 8.4) — blend BM25 text scoring with vector similarity, fuse with `RRF` or `LINEAR`. Use `FT.HYBRID` (see §1).
    
    Don't fetch a wide unfiltered result and filter client-side — slower and less accurate. See [references/hybrid-search.md](references/hybrid-search.md).
    
    ## 6. Aggregations and shaping
    
    `FT.AGGREGATE` is the declarative result-shaping command. Build a pipeline of stages.
    
    ```
    # Top 5 categories by total revenue
    FT.AGGREGATE idx:orders "@status:{shipped}"
        LOAD 2 @category @amount
        GROUPBY 1 @category
            REDUCE SUM 1 @amount AS revenue
        SORTBY 2 @revenue DESC
        LIMIT 0 5
    ```
    
    Common stages: `LOAD`, `APPLY` (computed fields), `FILTER` (post-query), `GROUPBY` + `REDUCE` (`SUM`, `COUNT`, `AVG`, `FIRST_VALUE`, `TOLIST`), `SORTBY`, `LIMIT`.
    
    For long-running result sets use `WITHCURSOR` + `FT.CURSOR READ` to page server-side. See [references/aggregate-pipeline.md](references/aggregate-pipeline.md) and [references/aggregate-cursors.md](references/aggregate-cursors.md).
    
    ## 7. RAG pattern
    
    Standard pipeline: embed the query, vector-search Redis, pass top-K context to the LLM.
    
    Practical tips:
    
    - **Match the metric** to the embedding model (almost always `COSINE` for normalized text models).
    - **Chunk long documents** (200–500-token chunks usually beat indexing whole pages).
    - **Batch inserts** rather than one call per record.
    - **Pre-filter with attributes** (tenant, recency, document type) before the vector search — see §5.
    - **Re-rank** at the top of the funnel if precision matters more than recall.
    
    See [references/rag-pattern.md](references/rag-pattern.md).
    
    ## 8. Operations
    
    Zero-downtime schema changes: keep app queries pointed at an alias and swap the underlying index.
    
    ```
    FT.CREATE idx:products_v2 ON HASH PREFIX 1 product: SCHEMA ...
    FT.ALIASUPDATE products idx:products_v2
    # App queries are stable:
    FT.SEARCH products "@category:{electronics}"
    ```
    
    Useful management commands: `FT.INFO`, `FT.DROPINDEX`, `FT._LIST`, `FT.ALIASADD/UPDATE/DEL`. See [references/index-management.md](references/index-management.md).
    
    Debug empty or slow queries with `FT.EXPLAIN` (shows how the query was parsed) and `FT.PROFILE` (shows execution stats). See [references/debugging.md](references/debugging.md).
    
    ## 9. Client examples
    
    Inline examples in this SKILL.md are CLI / RESP form — the wire protocol every client serializes to. For idiomatic snippets in a specific client:
    
    - **redis-py** (Python, raw client): [references/clients/python-redis-py.md](references/clients/python-redis-py.md)
    - **Jedis** (Java): [references/clients/java-jedis.md](references/clients/java-jedis.md)
    - **RedisVL** (Python, higher-level SDK on top of redis-py): [references/clients/python-redisvl.md](references/clients/python-redisvl.md)
    
    Other clients (Lettuce, node-redis, go-redis, NRedisStack, .NET) translate the same CLI form; coverage is tracked as a follow-up.
    
    ## References
    
    - [Redis: Search and query](https://redis.io/docs/latest/develop/interact/search-and-query/)
    - [Redis: Vectors](https://redis.io/docs/latest/develop/ai/search-and-query/vectors/)
    - [Redis: Query syntax](https://redis.io/docs/latest/develop/interact/search-and-query/query/)
    - [Redis: Query dialects](https://redis.io/docs/latest/develop/interact/search-and-query/advanced-concepts/dialects/)
    - [Redis: RAG quickstart](https://redis.io/docs/latest/develop/get-started/rag/)
    - [FT.CREATE](https://redis.io/docs/latest/commands/ft.create/) · [FT.SEARCH](https://redis.io/docs/latest/commands/ft.search/) · [FT.AGGREGATE](https://redis.io/docs/latest/commands/ft.aggregate/) · [FT.HYBRID](https://redis.io/docs/latest/commands/ft.hybrid/)
    - [RedisVL documentation](https://docs.redisvl.com/en/latest/)
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related