Query Engine: Search & Aggregations

This content was generated with the assistance of AI and should be verified against the official Redis documentation before being relied on in production.

This section’s bibliography lists the reference material consulted while preparing these pages.

Examples on this page target Redis Open Source 8.10 (8.10.2) with the Jedis 8 and Lettuce 7.7 client libraries, and every example runs against the idx:books index defined in Query Engine: Indexing & Data Modeling — this page doesn’t redefine FT.CREATE or the field list, only uses the resulting title, author, genres, price, year, and stock fields. FT.SEARCH finds and ranks individual matching documents; FT.AGGREGATE reshapes the matching set itself — grouping, reducing, and computing over it — and the two share the same underlying query language for their initial filter step.

Query syntax

All examples below use query dialect 2, the default for FT.SEARCH/FT.AGGREGATE since Redis 7.0 (set explicitly with DIALECT 2 if a client or config has changed the default). A bare query without a field modifier searches every TEXT field; @field: scopes a clause to one field.

  • Field modifiers and boolean combinators — terms separated by a space are implicitly ANDed, \| (or the word OR between clauses) is a logical OR, and a leading - negates a clause. Parentheses group clauses. See Redis query syntax.

127.0.0.1:6379> FT.SEARCH idx:books "@title:redis @genres:{technology}"
127.0.0.1:6379> FT.SEARCH idx:books "@author:smith | @author:jones"
127.0.0.1:6379> FT.SEARCH idx:books "vectors -fiction"
  • Exact phrases — double-quoting a run of terms requires them adjacent and in order, rather than merely all present. See Exact match.

127.0.0.1:6379> FT.SEARCH idx:books "@title:\"streams in practice\""
  • Prefix, infix, suffix, and wildcard matching — a trailing matches a prefix, a leading a suffix, and term an infix; WILDCARD fields (or the query-time % fuzzy operator below) go further with full glob patterns. See Query syntax.

127.0.0.1:6379> FT.SEARCH idx:books "@title:vec*"
127.0.0.1:6379> FT.SEARCH idx:books "@title:*practice"
127.0.0.1:6379> FT.SEARCH idx:books "@title:*redis*"
  • Fuzzy matching — wrapping a term in %…​% matches it within Levenshtein distance 1 by default (%%…​%% for distance 2, %%%…​%%% for distance 3), useful for tolerating a typo in a user-typed search box. See Query syntax.

127.0.0.1:6379> FT.SEARCH idx:books "@title:%vektors%"
  • Numeric ranges — @field:[min max] is inclusive on both ends; ( before a bound excludes it, and +inf/ -inf leave a side open. See Numeric range queries.

127.0.0.1:6379> FT.SEARCH idx:books "@price:[10 30]"
127.0.0.1:6379> FT.SEARCH idx:books "@year:[(2020 +inf]"
  • Tag filters — a TAG field is matched with @field:{value}, and \{value1 | value2\} matches any of several exact tags in one clause (the braces are literal query syntax, not an AsciiDoc attribute reference — they need no escaping inside FT.SEARCH itself, only when this page writes them in prose). See Tag fields.

127.0.0.1:6379> FT.SEARCH idx:books "@genres:{technology}"
127.0.0.1:6379> FT.SEARCH idx:books "@genres:{fiction | technology}"
  • Geo filters — @field:[lon lat radius unit] matches documents whose GEO field falls within radius of a point, unit one of m/km/mi/ft. The Bookshop schema indexes each book’s warehouse as the GEO field warehouse_location (see Query Engine: Indexing & Data Modeling), so the query below finds books stocked within 50 km of Madrid. See Geospatial queries.

127.0.0.1:6379> FT.SEARCH idx:books "@warehouse_location:[-3.7038 40.4168 50 km]"
  • SORTBY/LIMIT/RETURN/PARAMS — SORTBY field [ASC|DESC] orders the result set (requires the field to have been indexed with SORTABLE); LIMIT offset count pages through it; RETURN count field [field …​] trims the reply to only the named fields instead of the whole document; PARAMS nargs name value [name value …​] binds $name placeholders in the query string, so a parameterized query text can be prepared once and reused with different values without string concatenation. See FT.SEARCH.

127.0.0.1:6379> FT.SEARCH idx:books "@genres:{technology}" SORTBY price ASC LIMIT 0 10 RETURN 2 title price
127.0.0.1:6379> FT.SEARCH idx:books "@genres:{$g}" PARAMS 2 g technology DIALECT 2

Scoring, explaining, and highlighting

By default FT.SEARCH ranks matches with BM25 (SCORER BM25, the default scorer since Redis 8.0, replacing the older BM25STD/TFIDF split), which favors term rarity and normalizes for document length; passing SCORER TFIDF switches to plain term-frequency/inverse-document-frequency scoring instead, useful mainly for comparing against a system built around the older model. WITHSCORES returns each result’s numeric score, and EXPLAINSCORE (used together with WITHSCORES) additionally breaks down how that score was computed, term by term — handy for understanding why one book outranked another rather than only that it did. See Scoring.

127.0.0.1:6379> FT.SEARCH idx:books "@title:redis" SCORER BM25 WITHSCORES EXPLAINSCORE

HIGHLIGHT FIELDS count field [field …​] [TAGS open close] wraps matched terms in the given tags (default <b>/</b>) within the returned field values, and SUMMARIZE FIELDS count field [field …​] [FRAGS num] [LEN fragsize] [SEPARATOR sep] instead trims a long text field down to the fragments around each match — the two combine for a typical "snippet with bolded terms" search-result listing. See Highlighting.

127.0.0.1:6379> FT.SEARCH idx:books "@description:redis" HIGHLIGHT FIELDS 1 description TAGS "<em>" "</em>" SUMMARIZE FIELDS 1 description FRAGS 2 LEN 20

Stemming and stop words

A TEXT field is stemmed by default (using the index’s configured language, English unless FT.CREATE specified otherwise), so a query for book also matches books, and one for running also matches run — $term in a query can be prefixed with + (e.g. +running) to force an exact, unstemmed match when that matters. A small set of common English words (the, is, at, …​) is dropped from indexing and queries entirely as stop words unless the index was created with a custom STOPWORDS list (or STOPWORDS 0 to disable filtering altogether); this keeps the index smaller and avoids near-universal terms dominating relevance. See Stemming and Stop words.

Synonyms, spellcheck, and autocomplete

FT.SYNUPDATE index synonym-group-id [SKIPINITIALSCAN] term [term …​] adds or updates a named synonym group, so a query for any term in the group also matches documents containing any other term in the same group — useful for treating sci-fi and science fiction as interchangeable in the genres/title text without duplicating data. See FT.SYNUPDATE.

127.0.0.1:6379> FT.SYNUPDATE idx:books scifi-group scifi "sci-fi" "science fiction"
OK

FT.SPELLCHECK index query [DISTANCE dist] [TERMS <INCLUDE|EXCLUDE> dictionary term [term …​]] suggests corrections for terms in query that don’t appear in the index, each with a normalized-score ranking of candidate replacements — built for surfacing "did you mean" suggestions rather than issuing them as an automatic rewrite. See FT.SPELLCHECK.

127.0.0.1:6379> FT.SPELLCHECK idx:books "vecktors"
1) 1) "TERM"
   2) "vecktors"
   3) 1) 1) "0.5"
         2) "vectors"

