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.mdinstead; 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
- Minimum supported versions
- Connection setup
- Schema imports
- Create index — HASH
- Create index — JSON
- FT.SEARCH idioms
- FT.AGGREGATE idioms
- Cursors
- Vector queries
- FT.HYBRID
- Debugging
- Index management
- Common errors & version gotchas
- 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
FLOAT32bytes; withdecode_responses=TrueRedis 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 reducersNotes:
index_definitionis the modern path; older code imports fromindexDefinition(camelCase). Both work in 5.x, but the underscore form is what current upstream doctests use.- The two query builders are different classes:
QueryforFT.SEARCH,AggregateRequestforFT.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.reducersis a module of factory functions (count(),sum(),avg(),tolist()), not a class — that's why upstream imports it withas 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 GEOredis-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_ENDHASH-specific notes:
- Field names in the schema are the hash field names verbatim (no
$.path prefix; noas_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 TAGredis-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_ENDJSON-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", "$", {...})(seehome_json.pySTEP_STARTadd_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-py5.x does not expose standaloneft().cursor_read()orft().cursor_del()methods.FT.CURSOR READis invoked by passing aCursorinstance back toft().aggregate(cursor).FT.CURSOR DELrequires the rawr.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_ENDRead 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_ENDExplicit cleanup (release before MAXIDLE):
# STEP_START aggregate_cursor_del
r.execute_command("FT.CURSOR", "DEL", "idx:bicycle", cursor.cid)
# STEP_ENDSee 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 COSINEredis-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_ENDEncode 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_ENDPre-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_ENDThe 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_ENDAS <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_ENDReturns a HybridResult (or HybridCursorResult when cursor=... is supplied).
Important behaviours
hybrid_searchis decorated@experimental_method(). API may shift; pinredis-pyif you depend on it in production.LOAD-returned field values come back as bytes by default, even withdecode_responses=True, to match the legacy RESP2 HYBRID contract. Opt into decoding per field viaHybridPostProcessingConfig.load("brand", "model", decode_field=True)and pass the config aspost_processing=.CombineResultsMethodkwargs are passed verbatim to the server —WINDOW,CONSTANT,YIELD_SCORE_ASfor RRF;ALPHA,BETA,YIELD_SCORE_ASfor 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.