Skip to content

Commit d9baf56

Browse files
committed
feat(query): add aggregation and hybrid search queries
Implement aggregation-based queries with full feature parity to Python redisvl: - Add HybridQuery for text + vector hybrid search with weighted scoring - Add AggregationQuery base class for aggregation operations - Add FilterQuery for metadata-only filtering - Add ReducerFunction enum for all aggregation reducers - Add TokenEscaper utility for Redis search token escaping - Update SearchIndex to support new query types - Add 7 comprehensive integration tests for HybridQuery - Add SpotBugs exclusions for intentional builder patterns HybridQuery combines text and vector search using Redis aggregation, scoring documents as: hybrid_score = alpha * vector_score + (1-alpha) * text_score Includes support for: - Custom stopwords with language fallbacks - Filter expressions (tag, numeric, geo, text) - Configurable alpha weighting - Distance threshold tuning - Return field selection All 312+ tests passing with no regressions.
1 parent 7c8bb5d commit d9baf56

File tree

10 files changed

+1728
-0
lines changed

10 files changed

+1728
-0
lines changed

core/src/main/java/com/redis/vl/index/SearchIndex.java

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1261,6 +1261,28 @@ public List<Map<String, Object>> query(Object query) {
12611261
} else if (query instanceof TextQuery tq) {
12621262
SearchResult result = search(tq.toString());
12631263
return processSearchResult(result);
1264+
} else if (query instanceof FilterQuery fq) {
1265+
// FilterQuery: metadata-only query without vector search
1266+
// Python: FilterQuery (redisvl/query/query.py:314)
1267+
redis.clients.jedis.search.Query redisQuery = fq.buildRedisQuery();
1268+
UnifiedJedis jedis = getUnifiedJedis();
1269+
SearchResult result = jedis.ftSearch(schema.getName(), redisQuery);
1270+
return processSearchResult(result);
1271+
} else if (query instanceof AggregationQuery aq) {
1272+
// AggregationQuery: HybridQuery and other aggregation-based queries
1273+
// Python: HybridQuery (redisvl/query/aggregate.py:23)
1274+
redis.clients.jedis.search.aggr.AggregationBuilder aggregation = aq.buildRedisAggregation();
1275+
UnifiedJedis jedis = getUnifiedJedis();
1276+
1277+
// Add parameters if present (e.g., vector parameter for HybridQuery)
1278+
Map<String, Object> params = aq.getParams();
1279+
if (params != null && !params.isEmpty()) {
1280+
aggregation.params(params);
1281+
}
1282+
1283+
redis.clients.jedis.search.aggr.AggregationResult result =
1284+
jedis.ftAggregate(schema.getName(), aggregation);
1285+
return processAggregationResult(result);
12641286
}
12651287

12661288
// Default: try to convert to string and search
@@ -1323,6 +1345,28 @@ private List<Map<String, Object>> processSearchResult(SearchResult result) {
13231345
return processed;
13241346
}
13251347

