diff --git a/src/index.ts b/src/index.ts index b15103c..34a9b57 100644 --- a/src/index.ts +++ b/src/index.ts @@ -37,6 +37,8 @@ export { CountQuery } from './query/count.js'; export type { CountQueryConfig } from './query/count.js'; export { TextQuery } from './query/text.js'; export type { TextQueryConfig, TextScorer } from './query/text.js'; +export { AggregationQuery, Reducers } from './query/aggregation.js'; +export type { AggregateCommand, SortSpec, LoadField, Reducer } from './query/aggregation.js'; export { Tag, Num, Text, Geo, GeoRadius, Timestamp, FilterExpression } from './query/filter.js'; export type { Inclusive, GeoUnit } from './query/filter.js'; export type { diff --git a/src/indexes/search-index.ts b/src/indexes/search-index.ts index c10a883..cba79cc 100644 --- a/src/indexes/search-index.ts +++ b/src/indexes/search-index.ts @@ -12,6 +12,7 @@ import { RedisVLError, SchemaValidationError } from '../errors.js'; import type { BaseQuery, SearchResult, SearchDocument, QueryOptions } from '../query/base.js'; import { VectorQuery } from '../query/vector.js'; import { VectorRangeQuery } from '../query/range.js'; +import type { AggregationQuery } from '../query/aggregation.js'; import { DISTANCE_NORMALIZERS } from '../utils/distance.js'; import type { VectorFieldAttrs } from '../schema/fields.js'; @@ -655,4 +656,67 @@ export class SearchIndex { ); } } + + /** + * Execute an {@link AggregationQuery} (`FT.AGGREGATE`) against this index. + * + * Returns the raw aggregate result: a `total` row count and a list of + * rows, where each row is a `Record` of field + * name to value. GROUPBY/REDUCE/APPLY aliases appear as keys on each row. + * + * Scalar reducers (`COUNT`, `SUM`, `AVG`, …) yield strings — numeric + * casting is the caller's job. List reducers (`TOLIST`) yield + * `string[]`, preserving the array structure Redis returns on the wire. + * + * @example + * ```typescript + * import { AggregationQuery, Reducers } from 'redis-vl'; + * + * const q = new AggregationQuery('@category:{electronics}') + * .groupBy('@brand', 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.revenue); + * ``` + */ + async aggregate( + query: AggregationQuery + ): Promise<{ total: number; results: Array> }> { + try { + const { query: queryString, options } = query.toCommand(); + const reply = await this.client.ft.aggregate(this.name, queryString, options); + + // node-redis returns each row as a MapReply, which resolves to a + // plain object by default or to a real `Map` when the caller has + // opted into Map type-mapping via `client.withTypeMapping(...)`. + // Handle both shapes. Preserve array values verbatim (TOLIST + // returns string[]); coerce scalar non-strings via String() so + // numeric reducers come back as strings consistent with the + // FT.AGGREGATE wire format. + const results: Array> = reply.results.map((row) => { + const out: Record = {}; + const entries: Iterable<[string, unknown]> = + row instanceof Map + ? (row.entries() as Iterable<[string, unknown]>) + : Object.entries(row as Record); + for (const [k, v] of entries) { + if (Array.isArray(v)) { + out[k] = v.map((item) => (typeof item === 'string' ? item : String(item))); + } else { + out[k] = typeof v === 'string' ? v : String(v); + } + } + return out; + }); + + return { total: reply.total, results }; + } catch (error) { + throw new RedisVLError( + `Failed to execute aggregate query: ${error instanceof Error ? error.message : String(error)}`, + { cause: error instanceof Error ? error : undefined } + ); + } + } } diff --git a/src/query/aggregation.ts b/src/query/aggregation.ts new file mode 100644 index 0000000..63b3dae --- /dev/null +++ b/src/query/aggregation.ts @@ -0,0 +1,505 @@ +/** + * General-purpose aggregation query that builds an `FT.AGGREGATE` call. + * + * Mirrors Python redisvl's `AggregationQuery` — a thin, fluent builder over + * the FT.AGGREGATE pipeline (GROUPBY → REDUCE → APPLY → SORTBY → LIMIT → + * FILTER). Hybrid (text + vector) aggregation is out of scope for this + * class. + * + * @see https://redis.io/docs/latest/commands/ft.aggregate/ + */ + +import type { FtAggregateOptions } from '@redis/search/dist/lib/commands/AGGREGATE.js'; +import { QueryValidationError } from '../errors.js'; +import { renderFilter, type FilterInput } from './base.js'; + +/** Output of {@link AggregationQuery.toCommand}. */ +export interface AggregateCommand { + /** The query string (filter) — the second argument to `ft.aggregate`. */ + query: string; + /** Structured options passed to `client.ft.aggregate(indexName, query, options)`. */ + options: FtAggregateOptions; +} + +/** + * Sort specification entry for {@link AggregationQuery.sortBy}. + * + * Bare strings are treated as ascending sort on that field. To override + * direction use the object form. + */ +export type SortSpec = string | { field: string; direction?: 'ASC' | 'DESC' }; + +/** + * Field reference used with {@link AggregationQuery.load}. + * + * Accepted shapes for string identifiers (and any other field reference the + * builder takes): + * - bare name (e.g. `'brand'`) — auto-prefixed to `@brand` + * - `@name` — passed through as an index field reference + * - `$.path` — passed through as a JSONPath reference + * + * A bare `$name` (no dot) is rejected as a likely typo or stray PARAMS-style + * reference; use `@name` or `$.name` explicitly. + * + * The object form additionally supports `AS` aliasing via `{ identifier, as }`. + */ +export type LoadField = string | { identifier: string; as?: string }; + +/** Concrete REDUCE clause that {@link AggregationQuery.groupBy} accepts. */ +export type Reducer = + | { type: 'COUNT'; as?: string } + | { type: 'COUNT_DISTINCT'; property: string; as?: string } + | { type: 'COUNT_DISTINCTISH'; property: string; as?: string } + | { type: 'SUM'; property: string; as?: string } + | { type: 'MIN'; property: string; as?: string } + | { type: 'MAX'; property: string; as?: string } + | { type: 'AVG'; property: string; as?: string } + | { type: 'STDDEV'; property: string; as?: string } + | { type: 'QUANTILE'; property: string; quantile: number; as?: string } + | { type: 'TOLIST'; property: string; as?: string } + | { + type: 'FIRST_VALUE'; + property: string; + by?: string | { property: string; direction?: 'ASC' | 'DESC' }; + as?: string; + } + | { type: 'RANDOM_SAMPLE'; property: string; sampleSize: number; as?: string }; + +/** + * Factory namespace mirroring Python `redis.commands.search.reducers`. + * + * Each factory returns a plain {@link Reducer} object you can pass to + * {@link AggregationQuery.groupBy}. Use the optional second `as` argument to + * give the reducer's output column a name. + * + * @example + * ```typescript + * import { AggregationQuery, Reducers } from 'redis-vl'; + * + * const q = new AggregationQuery('@category:{electronics}') + * .groupBy('@brand', [ + * Reducers.count('total'), + * Reducers.avg('price', 'avg_price'), + * ]); + * ``` + */ +export const Reducers = { + count(as?: string): Reducer { + return { type: 'COUNT', as }; + }, + countDistinct(property: string, as?: string): Reducer { + return { type: 'COUNT_DISTINCT', property, as }; + }, + countDistinctish(property: string, as?: string): Reducer { + return { type: 'COUNT_DISTINCTISH', property, as }; + }, + sum(property: string, as?: string): Reducer { + return { type: 'SUM', property, as }; + }, + min(property: string, as?: string): Reducer { + return { type: 'MIN', property, as }; + }, + max(property: string, as?: string): Reducer { + return { type: 'MAX', property, as }; + }, + avg(property: string, as?: string): Reducer { + return { type: 'AVG', property, as }; + }, + stddev(property: string, as?: string): Reducer { + return { type: 'STDDEV', property, as }; + }, + quantile(property: string, quantile: number, as?: string): Reducer { + if (!Number.isFinite(quantile) || quantile < 0 || quantile > 1) { + throw new QueryValidationError('quantile must be in [0, 1]'); + } + return { type: 'QUANTILE', property, quantile, as }; + }, + toList(property: string, as?: string): Reducer { + return { type: 'TOLIST', property, as }; + }, + firstValue( + property: string, + options?: { + by?: string | { property: string; direction?: 'ASC' | 'DESC' }; + as?: string; + } + ): Reducer { + return { type: 'FIRST_VALUE', property, by: options?.by, as: options?.as }; + }, + randomSample(property: string, sampleSize: number, as?: string): Reducer { + if (!Number.isInteger(sampleSize) || sampleSize <= 0) { + throw new QueryValidationError('randomSample sampleSize must be a positive integer'); + } + return { type: 'RANDOM_SAMPLE', property, sampleSize, as }; + }, +} as const; + +interface GroupByStep { + kind: 'GROUPBY'; + properties: string[]; + reducers: Reducer[]; +} +interface ApplyStep { + kind: 'APPLY'; + expression: string; + as: string; +} +interface SortByStep { + kind: 'SORTBY'; + by: SortSpec[]; + max?: number; +} +interface LimitStep { + kind: 'LIMIT'; + offset: number; + count: number; +} +interface FilterStep { + kind: 'FILTER'; + expression: string; +} +type Step = GroupByStep | ApplyStep | SortByStep | LimitStep | FilterStep; + +/** + * Normalize a field reference for FT.AGGREGATE pipelines. + * + * Accepted shapes: + * - bare name (`'brand'`) → `@brand` + * - `@name` → as-is (index field reference) + * - `$.path` → as-is (JSONPath reference) + * + * A bare `$name` (no dot) is rejected: it's either a PARAMS-style reference + * that doesn't belong in a field slot or a typo of `@name`/`$.name`. + */ +function prefixFieldRef(name: string): string { + if (name.startsWith('@')) return name; + if (name.startsWith('$')) { + if (!name.startsWith('$.')) { + throw new QueryValidationError( + `Field reference '${name}' looks like a parameter ref or typo; ` + + `use '@${name.slice(1)}' for an index field or '$.${name.slice(1)}' for a JSONPath` + ); + } + return name; + } + return `@${name}`; +} + +function assertNonEmpty(value: string | undefined, label: string): void { + if (value === undefined || value.trim() === '') { + throw new QueryValidationError(`${label} cannot be empty`); + } +} + +function assertNonNegativeInteger(value: number | undefined, label: string): void { + if (value !== undefined && (!Number.isInteger(value) || value < 0)) { + throw new QueryValidationError(`${label} must be a non-negative integer`); + } +} + +function assertPositiveInteger(value: number | undefined, label: string): void { + if (value !== undefined && (!Number.isInteger(value) || value <= 0)) { + throw new QueryValidationError(`${label} must be a positive integer`); + } +} + +/** + * Fluent builder for `FT.AGGREGATE`. + * + * Steps are recorded in the order you call them — `.groupBy()` then + * `.apply()` is *not* the same as `.apply()` then `.groupBy()`, because each + * stage operates on the output of the previous one. This mirrors how + * Redis itself pipelines an aggregate request. + * + * @example + * ```typescript + * import { AggregationQuery, Reducers, Tag } from 'redis-vl'; + * + * const q = new AggregationQuery(Tag('category').eq('electronics')) + * .groupBy('@brand', [Reducers.sum('price', 'revenue'), Reducers.count('orders')]) + * .apply('@revenue / @orders', 'avg_order_value') + * .sortBy([{ field: 'revenue', direction: 'DESC' }]) + * .limit(0, 10); + * + * const { total, results } = await index.aggregate(q); + * ``` + */ +export class AggregationQuery { + private readonly _query: string; + private readonly steps: Step[] = []; + private _load?: LoadField[]; + private _params?: Record; + private _dialect?: number; + private _timeout?: number; + private _verbatim = false; + private _addScores = false; + + /** + * @param query Either a {@link FilterExpression}, a raw filter string, or + * omitted/`'*'` for no filtering. The same {@link FilterInput} contract + * the rest of the query DSL uses. + */ + constructor(query?: FilterInput) { + this._query = renderFilter(query); + } + + /** + * GROUPBY with one or more reducers. Properties are auto-prefixed with `@`. + * + * Pass an empty array to render `GROUPBY 0` for global reducers that + * aggregate across the whole result set (e.g. average price over every + * matching document, with no grouping field). + */ + groupBy(properties: string | string[], reducers: Reducer | Reducer[] = []): this { + const props = Array.isArray(properties) ? properties : [properties]; + for (const p of props) assertNonEmpty(p, 'groupBy property'); + const reducerList = Array.isArray(reducers) ? reducers : [reducers]; + if (props.length === 0 && reducerList.length === 0) { + throw new QueryValidationError( + 'groupBy with no properties requires at least one reducer' + ); + } + if (props.length > 0 && reducerList.length === 0) { + throw new QueryValidationError('groupBy with properties requires at least one reducer'); + } + this.steps.push({ + kind: 'GROUPBY', + properties: props.map(prefixFieldRef), + reducers: reducerList, + }); + return this; + } + + /** APPLY expression AS alias. Both arguments are required. */ + apply(expression: string, as: string): this { + assertNonEmpty(expression, 'apply expression'); + assertNonEmpty(as, 'apply alias'); + this.steps.push({ kind: 'APPLY', expression, as }); + return this; + } + + /** SORTBY one or more fields. Bare strings sort ASC. */ + sortBy(by: SortSpec | SortSpec[], max?: number): this { + const list = Array.isArray(by) ? by : [by]; + if (list.length === 0) { + throw new QueryValidationError('sortBy requires at least one field'); + } + for (const entry of list) { + const field = typeof entry === 'string' ? entry : entry.field; + assertNonEmpty(field, 'sortBy field'); + if (typeof entry !== 'string' && entry.direction !== undefined) { + if (entry.direction !== 'ASC' && entry.direction !== 'DESC') { + throw new QueryValidationError('sortBy direction must be ASC or DESC'); + } + } + } + assertNonNegativeInteger(max, 'sortBy max'); + this.steps.push({ kind: 'SORTBY', by: list, max }); + return this; + } + + /** + * LIMIT offset, count. + * + * `count = 0` is a valid Redis form that returns only the count/metadata + * with no rows. + */ + limit(offset: number, count: number): this { + assertNonNegativeInteger(offset, 'limit offset'); + assertNonNegativeInteger(count, 'limit count'); + this.steps.push({ kind: 'LIMIT', offset, count }); + return this; + } + + /** + * FILTER applied at this point in the pipeline, using the FT.AGGREGATE + * expression dialect (e.g. `'@revenue > 1000'`). Distinct from the + * constructor's query string, which uses the FT.SEARCH filter dialect. + */ + filter(expression: string): this { + assertNonEmpty(expression, 'filter expression'); + this.steps.push({ kind: 'FILTER', expression }); + return this; + } + + /** + * LOAD specific fields from the source documents. + * + * Each entry can be a bare field name (auto-prefixed with `@`) or an + * `{ identifier, as }` pair for aliasing. + */ + load(fields: LoadField | LoadField[]): this { + const list = Array.isArray(fields) ? fields : [fields]; + for (const f of list) { + if (typeof f === 'string') { + assertNonEmpty(f, 'load field'); + } else { + assertNonEmpty(f.identifier, 'load identifier'); + if (f.as !== undefined) assertNonEmpty(f.as, 'load alias'); + } + } + this._load = [...(this._load ?? []), ...list]; + return this; + } + + /** Bind PARAMS for `$param` references in the query string. */ + params(params: Record): this { + this._params = { ...(this._params ?? {}), ...params }; + return this; + } + + /** Set the DIALECT. Defaults to 2 (required for parameterized `$param` references). */ + dialect(dialect: number): this { + if (!Number.isInteger(dialect) || dialect <= 0) { + throw new QueryValidationError('dialect must be a positive integer'); + } + this._dialect = dialect; + return this; + } + + /** Server-side query TIMEOUT in milliseconds. */ + timeout(ms: number): this { + assertPositiveInteger(ms, 'timeout'); + this._timeout = ms; + return this; + } + + /** Disable stemming for the query string (VERBATIM). */ + verbatim(): this { + this._verbatim = true; + return this; + } + + /** Include the document's `@__score` in the output (ADDSCORES). */ + addScores(): this { + this._addScores = true; + return this; + } + + /** The rendered query string this aggregation will use. */ + get query(): string { + return this._query; + } + + /** Build the structured options for `client.ft.aggregate(indexName, query, options)`. */ + toCommand(): AggregateCommand { + const options: FtAggregateOptions = {}; + + if (this._verbatim) options.VERBATIM = true; + if (this._addScores) options.ADDSCORES = true; + if (this._timeout !== undefined) options.TIMEOUT = this._timeout; + options.DIALECT = this._dialect ?? 2; + // Only set PARAMS when non-empty — node-redis serializes `{}` as + // `PARAMS 0`, which Redis rejects. + if (this._params !== undefined && Object.keys(this._params).length > 0) { + options.PARAMS = this._params; + } + + if (this._load && this._load.length > 0) { + // The Redis client types LOAD entries with template-literal types + // (`@${string}` / `$.${string}`). We've already enforced that + // contract via prefixFieldRef, but TS can't see through the + // string return — cast at the boundary. + options.LOAD = this._load.map((f) => + typeof f === 'string' + ? prefixFieldRef(f) + : f.as !== undefined + ? { identifier: prefixFieldRef(f.identifier), AS: f.as } + : prefixFieldRef(f.identifier) + ) as FtAggregateOptions['LOAD']; + } + + if (this.steps.length > 0) { + options.STEPS = this.steps.map((step) => + this.renderStep(step) + ) as FtAggregateOptions['STEPS']; + } + + return { query: this._query, options }; + } + + private renderStep(step: Step): unknown { + switch (step.kind) { + case 'GROUPBY': { + // node-redis renders `GROUPBY 0` when `properties` is falsy. + // Omit it entirely for empty groups so global reducers work. + const out: { type: 'GROUPBY'; properties?: string[]; REDUCE: unknown[] } = { + type: 'GROUPBY', + REDUCE: step.reducers.map((r) => this.renderReducer(r)), + }; + if (step.properties.length > 0) out.properties = step.properties; + return out; + } + case 'APPLY': + return { type: 'APPLY', expression: step.expression, AS: step.as }; + case 'SORTBY': { + const out: { type: 'SORTBY'; BY: unknown; MAX?: number } = { + type: 'SORTBY', + BY: step.by.map((entry) => { + const field = typeof entry === 'string' ? entry : entry.field; + const ref = prefixFieldRef(field); + const direction = typeof entry === 'string' ? undefined : entry.direction; + return direction ? { BY: ref, DIRECTION: direction } : ref; + }), + }; + if (step.max !== undefined) out.MAX = step.max; + return out; + } + case 'LIMIT': + return { type: 'LIMIT', from: step.offset, size: step.count }; + case 'FILTER': + return { type: 'FILTER', expression: step.expression }; + } + } + + private renderReducer(r: Reducer): unknown { + const base: { type: string; AS?: string } = { type: r.type }; + if (r.as !== undefined) base.AS = r.as; + switch (r.type) { + case 'COUNT': + return base; + case 'COUNT_DISTINCT': + case 'COUNT_DISTINCTISH': + case 'SUM': + case 'MIN': + case 'MAX': + case 'AVG': + case 'STDDEV': + case 'TOLIST': + return { ...base, property: prefixFieldRef(r.property) }; + case 'QUANTILE': + return { + ...base, + property: prefixFieldRef(r.property), + quantile: r.quantile, + }; + case 'FIRST_VALUE': + return { + ...base, + property: prefixFieldRef(r.property), + ...(r.by !== undefined + ? { + BY: + typeof r.by === 'string' + ? prefixFieldRef(r.by) + : { + property: prefixFieldRef(r.by.property), + ...(r.by.direction + ? { direction: r.by.direction } + : {}), + }, + } + : {}), + }; + case 'RANDOM_SAMPLE': + return { + ...base, + property: prefixFieldRef(r.property), + sampleSize: r.sampleSize, + }; + } + } +} + +// Used in JSDoc @example blocks above. +export type { FilterInput }; diff --git a/src/query/count.ts b/src/query/count.ts index c60b44c..580f48a 100644 --- a/src/query/count.ts +++ b/src/query/count.ts @@ -17,7 +17,7 @@ export interface CountQueryConfig { * * @example * ```typescript - * import { CountQuery, Tag } from 'redisvl'; + * import { CountQuery, Tag } from 'redis-vl'; * * const total = (await index.search(new CountQuery({ filter: Tag('brand').eq('nike') }))).total; * ``` diff --git a/src/query/filter-query.ts b/src/query/filter-query.ts index 847c8fc..8314740 100644 --- a/src/query/filter-query.ts +++ b/src/query/filter-query.ts @@ -26,7 +26,7 @@ export interface FilterQueryConfig { * * @example * ```typescript - * import { FilterQuery, Tag, Num } from 'redisvl'; + * import { FilterQuery, Tag, Num } from 'redis-vl'; * * const q = new FilterQuery({ * filter: Tag('brand').eq('nike').and(Num('price').lt(100)), diff --git a/src/query/index.ts b/src/query/index.ts index 8ba8c64..7ee8020 100644 --- a/src/query/index.ts +++ b/src/query/index.ts @@ -10,3 +10,4 @@ export * from './filter-query.js'; export * from './count.js'; export * from './range.js'; export * from './text.js'; +export * from './aggregation.js'; diff --git a/src/query/range.ts b/src/query/range.ts index 14ceddd..8c96193 100644 --- a/src/query/range.ts +++ b/src/query/range.ts @@ -65,7 +65,7 @@ export interface VectorRangeQueryConfig { * * @example * ```typescript - * import { VectorRangeQuery, Tag } from 'redisvl'; + * import { VectorRangeQuery, Tag } from 'redis-vl'; * * const q = new VectorRangeQuery({ * vector: embedding, diff --git a/src/query/text.ts b/src/query/text.ts index bece785..1c4f7b9 100644 --- a/src/query/text.ts +++ b/src/query/text.ts @@ -56,7 +56,7 @@ export interface TextQueryConfig { * * @example * ```typescript - * import { TextQuery, Tag } from 'redisvl'; + * import { TextQuery, Tag } from 'redis-vl'; * * const q = new TextQuery({ * text: 'machine learning', diff --git a/tests/integration/aggregation.test.ts b/tests/integration/aggregation.test.ts new file mode 100644 index 0000000..ec09dfb --- /dev/null +++ b/tests/integration/aggregation.test.ts @@ -0,0 +1,163 @@ +/** + * Integration coverage for AggregationQuery / SearchIndex.aggregate. + * + * Seeds a small product fixture so GROUPBY / SUM / AVG / APPLY / SORTBY / + * FILTER all produce predictable values. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { createClient, type RedisClientType } from 'redis'; +import { IndexSchema } from '../../src/schema/schema.js'; +import { SearchIndex } from '../../src/indexes/search-index.js'; +import { AggregationQuery, Reducers } from '../../src/query/aggregation.js'; +import { Tag } from '../../src/query/filter.js'; + +interface Product extends Record { + id: string; + brand: string; + category: string; + price: number; + quantity: number; +} + +describe('AggregationQuery integration', () => { + let client: RedisClientType; + let index: SearchIndex; + + beforeAll(async () => { + client = createClient({ + url: process.env.REDIS_URL || 'redis://localhost:6379', + }); + await client.connect(); + + const schema = IndexSchema.fromObject({ + index: { + name: 'redisvl-test-aggregate', + prefix: 'rvl-test-agg', + storageType: 'hash', + }, + fields: [ + { name: 'brand', type: 'tag' }, + { name: 'category', type: 'tag' }, + { name: 'price', type: 'numeric' }, + { name: 'quantity', type: 'numeric' }, + ], + }); + + index = new SearchIndex(schema, client); + await index.create({ overwrite: true, drop: true }); + + const products: Product[] = [ + { id: '1', brand: 'acme', category: 'electronics', price: 1200, quantity: 2 }, + { id: '2', brand: 'acme', category: 'electronics', price: 25, quantity: 10 }, + { id: '3', brand: 'omega', category: 'electronics', price: 150, quantity: 4 }, + { id: '4', brand: 'ergo', category: 'furniture', price: 300, quantity: 3 }, + { id: '5', brand: 'ergo', category: 'furniture', price: 500, quantity: 1 }, + { id: '6', brand: 'acme', category: 'electronics', price: 400, quantity: 5 }, + ]; + + await index.load(products, { idField: 'id' }); + await new Promise((r) => setTimeout(r, 100)); + }); + + afterAll(async () => { + await index?.delete({ drop: true }).catch(() => {}); + await client?.quit(); + }); + + it('groups by brand with COUNT and SUM reducers', async () => { + const q = new AggregationQuery() + .groupBy('brand', [Reducers.count('total'), Reducers.sum('price', 'revenue')]) + .sortBy([{ field: 'brand', direction: 'ASC' }]); + + const { total, results } = await index.aggregate(q); + + expect(total).toBeGreaterThan(0); + const byBrand = Object.fromEntries(results.map((r) => [r.brand, r])); + expect(byBrand['acme']).toMatchObject({ total: '3', revenue: '1625' }); + expect(byBrand['omega']).toMatchObject({ total: '1', revenue: '150' }); + expect(byBrand['ergo']).toMatchObject({ total: '2', revenue: '800' }); + }); + + it('honors the constructor query string as a pre-aggregation filter', async () => { + const q = new AggregationQuery(Tag('category').eq('electronics')).groupBy( + 'brand', + Reducers.count('total') + ); + + const { results } = await index.aggregate(q); + const brands = new Set(results.map((r) => r.brand)); + expect(brands.has('acme')).toBe(true); + expect(brands.has('omega')).toBe(true); + expect(brands.has('ergo')).toBe(false); + }); + + it('supports APPLY to derive a field, plus SORTBY + LIMIT', async () => { + const q = new AggregationQuery() + .groupBy('brand', [Reducers.sum('price', 'revenue'), Reducers.sum('quantity', 'units')]) + .apply('@revenue / @units', 'avg_unit_price') + .sortBy([{ field: 'avg_unit_price', direction: 'DESC' }]) + .limit(0, 1); + + const { results } = await index.aggregate(q); + expect(results).toHaveLength(1); + // ergo: revenue=800 / units=4 = 200 — highest avg unit price. + // (acme is 1625/17 ≈ 95.6, omega is 150/4 = 37.5.) + expect(results[0].brand).toBe('ergo'); + expect(Number(results[0].avg_unit_price)).toBe(200); + }); + + it('applies post-aggregation FILTER (FT.AGGREGATE expression dialect)', async () => { + const q = new AggregationQuery() + .groupBy('brand', Reducers.sum('price', 'revenue')) + .filter('@revenue > 200'); + + const { results } = await index.aggregate(q); + const brands = new Set(results.map((r) => r.brand)); + // acme (1625) and ergo (800) pass; omega (150) is filtered out. + expect(brands.has('acme')).toBe(true); + expect(brands.has('ergo')).toBe(true); + expect(brands.has('omega')).toBe(false); + }); + + it('binds PARAMS for parameterized filter strings', async () => { + const q = new AggregationQuery('@brand:{$brandName}') + .params({ brandName: 'omega' }) + .groupBy('brand', Reducers.count('total')); + + const { results } = await index.aggregate(q); + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ brand: 'omega', total: '1' }); + }); + + it('preserves TOLIST array values', async () => { + const q = new AggregationQuery() + .groupBy('category', Reducers.toList('brand', 'brands')) + .sortBy([{ field: 'category', direction: 'ASC' }]); + + const { results } = await index.aggregate(q); + const byCategory = Object.fromEntries(results.map((r) => [r.category as string, r])); + + const electronicsBrands = byCategory['electronics'].brands; + expect(Array.isArray(electronicsBrands)).toBe(true); + // Order isn't guaranteed by Redis — assert as a set. + expect(new Set(electronicsBrands as string[])).toEqual(new Set(['acme', 'omega'])); + + const furnitureBrands = byCategory['furniture'].brands; + expect(Array.isArray(furnitureBrands)).toBe(true); + expect(new Set(furnitureBrands as string[])).toEqual(new Set(['ergo'])); + }); + + it('supports GROUPBY 0 for global reducers (whole-result aggregation)', async () => { + const q = new AggregationQuery().groupBy( + [], + [Reducers.sum('price', 'total_revenue'), Reducers.count('order_count')] + ); + + const { results } = await index.aggregate(q); + expect(results).toHaveLength(1); + // 1200 + 25 + 150 + 300 + 500 + 400 = 2575 across all 6 rows. + expect(Number(results[0].total_revenue)).toBe(2575); + expect(results[0].order_count).toBe('6'); + }); +}); diff --git a/tests/unit/indexes/search-index.test.ts b/tests/unit/indexes/search-index.test.ts index 4e3c845..337d1d2 100644 --- a/tests/unit/indexes/search-index.test.ts +++ b/tests/unit/indexes/search-index.test.ts @@ -7,6 +7,7 @@ import type { RedisClientType } from 'redis'; import { RedisVLError, SchemaValidationError } from '../../../src/errors.js'; import { VectorQuery } from '../../../src/query/vector.js'; import { TextQuery } from '../../../src/query/text.js'; +import { AggregationQuery, Reducers } from '../../../src/query/aggregation.js'; describe('SearchIndex', () => { let schema: IndexSchema; @@ -43,6 +44,7 @@ describe('SearchIndex', () => { dropIndex: vi.fn(), info: vi.fn(), search: vi.fn(), + aggregate: vi.fn(), }, multi: vi.fn().mockReturnValue(mockPipeline), withTypeMapping: vi.fn().mockReturnThis(), @@ -1363,4 +1365,76 @@ describe('SearchIndex', () => { ); }); }); + + describe('aggregate', () => { + type AggregateFunction = RedisClientType['ft']['aggregate']; + type AggregateReply = Awaited>; + + it('preserves array values from TOLIST instead of stringifying them', async () => { + const ftAggregate = mockClient.ft.aggregate as MockedFunction; + ftAggregate.mockResolvedValue({ + total: 1, + results: [ + { + brand: 'acme', + skus: ['a', 'b', 'c'], + revenue: 1625, + }, + ], + } as unknown as AggregateReply); + + const index = new SearchIndex(schema, mockClient); + const { results } = await index.aggregate( + new AggregationQuery().groupBy('brand', [ + Reducers.toList('sku', 'skus'), + Reducers.sum('price', 'revenue'), + ]) + ); + + expect(results).toHaveLength(1); + expect(results[0].brand).toBe('acme'); + expect(results[0].skus).toEqual(['a', 'b', 'c']); + expect(results[0].revenue).toBe('1625'); + }); + + it('handles Map-shaped rows (RESP3 / MAP type-mapping)', async () => { + const ftAggregate = mockClient.ft.aggregate as MockedFunction; + const row = new Map([ + ['brand', 'omega'], + ['skus', ['x', 'y']], + ['total', 4], + ]); + ftAggregate.mockResolvedValue({ + total: 1, + results: [row], + } as unknown as AggregateReply); + + const index = new SearchIndex(schema, mockClient); + const { results } = await index.aggregate( + new AggregationQuery().groupBy('brand', Reducers.toList('sku', 'skus')) + ); + + expect(results[0].skus).toEqual(['x', 'y']); + expect(results[0].total).toBe('4'); + }); + + it('forwards GROUPBY 0 (no properties) to ft.aggregate for global reducers', async () => { + const ftAggregate = mockClient.ft.aggregate as MockedFunction; + ftAggregate.mockResolvedValue({ + total: 1, + results: [{ avg_price: '275' }], + } as unknown as AggregateReply); + + const index = new SearchIndex(schema, mockClient); + const { results } = await index.aggregate( + new AggregationQuery().groupBy([], Reducers.avg('price', 'avg_price')) + ); + + expect(results[0].avg_price).toBe('275'); + const [, , options] = ftAggregate.mock.calls[0]; + const step = (options!.STEPS as unknown as Array>)[0]; + expect('properties' in step).toBe(false); + expect(step.type).toBe('GROUPBY'); + }); + }); }); diff --git a/tests/unit/query/aggregation.test.ts b/tests/unit/query/aggregation.test.ts new file mode 100644 index 0000000..d5946f1 --- /dev/null +++ b/tests/unit/query/aggregation.test.ts @@ -0,0 +1,273 @@ +import { describe, it, expect } from 'vitest'; +import { AggregationQuery, Reducers } from '../../../src/query/aggregation.js'; +import { Tag, Num } from '../../../src/query/filter.js'; +import { QueryValidationError } from '../../../src/errors.js'; + +describe('AggregationQuery', () => { + describe('query string', () => { + it('defaults to wildcard when no filter is supplied', () => { + const q = new AggregationQuery(); + expect(q.toCommand().query).toBe('*'); + }); + + it('accepts a string filter', () => { + const q = new AggregationQuery('@brand:{nike}'); + expect(q.toCommand().query).toBe('@brand:{nike}'); + }); + + it('accepts a FilterExpression', () => { + const q = new AggregationQuery(Tag('brand').eq('nike')); + expect(q.toCommand().query).toBe('@brand:{nike}'); + }); + }); + + describe('groupBy + reducers', () => { + it('renders a single-property GROUPBY with a COUNT reducer', () => { + const q = new AggregationQuery().groupBy('brand', Reducers.count('total')); + const { options } = q.toCommand(); + expect(options.STEPS).toEqual([ + { + type: 'GROUPBY', + properties: ['@brand'], + REDUCE: [{ type: 'COUNT', AS: 'total' }], + }, + ]); + }); + + it('accepts multiple properties and reducers as arrays', () => { + const q = new AggregationQuery().groupBy( + ['brand', 'category'], + [Reducers.sum('price', 'revenue'), Reducers.avg('price', 'avg_price')] + ); + const { options } = q.toCommand(); + expect(options.STEPS).toEqual([ + { + type: 'GROUPBY', + properties: ['@brand', '@category'], + REDUCE: [ + { type: 'SUM', AS: 'revenue', property: '@price' }, + { type: 'AVG', AS: 'avg_price', property: '@price' }, + ], + }, + ]); + }); + + it('preserves explicit @ and $ prefixes on properties', () => { + const q = new AggregationQuery().groupBy(['@brand', '$.category'], Reducers.count()); + const { options } = q.toCommand(); + expect((options.STEPS![0] as { properties: string[] }).properties).toEqual([ + '@brand', + '$.category', + ]); + }); + + it('accepts $.path JSONPath references on groupBy properties', () => { + const q = new AggregationQuery().groupBy('$.category', Reducers.count()); + const { options } = q.toCommand(); + expect((options.STEPS![0] as { properties: string[] }).properties).toEqual([ + '$.category', + ]); + }); + + it('rejects bare $name field references as likely typos', () => { + expect(() => new AggregationQuery().groupBy('$brand', Reducers.count())).toThrow( + QueryValidationError + ); + }); + + it('renders QUANTILE with its quantile arg', () => { + const q = new AggregationQuery().groupBy( + 'brand', + Reducers.quantile('price', 0.95, 'p95') + ); + const reducer = ( + q.toCommand().options.STEPS![0] as unknown as { + REDUCE: Array>; + } + ).REDUCE[0]; + expect(reducer).toEqual({ + type: 'QUANTILE', + AS: 'p95', + property: '@price', + quantile: 0.95, + }); + }); + + it('renders FIRST_VALUE with BY direction', () => { + const q = new AggregationQuery().groupBy( + 'brand', + Reducers.firstValue('name', { + by: { property: 'price', direction: 'DESC' }, + as: 'top', + }) + ); + const reducer = ( + q.toCommand().options.STEPS![0] as unknown as { + REDUCE: Array>; + } + ).REDUCE[0]; + expect(reducer).toMatchObject({ + type: 'FIRST_VALUE', + AS: 'top', + property: '@name', + BY: { property: '@price', direction: 'DESC' }, + }); + }); + + it('renders GROUPBY 0 (omitted properties) for global reducers', () => { + const q = new AggregationQuery().groupBy([], Reducers.avg('price', 'avg_price')); + const step = q.toCommand().options.STEPS![0] as unknown as Record; + // node-redis renders `GROUPBY 0` when `properties` is falsy/missing, + // so the omission of `properties` is what we're asserting here. + expect(step).toEqual({ + type: 'GROUPBY', + REDUCE: [{ type: 'AVG', AS: 'avg_price', property: '@price' }], + }); + expect('properties' in step).toBe(false); + }); + + it('rejects groupBy([]) with no reducers', () => { + expect(() => new AggregationQuery().groupBy([])).toThrow(QueryValidationError); + }); + + it('rejects groupBy(prop) with no reducers', () => { + expect(() => new AggregationQuery().groupBy('brand')).toThrow(QueryValidationError); + }); + + it('rejects QUANTILE outside [0, 1]', () => { + expect(() => Reducers.quantile('price', 1.5)).toThrow(QueryValidationError); + }); + }); + + describe('apply / sortBy / limit / filter', () => { + it('renders APPLY with expression and alias', () => { + const q = new AggregationQuery().apply('@price * @quantity', 'total'); + expect(q.toCommand().options.STEPS).toEqual([ + { type: 'APPLY', expression: '@price * @quantity', AS: 'total' }, + ]); + }); + + it('renders bare-string SORTBY as ascending field reference', () => { + const q = new AggregationQuery().sortBy('revenue'); + expect(q.toCommand().options.STEPS).toEqual([{ type: 'SORTBY', BY: ['@revenue'] }]); + }); + + it('renders directional SORTBY entries', () => { + const q = new AggregationQuery().sortBy( + [ + { field: 'revenue', direction: 'DESC' }, + { field: 'brand', direction: 'ASC' }, + ], + 5 + ); + expect(q.toCommand().options.STEPS).toEqual([ + { + type: 'SORTBY', + BY: [ + { BY: '@revenue', DIRECTION: 'DESC' }, + { BY: '@brand', DIRECTION: 'ASC' }, + ], + MAX: 5, + }, + ]); + }); + + it('renders LIMIT', () => { + const q = new AggregationQuery().limit(20, 10); + expect(q.toCommand().options.STEPS).toEqual([{ type: 'LIMIT', from: 20, size: 10 }]); + }); + + it('renders post-aggregation FILTER (FT.AGGREGATE expression dialect)', () => { + const q = new AggregationQuery().filter('@revenue > 1000'); + expect(q.toCommand().options.STEPS).toEqual([ + { type: 'FILTER', expression: '@revenue > 1000' }, + ]); + }); + + it('rejects negative LIMIT offset', () => { + expect(() => new AggregationQuery().limit(-1, 10)).toThrow(QueryValidationError); + }); + + it('allows LIMIT 0 0 for count-only queries', () => { + const q = new AggregationQuery().limit(0, 0); + expect(q.toCommand().options.STEPS).toEqual([{ type: 'LIMIT', from: 0, size: 0 }]); + }); + + it('allows sortBy max=0 (no row cap)', () => { + const q = new AggregationQuery().sortBy('revenue', 0); + expect(q.toCommand().options.STEPS).toEqual([ + { type: 'SORTBY', BY: ['@revenue'], MAX: 0 }, + ]); + }); + + it('rejects non-ASC/DESC sort directions', () => { + expect(() => + new AggregationQuery().sortBy([{ field: 'x', direction: 'BOGUS' as 'ASC' }]) + ).toThrow(QueryValidationError); + }); + }); + + describe('step ordering', () => { + it('preserves the order in which builder methods were called', () => { + const q = new AggregationQuery() + .groupBy('brand', Reducers.sum('price', 'revenue')) + .apply('@revenue / 100', 'revenue_hundreds') + .filter('@revenue > 0') + .sortBy([{ field: 'revenue', direction: 'DESC' }]) + .limit(0, 5); + const kinds = q.toCommand().options.STEPS!.map((s) => (s as { type: string }).type); + expect(kinds).toEqual(['GROUPBY', 'APPLY', 'FILTER', 'SORTBY', 'LIMIT']); + }); + }); + + describe('LOAD', () => { + it('renders bare LOAD field with @ prefix', () => { + const q = new AggregationQuery().load('title'); + expect(q.toCommand().options.LOAD).toEqual(['@title']); + }); + + it('renders LOAD with identifier + AS', () => { + const q = new AggregationQuery().load({ identifier: 'title', as: 't' }); + expect(q.toCommand().options.LOAD).toEqual([{ identifier: '@title', AS: 't' }]); + }); + + it('appends across multiple load() calls', () => { + const q = new AggregationQuery().load('a').load(['b', 'c']); + expect(q.toCommand().options.LOAD).toEqual(['@a', '@b', '@c']); + }); + }); + + describe('top-level options', () => { + it('threads PARAMS through and lets the query reference $params', () => { + const q = new AggregationQuery(Num('price').gt(0).and(Tag('brand').eq('nike'))) + .params({ minRev: 1000 }) + .filter('@revenue > $minRev'); + const { options } = q.toCommand(); + expect(options.PARAMS).toEqual({ minRev: 1000 }); + }); + + it('threads DIALECT and TIMEOUT and verbatim/addScores flags', () => { + const q = new AggregationQuery().dialect(2).timeout(500).verbatim().addScores(); + const { options } = q.toCommand(); + expect(options.DIALECT).toBe(2); + expect(options.TIMEOUT).toBe(500); + expect(options.VERBATIM).toBe(true); + expect(options.ADDSCORES).toBe(true); + }); + + it('applies only DIALECT default when no options are set', () => { + const { options } = new AggregationQuery().toCommand(); + expect(options).toEqual({ DIALECT: 2 }); + }); + + it('defaults DIALECT to 2 when not set', () => { + const { options } = new AggregationQuery().toCommand(); + expect(options.DIALECT).toBe(2); + }); + + it('lets an explicit .dialect() override the default', () => { + const { options } = new AggregationQuery().dialect(3).toCommand(); + expect(options.DIALECT).toBe(3); + }); + }); +}); diff --git a/website/docs/user-guide/aggregation.md b/website/docs/user-guide/aggregation.md new file mode 100644 index 0000000..3d9b384 --- /dev/null +++ b/website/docs/user-guide/aggregation.md @@ -0,0 +1,169 @@ +--- +sidebar_position: 6 +--- + +# Aggregation queries + +`AggregationQuery` builds an [`FT.AGGREGATE`](https://redis.io/docs/latest/commands/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 + +```typescript +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` 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`: + +```typescript +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](https://redis.io/docs/latest/commands/ft.aggregate/#group-by-reducers). All accept an optional final `as` argument that aliases the reducer's output column. + +| Factory | Renders to | Notes | +| ---------------------------------- | ----------------- | ---------------------------------- | +| `Reducers.count(as?)` | `COUNT` | No property required. | +| `Reducers.countDistinct(p, as?)` | `COUNT_DISTINCT` | Exact distinct count. | +| `Reducers.countDistinctish(p, as?)`| `COUNT_DISTINCTISH` | HyperLogLog 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?)` | `QUANTILE` | `q` is a number in `[0, 1]`. | +| `Reducers.toList(p, as?)` | `TOLIST` | All unique values in the group. | +| `Reducers.firstValue(p, options?)` | `FIRST_VALUE` | `options.by` orders ties; `options.as` aliases output. | +| `Reducers.randomSample(p, n, as?)` | `RANDOM_SAMPLE` | `n` is a positive integer. | + +```typescript +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 `, not the `{}`/`[]` syntax of FT.SEARCH): + +```typescript +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: + +```typescript +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 + +```typescript +const { total, results } = await index.aggregate(q); +// total: number — the row count Redis reports after aggregation +// results: Array> — 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 + +- [Filters and queries](./filters-and-queries.md) — building the filter passed to the constructor. +- [`FT.AGGREGATE` reference](https://redis.io/docs/latest/commands/ft.aggregate/) — full command syntax. diff --git a/website/docs/user-guide/filters-and-queries.md b/website/docs/user-guide/filters-and-queries.md index 99a817c..84edc19 100644 --- a/website/docs/user-guide/filters-and-queries.md +++ b/website/docs/user-guide/filters-and-queries.md @@ -13,7 +13,7 @@ Together, they cover the cases where pure KNN vector search isn't what you want The DSL builds typed, escaped Redis Search filter expressions through method chaining. Every operator returns a `FilterExpression`, which can be passed directly to a query or composed further with `.and()` / `.or()`. ```typescript -import { Tag, Num, Text, Geo, GeoRadius, Timestamp } from 'redisvl'; +import { Tag, Num, Text, Geo, GeoRadius, Timestamp } from 'redis-vl'; const filter = Tag('brand') .eq('nike') @@ -75,7 +75,7 @@ Text('description').isMissing(); ```typescript -import { Geo, GeoRadius } from 'redisvl'; +import { Geo, GeoRadius } from 'redis-vl'; const sf = new GeoRadius(-122.4194, 37.7749, 5, 'km'); // lon, lat, radius, unit @@ -89,7 +89,7 @@ Units: `'m'`, `'km'`, `'mi'`, `'ft'`. ```typescript -import { Timestamp } from 'redisvl'; +import { Timestamp } from 'redis-vl'; // Accepts Date, ISO string, or Unix seconds (number) Timestamp('created_at').eq(new Date('2024-01-15T12:00:00Z')); @@ -112,7 +112,7 @@ Timestamp('created_at').between(new Date('2024-01-01'), new Date('2024-12-31')); Every operator returns a `FilterExpression`. Combine expressions with `.and()` and `.or()` — they're regular methods, evaluated in chain order: ```typescript -import { Tag, Num } from 'redisvl'; +import { Tag, Num } from 'redis-vl'; Tag('brand') .eq('nike') @@ -140,7 +140,7 @@ All four query types are passed to `index.search()` and return a `SearchResult