Autocomplete uses a separate suggestion dictionary rather than the index itself: FT.SUGADD key string score [INCR] [PAYLOAD payload] adds or reweights a suggestion string under a dictionary key, and FT.SUGGET key prefix [FUZZY] [WITHSCORES] [WITHPAYLOADS] [MAX max] returns the highest-scoring suggestions matching a typed prefix, optionally with fuzzy prefix matching for a typo-tolerant search box. See FT.SUGADD and FT.SUGGET.

127.0.0.1:6379> FT.SUGADD bookshop:titlesuggest "The Redis Way" 5
(integer) 1
127.0.0.1:6379> FT.SUGGET bookshop:titlesuggest "red" FUZZY WITHSCORES
1) "The Redis Way"
2) "5"

Inspecting a query: FT.EXPLAIN and FT.PROFILE

FT.EXPLAIN index query [DIALECT dialect] parses query and prints the execution tree the engine built from it, without running it — useful for confirming how a query with mixed AND/OR/negation actually groups before running it against real data. FT.PROFILE index <SEARCH|AGGREGATE> [LIMITED] QUERY query instead executes the query and returns both its normal reply and a breakdown of time spent per execution stage, the tool of choice when a query is slow and it isn’t obvious which clause or iterator is the cost. See FT.EXPLAIN and FT.PROFILE.

127.0.0.1:6379> FT.EXPLAIN idx:books "@genres:{technology} @price:[10 30]"
127.0.0.1:6379> FT.PROFILE idx:books SEARCH QUERY "@title:redis"

Searching from Jedis

package com.example.bookshop;

import redis.clients.jedis.RedisClient;
import redis.clients.jedis.search.Query;
import redis.clients.jedis.search.SearchResult;

public class BookSearchService {

    private final RedisClient jedis;

    public BookSearchService(RedisClient jedis) {
        this.jedis = jedis;
    }

    public SearchResult searchTechnologyBooks(String term) {
        Query query = new Query("@title:" + term + " @genres:{technology}")
                .setSortBy("price", true)
                .limit(0, 10)
                .returnFields("title", "price");
        return jedis.ftSearch("idx:books", query);
    }
}

This derives from FT.SEARCH.

FT.AGGREGATE: reshaping the result set

Where FT.SEARCH returns matching documents, FT.AGGREGATE runs a matching set through a pipeline of stages — loading extra fields, grouping and reducing, computing new values, sorting, and filtering — much like a SQL GROUP BY query, and is the tool for faceted navigation, dashboards, and any "summarize, don’t list" search.

  • FT.AGGREGATE index query [LOAD count field [field …​] \| LOAD *] [GROUPBY nargs property [property …​] REDUCE function nargs arg [arg …​] [AS name] …​] [APPLY expression AS name] [SORTBY nargs property [ASC \| DESC] …​ [MAX num]] [FILTER expression] [LIMIT offset count] [WITHCURSOR [COUNT count] [MAXIDLE ms]] [PARAMS nargs name value …​] [DIALECT dialect] — stages run left to right in the order written, each consuming the previous stage’s output. See FT.AGGREGATE.

