Skip to main content

Aggregation queries

AggregationQuery builds an FT.AGGREGATE call against an index. Unlike FT.SEARCH (which retrieves documents), FT.AGGREGATE runs a pipeline — group rows, reduce them, derive new fields, sort, page — and returns computed rows rather than source documents.

If you've used Python redisvl, this mirrors AggregationQuery over AggregateRequest. Hybrid (text + vector) aggregation is out of scope for this class.

When to use it

Reach for AggregationQuery when you want to answer questions about groups of documents rather than fetch the documents themselves:

  • "How many products per brand?"
  • "Average price by category for items in stock?"
  • "Top 10 brands by revenue this month?"
  • "p95 latency per region?"

The pipeline

A FT.AGGREGATE call is a chain of steps applied in order:

  1. Query string — selects which documents enter the pipeline (FT.SEARCH filter dialect).
  2. GROUPBY + REDUCE — collapses rows into groups, applying reducers like COUNT, SUM, AVG.
  3. APPLY — derives new fields from existing ones (@revenue / @orders AS avg).
  4. FILTER — drops rows that don't satisfy an expression (FT.AGGREGATE expression dialect — @revenue > 1000).
  5. SORTBY — orders rows.
  6. LIMIT — paginates.

AggregationQuery records the steps in the order you call the corresponding methods, so .groupBy().apply() is not the same as .apply().groupBy().

A first query

import {
SearchIndex,
IndexSchema,
AggregationQuery,
Reducers,
} from 'redis-vl';
import { createClient } from 'redis';

const client = createClient();
await client.connect();

const schema = IndexSchema.fromObject({
index: { name: 'orders', prefix: 'order', storageType: 'hash' },
fields: [
{ name: 'brand', type: 'tag' },
{ name: 'category', type: 'tag' },
{ name: 'price', type: 'numeric' },
{ name: 'quantity', type: 'numeric' },
],
});
const index = new SearchIndex(schema, client);

const q = new AggregationQuery()
.groupBy('brand', [
Reducers.count('orders'),
Reducers.sum('price', 'revenue'),
])
.sortBy([{ field: 'revenue', direction: 'DESC' }])
.limit(0, 5);

const { total, results } = await index.aggregate(q);
for (const row of results) {
console.log(row.brand, row.orders, row.revenue);
}

Each row is a Record<string, string | string[]> keyed by the reducer/apply alias (or the GROUPBY field). Most reducers return strings — cast to numbers in user code (Number(row.revenue)) when you need numeric types. Reducers.toList is the exception: it returns string[] for that column.

Filtering rows into the pipeline

The constructor takes the same FilterInput the rest of the query DSL uses — either a raw string or a FilterExpression:

import { AggregationQuery, Reducers, Tag, Num } from 'redis-vl';

const q = new AggregationQuery(
Tag('category').eq('electronics').and(Num('price').gt(0))
).groupBy('brand', Reducers.count('orders'));

This is the FT.SEARCH filter dialect. It's distinct from the post-aggregation .filter() step, which uses the FT.AGGREGATE expression dialect.

Reducers

Reducers is a namespace of factory functions mirroring redis-py's reducer module. All accept an optional final as argument that aliases the reducer's output column.

FactoryRenders toNotes
Reducers.count(as?)COUNTNo property required.
Reducers.countDistinct(p, as?)COUNT_DISTINCTExact distinct count.
Reducers.countDistinctish(p, as?)COUNT_DISTINCTISHHyperLogLog approximation.
Reducers.sum(p, as?)SUM
Reducers.min(p, as?)MIN
Reducers.max(p, as?)MAX
Reducers.avg(p, as?)AVG
Reducers.stddev(p, as?)STDDEV
Reducers.quantile(p, q, as?)QUANTILEq is a number in [0, 1].
Reducers.toList(p, as?)TOLISTAll unique values in the group.
Reducers.firstValue(p, options?)FIRST_VALUEoptions.by orders ties; options.as aliases output.
Reducers.randomSample(p, n, as?)RANDOM_SAMPLEn is a positive integer.
import { Reducers } from 'redis-vl';

new AggregationQuery().groupBy('brand', [
Reducers.count('orders'),
Reducers.avg('price', 'avg_price'),
Reducers.quantile('price', 0.95, 'p95_price'),
Reducers.firstValue('name', {
by: { property: 'price', direction: 'DESC' },
as: 'top_product',
}),
]);

APPLY and FILTER

APPLY derives a new field that downstream steps can refer to. FILTER drops rows; it uses the FT.AGGREGATE expression dialect (@field <op> <value>, not the {}/[] syntax of FT.SEARCH):

new AggregationQuery()
.groupBy('brand', [
Reducers.sum('price', 'revenue'),
Reducers.sum('quantity', 'units'),
])
.apply('@revenue / @units', 'avg_unit_price')
.filter('@avg_unit_price > 50')
.sortBy([{ field: 'avg_unit_price', direction: 'DESC' }])
.limit(0, 10);

Parameterized queries

Use .params() to bind values referenced as $name in the query string:

const q = new AggregationQuery('@brand:{$brandName}')
.params({ brandName: 'acme' })
.dialect(2)
.groupBy('brand', Reducers.count('orders'));

Other knobs

  • .load([...]) — load specific source-document fields into the pipeline as @field (or {identifier, as} for aliasing).
  • .dialect(n) — set DIALECT.
  • .timeout(ms) — server-side query timeout.
  • .verbatim() — disable stemming for the query string.
  • .addScores() — include @__score in each row.

Result shape

const { total, results } = await index.aggregate(q);
// total: number — the row count Redis reports after aggregation
// results: Array<Record<string, string | string[]>> — one entry per emitted row

Most reducer columns are strings. Reducers.toList (TOLIST) is the exception — it returns string[] for that column. If you need numeric types, cast at the call site (Number(row.revenue)). Aggregation reducers preserve numeric precision on the server side; the wire format simply hands them back as strings.

See also