← Files RedisARCHIVED FILE

skills/redis-search/references/clients/python-redis-py.md

33.8 KB · Oct 2, 2026 · 00:19 UTC

↓ Download file

# 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.

SHA-256: accec61b0de62934cbbf2307c5be58491751928913d76bd92c1ce913a9d7b930