1348+
/**
1349+
* Process AggregationResult into List of Maps.
1350+
*
1351+
* <p>Converts Redis aggregation results into a list of maps, where each map represents a row from
1352+
* the aggregation result.
1353+
*
1354+
* @param result the AggregationResult from Redis
1355+
* @return list of maps containing aggregation results
1356+
*/
1357+
private List<Map<String, Object>> processAggregationResult(
1358+
redis.clients.jedis.search.aggr.AggregationResult result) {
1359+
List<Map<String, Object>> processed = new ArrayList<>();
1360+
if (result != null && result.getResults() != null) {
1361+
for (Map<String, Object> row : result.getResults()) {
1362+
// Each row is already a Map<String, Object>
1363+
// Just add it to the processed list
1364+
processed.add(new HashMap<>(row));
1365+
}
1366+
}
1367+
return processed;
1368+
}
1369+
13261370
public List<SearchResult> batchSearch(List<String> queries) {
13271371
return batchSearch(queries, Integer.MAX_VALUE);
13281372
}
@@ -1487,6 +1531,9 @@ private String buildQueryString(Object query) {
14871531
return vq.toQueryString();
14881532
} else if (query instanceof Filter) {
14891533
return ((Filter) query).build();
1534+
} else if (query instanceof FilterQuery fq) {
1535+
// FilterQuery: extract filter expression or use "*"
1536+
return (fq.getFilterExpression() != null) ? fq.getFilterExpression().build() : "*";
14901537
} else if (query instanceof TextQuery) {
14911538
return query.toString();
14921539
} else {
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package com.redis.vl.query;
2+
3+
import java.util.Map;
4+
import redis.clients.jedis.search.aggr.AggregationBuilder;
5+
6+
/**
7+
* Base class for aggregation queries used to create aggregation queries for Redis.
8+
*
9+
* <p>Ported from Python: redisvl/query/aggregate.py:14
10+
*
11+
* <p>Python equivalent:
12+
*
13+
* <pre>
14+
* class AggregationQuery(AggregateRequest):
15+
* """
16+
* Base class for aggregation queries used to create aggregation queries for Redis.
17+
* """
18+
*
19+
* def __init__(self, query_string):
20+
* super().__init__(query_string)
21+
* </pre>
22+
*
23+
* <p>This is a base class for queries that use Redis aggregation capabilities. Aggregation queries
24+
* can perform complex data analysis operations like grouping, filtering, sorting, and applying
25+
* reducer functions.
26+
*
27+
* @see HybridQuery
28+
* @since 0.1.0
29+
*/
30+
public abstract class AggregationQuery {
31+
32+
/**
33+
* Build the Redis AggregationBuilder for this query.
34+
*
35+
* @return the Jedis AggregationBuilder configured for this query
36+
*/
37+
public abstract AggregationBuilder buildRedisAggregation();
38+
39+
/**
40+
* Build the base query string for the aggregation.
41+
*
42+
* @return the query string
43+
*/
44+
public abstract String buildQueryString();
45+
46+
/**
47+
* Get the parameters for the aggregation query.
48+
*
49+
* <p>Used for parameterized queries (e.g., vector parameter in HybridQuery).
50+
*
51+
* @return a map of parameter names to values
52+
*/
53+
public abstract Map<String, Object> getParams();
54+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package com.redis.vl.query;
2+
3+
import java.util.List;
4+
import java.util.Map;
5+
import lombok.Builder;
6+
import lombok.Getter;
7+
import redis.clients.jedis.search.Query;
8+
9+
/**
10+
* A query for running a filtered search with a filter expression (no vector search).
11+
*
12+
* <p>Ported from Python: redisvl/query/query.py:314 (FilterQuery class)
13+
*
14+
* <p>Python equivalent:
15+
*
16+
* <pre>
17+
* FilterQuery(
18+
* filter_expression=Tag("credit_score") == "high",
19+
* return_fields=["user", "age"],
20+
* num_results=10,
21+
* sort_by="age"
22+
* )
23+
* </pre>
24+
*
25+
* Java equivalent:
26+
*
27+
* <pre>
28+
* FilterQuery.builder()
29+
* .filterExpression(Filter.tag("credit_score", "high"))
30+
* .returnFields(List.of("user", "age"))
31+
* .numResults(10)
32+
* .sortBy("age")
33+
* .build()
34+
* </pre>
35+
*/
36+
@Getter
37+
@Builder
38+
public class FilterQuery {
39+
40+
/**
41+
* The filter expression to query with. Python: filter_expression (Optional[Union[str,
42+
* FilterExpression]]) Defaults to '*' if null.
43+
*/
44+
private final Filter filterExpression;
45+
46+
/** The fields to return in results. Python: return_fields (Optional[List[str]]) */
47+
@Builder.Default private final List<String> returnFields = List.of();
48+
49+
/** The number of results to return. Python: num_results (int) - defaults to 10 */
50+
@Builder.Default private final int numResults = 10;
51+
52+
/** The query dialect (RediSearch version). Python: dialect (int) - defaults to 2 */
53+
@Builder.Default private final int dialect = 2;
54+
55+
/**
56+
* Field to sort results by. Python: sort_by (Optional[SortSpec]) - can be str, Tuple[str, str],
57+
* or List Note: Redis Search only supports single-field sorting, so only first field is used.
58+
* Defaults to ascending order.
59+
*/
60+
private final String sortBy;
61+
62+
/**
63+
* Whether to require terms in field to have same order as in query filter. Python: in_order
64+
* (bool) - defaults to False
65+
*/
66+
@Builder.Default private final boolean inOrder = false;
67+
68+
/** Additional parameters for the query. Python: params (Optional[Dict[str, Any]]) */
69+
@Builder.Default private final Map<String, Object> params = Map.of();
70+
71+
/**
72+
* Build Redis Query object from FilterQuery.
73+
*
74+
* <p>Python equivalent: _build_query_string() method (line 368-372) Returns the filter expression
75+
* string or '*' if no filter.
76+
*
77+
* @return Jedis Query object
78+
*/
79+
public Query buildRedisQuery() {
80+
// Python: if isinstance(self._filter_expression, FilterExpression):
81+
// return str(self._filter_expression)
82+
// return self._filter_expression
83+
String filterStr = (filterExpression != null) ? filterExpression.build() : "*";
84+
85+
// Python: super().__init__("*")
86+
// Python: self.paging(0, self._num_results).dialect(dialect)
87+
Query query = new Query(filterStr).limit(0, numResults).dialect(dialect);
88+
89+
// Python: if return_fields: self.return_fields(*return_fields)
90+
if (!returnFields.isEmpty()) {
91+
query.returnFields(returnFields.toArray(String[]::new));
92+
}
93+
94+
// Python: if sort_by: self.sort_by(sort_by)
95+
// Note: Python accepts tuple for DESC, but here we default to ASC
96+
if (sortBy != null && !sortBy.isEmpty()) {
97+
query.setSortBy(sortBy, true); // true = ascending
98+
}
99+
100+
// Python: if in_order: self.in_order()
101+
if (inOrder) {
102+
query.setInOrder();
103+
}
104+
105+
return query;
106+
}
107+
}

0 commit comments

Comments
 (0)