LOAD count field [field …​] (or LOAD * for every field) pulls fields from the original document into the pipeline — only fields actually indexed as SORTABLE, or explicitly `LOAD`ed, are available to later stages, since aggregation otherwise only sees what the query already scored, not the full document.

GROUPBY nargs property [property …​] REDUCE function nargs arg [arg …​] [AS name] groups rows by one or more properties and computes one or more reducers per group:

Reducer Computes

COUNT

Number of rows in the group.

SUM field

Sum of field across the group.

AVG field

Mean of field across the group.

MIN field / MAX field

Smallest / largest value of field in the group.

COUNT_DISTINCT field

Exact distinct-value count of field (holds every distinct value seen, so memory grows with cardinality).

COUNT_DISTINCTISH field

Approximate distinct-value count via HyperLogLog — bounded memory, small error, for high-cardinality fields.

QUANTILE field q

Approximate value at quantile q (0-1) of `field’s distribution across the group.

TOLIST field

Every value of field in the group, collected into an array.

FIRST_VALUE field [BY field [ASC | DESC]]

One representative value of field from the group, optionally the one from the row ranked first by a second field.

RANDOM_SAMPLE field count

Up to count values of field, sampled at random from the group.

Run against the three Bookshop books loaded in JSON, after that page’s JSON.MERGE raises "The Redis Way" to 21.99, fiction averages 21.99 and technology averages (34.50 + 29.00) / 2 = 31.75:

127.0.0.1:6379> FT.AGGREGATE idx:books "*" GROUPBY 1 @genres REDUCE COUNT 0 AS book_count REDUCE AVG 1 @price AS avg_price
1) (integer) 2
2) 1) "genres"
   2) "fiction"
   3) "book_count"
   4) "1"
   5) "avg_price"
   6) "21.99"
3) 1) "genres"
   2) "technology"
   3) "book_count"
   4) "2"
   5) "avg_price"
   6) "31.75"

APPLY expression AS name computes a new field from an expression over already-available fields (arithmetic, string, and date functions), and FILTER expression drops rows where a boolean expression over such fields is false — unlike a query clause, FILTER runs after LOAD/GROUPBY/APPLY, so it can filter on a computed or reduced value the original query text has no way to reference:

127.0.0.1:6379> FT.AGGREGATE idx:books "*" GROUPBY 1 @genres REDUCE AVG 1 @price AS avg_price APPLY "avg_price * 1.21" AS avg_price_with_tax FILTER "@avg_price_with_tax > 15"

SORTBY nargs property [ASC \| DESC] …​ [MAX num] orders rows by one or more properties, and MAX caps how many sorted rows are kept — a cheaper way to get a top-N than sorting the full set and then LIMIT`ing it, since the engine only has to track the top `num rows as it scans. LIMIT offset count pages through the final result the ordinary way.

For a result set too large to return in one reply, WITHCURSOR [COUNT count] [MAXIDLE ms] returns the first count rows plus a cursor ID instead of the whole set, and FT.CURSOR READ index cursor-id [COUNT count] fetches the next batch; the cursor is discarded automatically once exhausted, or after MAXIDLE milliseconds of inactivity, or explicitly with FT.CURSOR DEL index cursor-id. See FT.CURSOR READ.

127.0.0.1:6379> FT.AGGREGATE idx:books "*" GROUPBY 1 @genres REDUCE COUNT 0 AS book_count WITHCURSOR COUNT 100
127.0.0.1:6379> FT.CURSOR READ idx:books 342459320

Faceted navigation example

A typical faceted-search sidebar needs two independent facts about the current result set: how many books fall into each genre, and how the average price trends per publication year. Both are single FT.AGGREGATE calls over idx:books, run alongside (not instead of) the FT.SEARCH call that renders the actual result list:

127.0.0.1:6379> FT.AGGREGATE idx:books "*" GROUPBY 1 @genres REDUCE COUNT 0 AS book_count SORTBY 2 @book_count DESC
127.0.0.1:6379> FT.AGGREGATE idx:books "*" GROUPBY 1 @year REDUCE AVG 1 @price AS avg_price REDUCE COUNT 0 AS book_count SORTBY 2 @year ASC

The first powers a "Genre (count)" facet list; the second, a price-trend-by-year chart — both derived from FT.AGGREGATE with no extra round trip to the application tier to compute either.

flowchart LR Q["Query: matching documents"] --> L["LOAD\nextra fields"] L --> G["GROUPBY / REDUCE\nper-group aggregates"] G --> A["APPLY\ncomputed fields"] A --> S["SORTBY\n(MAX N)"] S --> F["FILTER"] F --> LM["LIMIT"] LM --> R["Result rows\n(or WITHCURSOR + FT.CURSOR READ)"]

References