All skills
redis avatar

/redis-search

@6f59bfc official
by redisredis/agent-skills163 stars
30

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.

Use this Skill: https://skilld.dev/gh/redis/agent-skills/redis-search

This session only. Nothing lands on disk.

referencesclientspython-redis-py.md

≈8.7k tokens on demand. Your agent reads this file only when SKILL.md points to it.

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. Do not duplicate that grammar here.
  • Jedis (Java) equivalents for the same operations: java-jedis.md.
  • RedisVL is a different SDK (schema-first, semantic-cache, message-history). For RedisVL targets, read 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
  2. Connection setup
  3. Schema imports
  4. Create index — HASH
  5. Create index — JSON
  6. FT.SEARCH idioms
  7. FT.AGGREGATE idioms
  8. Cursors
  9. Vector queries
  10. FT.HYBRID
  11. Debugging
  12. Index management
  13. Common errors & version gotchas
  14. Upstream examples index

1. Minimum supported versions

Jedis equivalent: see 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.

The canonical connect, from doctests/search_quickstart.py — STEP_START connect:

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):

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.

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:

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.

CLI form (from 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):

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

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:

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

For the query DSL itself (delimiters, operators, escaping), read ../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`
.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

# 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

# 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

# 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

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

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.

For pipeline-stage ordering rules, see 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"))`
.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

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

For lifecycle rules and when to use cursors, see 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:

# 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():

# 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):

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

For query-attribute syntax (=>[KNN ...], [VECTOR_RANGE ...]) and pre-filter shape, read 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):

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

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

# 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

# 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

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

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.

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

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

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.

For interpreting FT.EXPLAIN and FT.PROFILE output, see debugging.md.

FT.EXPLAIN

# 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

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

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.

For semantics (FT.ALTER capacity, alias use cases), see index-management.md.

Add a field

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)

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

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

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.

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.

Source: SKILL.md on GitHub

No alerts1mo3 checks · Risk SAFE
  • Gen Agent Trust Hub1mo

    The skill provides technical guidance for using Redis Search, including schema design, query syntax, vector similarity, and RAG pipelines. It includes idiomatic code examples for official Python and Java client libraries. All external references are to official Redis documentation and repositories, and no malicious patterns were detected.

  • Socket1mo

    No alerts

  • Snyk1mo

    Risk: LOW · No issues

Signed by skilld at 6f59bfc. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 3 days ago.

Activeupdated 3 months ago
metadata
{
  "author": "Redis, Inc.",
  "version": "1.0.0"
}

README badge

README badge for redis/agent-skills/redis-search