Grouping Information in Results
This document describes what grouping is and gives a few examples on how to use it. Refer to the Query API reference for how to set the select parameter, and the Grouping reference for details.
Vespa grouping has a flexible language in
which to describe how the query hits should be grouped, aggregated and presented in results.
The grouping parameter can be thought of as a high-level program.
The core operations are all
(process a list of elements as a whole),
each
(process each element in a list separately),
group
(group the elements of a list into sub-lists) and
output
(output result).
The operations can be nested.
Vespa distributes and executes the grouping program on content nodes, and merges results on container nodes - in multiple phases, as needed. As realizing such programs over a distributed data set requires more network roundtrips than a regular search query, these queries are more expensive than regular queries. However, executing such programs are more efficient than achieving the same end result by issuing multiple independent queries to Vespa.
Grouping supports continuation objects that is used for pagination.
For the entirety of this document, assume an index of engine part purchases:
Date | Price | Tax | Item | Customer |
---|---|---|---|---|
2006-09-06 09:00:00 | $1 000 | 0.24 | Intake valve | Smith |
2006-09-07 10:00:00 | $1 000 | 0.12 | Rocker arm | Smith |
2006-09-07 11:00:00 | $2 000 | 0.24 | Spring | Smith |
2006-09-08 12:00:00 | $3 000 | 0.12 | Valve cover | Jones |
2006-09-08 10:00:00 | $5 000 | 0.24 | Intake port | Jones |
2006-09-08 11:00:00 | $8 000 | 0.12 | Head | Brown |
2006-09-09 12:00:00 | $1 300 | 0.24 | Coolant | Smith |
2006-09-09 10:00:00 | $2 100 | 0.12 | Engine block | Jones |
2006-09-09 11:00:00 | $3 400 | 0.24 | Oil pan | Brown |
2006-09-09 12:00:00 | $5 500 | 0.12 | Oil sump | Smith |
2006-09-10 10:00:00 | $8 900 | 0.24 | Camshaft | Jones |
2006-09-10 11:00:00 | $1 440 | 0.12 | Exhaust valve | Brown |
2006-09-10 12:00:00 | $2 330 | 0.24 | Rocker arm | Brown |
2006-09-10 10:00:00 | $3 770 | 0.12 | Spring | Brown |
2006-09-10 11:00:00 | $6 100 | 0.24 | Spark plug | Smith |
2006-09-11 12:00:00 | $9 870 | 0.12 | Exhaust port | Jones |
2006-09-11 10:00:00 | $1 597 | 0.24 | Piston | Brown |
2006-09-11 11:00:00 | $2 584 | 0.12 | Connection rod | Smith |
2006-09-11 12:00:00 | $4 181 | 0.24 | Rod bearing | Jones |
2006-09-11 13:00:00 | $6 765 | 0.12 | Crankshaft | Jones |
Basic Grouping
Example: Return the total sum of purchases per customer - steps:
-
Select all documents, query by document type purchase:
/search/?yql=select * from sources * where sddocname contains "purchase"
-
Specify the grouping expression:
group(customer)
-
Specify output - return the sum of the purchase price for each customer.
each
refers to each of the groups produced bygroup(customer)
:each(output(sum(price)))
-
Set
hits=0
to reduce latency and data in result set - the documents need not be returned - final query:/search/?hits=0&yql=select * from sources * where sddocname contains "purchase" | all( group(customer) each(output(sum(price))) );
URL encoded equivalent:
/search/?hits=0& yql=select%20%2A%20from%20sources%20%2A%20where%20sddocname%20contains%20%27purchase%27%20%7C%20 all(group(customer)%20each(output(sum(price))))%3BResult:
GroupId | Sum(price) |
---|---|
Brown | $20 537 |
Jones | $39 816 |
Smith | $19 484 |
Example: Sum price of purchases per date:
select (…) | all(group(time.date(date)) each(output(sum(price))));
GroupId | Sum(price) |
---|---|
2006-09-06 | $1 000 |
2006-09-07 | $3 000 |
2006-09-08 | $16 000 |
2006-09-09 | $12 300 |
2006-09-10 | $22 540 |
2006-09-11 | $24 997 |
Note: in examples above, all documents are evaluated. Modify the query to add filters (and hence cut query latency), like (remember to URL encode):
/search/?yql=select * from sources * where customer contains "smith";
Ordering and Limiting Groups
In many scenarios, a large collection of groups is produced, possibly too large to display or process. This is handled by ordering groups, then limiting, the number of groups to return.
The order
clause accepts a list of one or more expressions.
Each of the arguments to order
is prefixed by either a plus/minus for ascending/descending order.
Limit the number of groups using max
and precision
-
the latter is the number of groups returned per content node to be merged to the global result.
Larger document distribution skews hence require a higher precision
for accurate results.
Example: To find the 2 globally best groups, make an educated guess on how
many samples are needed to fetch from each node in order to get the right groups.
This is the precision
.
An initial factor of 3 has proven to be quite good in most usecases.
If however the data for customer 'Jones' was spread on 3 different content nodes.
'Jones' might be among the 2 best on only one node. But based on the distribution
of the data we have concluded by earlier tests that if we fetch 5.67 as many groups
as we need we will have a correct answer with at least 99.999% confidence. So then
we just use 6 times as much groups when doing the merge.
However there is one exception.
Without an order
constraint, precision
is not required.
Then local ordering will be the same as global ordering.
Ordering will not change after a merge operation.
Example: The two customers with most purchases, returning the sum for each:
select (…) | all(group(customer) max(2) precision(12) order(-count()) each(output(sum(price))));
GroupId | sum(price) |
---|---|
Jones | $39 816 |
Smith | $19 484 |
Hits per Group
Use summary
to print the fields for a hit,
and max
to limit the number of hits per group.
Example: Return the three most expensive parts per customer:
/search/?yql=select * from sources * where sddocname contains "purchase" | all(group(customer) each(max(3) each(output(summary())))); &ranking=pricerank
GroupId | |||||
---|---|---|---|---|---|
Brown | |||||
Date | Price | Tax | Item | Customer | |
2006-09-08 11:00 | $8 000 | 0.12 | Head | Brown | |
2006-09-10 10:00 | $3 770 | 0.12 | Spring | Brown | |
2006-09-09 11:00 | $3 400 | 0.24 | Oil pan | Brown | |
Jones | |||||
Date | Price | Tax | Item | Customer | |
2006-09-11 12:00 | $9 870 | 0.12 | Exhaust port | Jones | |
2006-09-10 10:00 | $8 900 | 0.24 | Camshaft | Jones | |
2006-09-11 13:00 | $6 765 | 0.12 | Crankshaft | Jones | |
Smith | |||||
Date | Price | Tax | Item | Customer | |
2006-09-10 11:00 | $6 100 | 0.24 | Spark plug | Smith | |
2006-09-09 12:00 | $5 500 | 0.12 | Oil sump | Smith | |
2006-09-11 11:00 | $2 584 | 0.12 | Connection rod | Smith |
-
The
order
clause is a directive for group ordering, not hit ordering. Here, there is no order clause on the groups, hence default orderingmax(relevance())
is used. In this case, the query is "all documents", hence all groups are equally relevant and the group order is random. -
To order hits inside groups, use ranking. Add
ranking=pricerank
to the query to use the pricerank rank profile to rank by price:rank-profile pricerank inherits default { first-phase { expression: attribute(price) } }
Nested Groups
Groups can be nested. This offers great drilling capabilities, as there are no limits to nesting depth or presented information on any level. Example: How much each customer has spent per day by grouping on customer, then date:
select (…) | all(group(customer) each(group(time.date(date)) each(output(sum(price)))));
GroupId | ||
---|---|---|
Brown | ||
GroupId | Sum(price) | |
2006-09-08 | $8 000 | |
2006-09-09 | $3 400 | |
2006-09-10 | $7 540 | |
2006-09-11 | $1 597 | |
Jones | ||
GroupId | Sum(price) | |
2006-09-08 | $8 000 | |
2006-09-09 | $2 100 | |
2006-09-10 | $8 900 | |
2006-09-11 | $20 816 | |
Smith | ||
GroupId | Sum(price) | |
2006-09-06 | $1 000 | |
2006-09-07 | $3 000 | |
2006-09-09 | $6 800 | |
2006-09-10 | $6 100 | |
2006-09-11 | $2 584 |
Use this to query for all items on a per-customer basis, displaying the most expensive hit for each customer,
with subgroups of purchases on a per-date basis.
Use the summary
clause
to show hits inside any group at any nesting level.
Include the sum price for each customer, both as a grand total and broken down on a per-day basis:
/search/?yql=select * from sources * where sddocname contains "purchase" | all(group(customer) each(max(1) output(sum(price)) each(output(summary()))) each(group(time.date(date)) each(max(10) output(sum(price)) each(output(summary()))))); &ranking=pricerank
GroupId | sum(price) | |||||
---|---|---|---|---|---|---|
Brown | $20 537 | |||||
Date | Price | Tax | Item | Customer | ||
2006-09-08 11:00 | $8 000 | 0.12 | Head | Brown | ||
GroupId | Sum(price) | |||||
2006-09-08 | $8 000 | |||||
Date | Price | Tax | Item | Customer | ||
2006-09-08 11:00 | $8 000 | 0.12 | Head | Brown | ||
2006-09-09 | $3 400 | |||||
Date | Price | Tax | Item | Customer | ||
2006-09-09 11:00 | $3 400 | 0.12 | Oil pan | Brown | ||
2006-09-10 | $7 540 | |||||
Date | Price | Tax | Item | Customer | ||
2006-09-10 10:00 | $3 770 | 0.12 | Spring | Brown | ||
2006-09-10 12:00 | $2 330 | 0.24 | Rocker arm | Brown | ||
2006-09-10 11:00 | $1 440 | 0.12 | Exhaust valve | Brown | ||
2006-09-11 | $1 597 | |||||
Date | Price | Tax | Item | Customer | ||
2006-09-11 10:00 | $1 597 | 0.24 | Piston | Brown | ||
Jones | $39 816 | |||||
Date | Price | Tax | Item | Customer | ||
2006-09-11 12:00 | $9 870 | 0.12 | Exhaust port | Jones | ||
GroupId | Sum(price) | |||||
2006-09-08 | $8 000 | |||||
Date | Price | Tax | Item | Customer | ||
2006-09-08 10:00 | $5 000 | 0.24 | Intake port | Jones | ||
2006-09-08 12:00 | $3 000 | 0.12 | Valve cover | Jones | ||
2006-09-09 | $2 100 | |||||
Date | Price | Tax | Item | Customer | ||
2006-09-09 10:00 | $2 100 | 0,12 | Engine block | Jones | ||
2006-09-10 | $8 900 | |||||
Date | Price | Tax | Item | Customer | ||
2006-09-10 10:00 | $8 900 | 0.24 | Camshaft | Jones | ||
2006-09-11 | $20 816 | |||||
Date | Price | Tax | Item | Customer | ||
2006-09-11 12:00 | $9 870 | 0.12 | Exhaust port | Jones | ||
2006-09-11 13:00 | $6 765 | 0.12 | Crankshaft | Jones | ||
2006-09-11 12:00 | $4 181 | 0.24 | Rod bearing | Jones | ||
Smith | $19 484 | |||||
Date | Price | Tax | Item | Customer | ||
2006-09-10 11:00 | $6 100 | 0.24 | Spark plug | Smith | ||
GroupId | Sum(price) | |||||
2006-09-06 | $1 000 | |||||
Date | Price | Tax | Item | Customer | ||
2006-09-06 09:00 | $1 000 | 0.24 | Intake valve | Smith | ||
2006-09-07 | $3 000 | |||||
Date | Price | Tax | Item | Customer | ||
2006-09-07 11:00 | $2 000 | 0.24 | Spring | Smith | ||
2006-09-07 10:00 | $1 000 | 0.12 | Rocker arm | Smith | ||
2006-09-09 | $6 800 | |||||
Date | Price | Tax | Item | Customer | ||
2006-09-09 12:00 | $5 500 | 0.12 | Oil sump | Smith | ||
2006-09-09 12:00 | $1 300 | 0.24 | Coolant | Smith | ||
2006-09-10 | $6 100 | |||||
Date | Price | Tax | Item | Customer | ||
2006-09-10 11:00 | $6 100 | 0.24 | Spark plug | Smith | ||
2006-09-11 | $2 584 | |||||
Date | Price | Tax | Item | Customer | ||
2006-09-11 11:00 | $2 584 | 0.12 | Connection rod | Smith |
Range grouping
In examples above, results are grouped on distinct values, like customer or date. To group on price:
select (…) | all(group(price) each(each(output(summary()))));This gives one group per price. To group on price ranges, one could compress the price range. This gives prices in $0 - $999 in bucket 0, $1 000 - $2 000 in bucket 1 and so on:
select (…) | all(group(price/1000) each(each(output(summary()))));An alternative is using bucket expressions - think of a bucket as the range per group. Group on price, make groups have a width of 1000:
select (…) | all(group(fixedwidth(price,1000)) each(each(output(summary()))));Use
predefined
to configure group sizes individually (the two below are equivalent):
select (…) | all(group(predefined(price, bucket(0,1000), bucket(1000,2000), bucket(2000,5000), bucket(5000,inf))) each(each(output(summary())))); select (…) | all(group(predefined(price, bucket[0,1000>, bucket[1000,2000>, bucket[2000,5000>, bucket[5000,inf>)) each(each(output(summary()))));This works with strings as well - put Jones and Smith in the second group:
select (…) | all(group(predefined(customer, bucket(-inf,"Jones"), bucket("Jones", inf))) each(each(output(summary()))));... or have Jones in his own group:
select (…) | all(group(predefined(customer, bucket<-inf,"Jones">, bucket["Jones"], bucket<"Jones", inf>)) each(each(output(summary()))));
Pagination
Grouping supports continuation objects
that are passed as annotations to the grouping statement.
The continuations
annotation is a list of zero or more continuation strings, returned in the grouping result.
For example, given the result:
{ "root": { "children": [ { "children": [ { "children": [ { "fields": { "count()": 7 }, "value": "Jones", "id": "group:string:Jones", "relevance": 1.0 } ], "continuation": { "next": "BGAAABEBEBC", "prev": "BGAAABEABC" }, "id": "grouplist:customer", "label": "customer", "relevance": 1.0 } ], "continuation": { "this": "BGAAABEBCA" }, "id": "group:root:0", "relevance": 1.0 } ], "fields": { "totalCount": 20 }, "id": "toplevel", "relevance": 1.0 } }
reproduce the same result by passing the this continuation along the orginal select:
select (…) | [{ 'continuations':['BGAAABEBCA'] }]all(…);To display the next page of customers, pass the this continuation of the root group, and the next continuation of the customer list:
select (…) | [{ 'continuations':['BGAAABEBCA', 'BGAAABEBEBC'] }]all(…);To display the previous page of customers, pass the this continuation of the root group, and the prev continuation of the customer list:
select (…) | [{ 'continuations':['BGAAABEBCA', 'BGAAABEABC'] }]all(…);The
continuations
annotation is an ordered list of continuation strings.
These are combined by replacement,
so that a continuation given later will replace any shared state with a continuation given before.
Also, when using the continuations
annotation, always pass the this continuation as its first element.
Note: Continuations work best when you have a stable ordering of hits - which can be achieved by using ranking or ordering. You may also need to add an additional tie-breaker - like random.match or a random double value stored in each document - to keep the ordering stable in case of multiple documents which would otherwise get the same rank score, or the same value used for ordering.
Expressions
Instead of just grouping on some attribute value,
the group
clause may contain arbitrarily complex expressions -
see group
in the
grouping reference for an exhaustive list.
Examples:
- Select the minimum or maximum of sub-expressions
- Addition, subtraction, multiplication, division, and even modulo of sub-expressions
- Bitwise operations on sub-expressions
- Concatenation of the results of sub-expressions
select (…) | all(group(mod(div(date,mul(60,60)),24)) each(output(sum(price))));
GroupId | sum(price) |
---|---|
09:00 | $1 000 |
10:00 | $22 367 |
11:00 | $23 524 |
12:00 | $26 181 |
13:00 | $6 765 |
These types of expressions may also be used inside output
operations,
so instead of simply calculating the sum price of the grouped purchases,
calculate the sum income after taxes per customer:
select (…) | all(group(customer) each(output(sum(mul(price,sub(1,tax))))));
GroupId | sum(mul(price,sub(1,tax))) |
---|---|
Brown | $17 193 |
Jones | $32 868 |
Smith | $15 897 |
Note that the validity of an expression depends on the current nesting level.
E.g. while sum(price)
would be a valid expression for a group of hits, price
would not.
As a general rule, each operator within an expression either applies to a single hit or aggregates values across a group.
Search Container API
As an alternative to a textual representation,
one can use the programmatic API to execute grouping requests.
This allows multiple grouping requests to run in parallel,
and does not collide with the yql
parameter - example:
@Override public Result search(Query query, Execution execution) { // Create grouping request. GroupingRequest request = GroupingRequest.newInstance(query); request.setRootOperation(new AllOperation() .setGroupBy(new AttributeValue("foo")) .addChild(new EachOperation() .addOutput(new CountAggregator().setLabel("count")))); // Perform grouping request. Result result = execution.search(query); // Process grouping result. Group root = request.getResultGroup(result); GroupList foo = root.getGroupList("foo"); for (Hit hit : foo) { Group group = (Group)hit; Long count = (Long)group.getField("count"); // TODO: Process group and count. } // Pass results back to calling searcher. return result; }Refer to the API documentation for the complete reference.
More examples
TopN / Full corpus
Simple grouping, count the number of documents in each group:
all( group(a) each(output(count())) );Two parallel groupings:
all( all(group(a) each(output(count()))) all(group(b) each(output(count()))) );Only the 1000 best hits will be grouped at each content node. Lower accuracy, but higher speed:
all( max(1000) all(group(a) each(output(count()))) );In streaming search one can group all searched documents by adding a
where(true)
clause:
all( where(true) all(group(myfield) each(output(count()))) );
Selecting groups
Do a modulo 5 operation before selecting the group:
all( group(a % 5) each(output(count())) );Do
a + b * c
before selecting the group:
all( group(a + b * c) each(output(count())) );
Ordering groups
Do a modulo 5 operation before selecting the group - the groups are then ordered by their aggregated sum of attribute "b":
all( group(a % 5) order(sum(b)) each(output(count())) );Do
a + b * c
before selecting the group.
Ordering is given by the maximum value of attribute "d" in each group:
all( group(a + b * c) order(max(d)) each(output(count())) );Take the average relevance of the groups and multiply it with the number of groups to get a cumulative count:
all( group(a) order(avg(relevance()) * count()) each(output(count())) );One can not directly reference an attribute in the order clause, as this:
all(group(a) order(attr * count()) each(output(count())) );However, one can do this:
all(group(a) order(max(attr) * count()) each(output(count())) );
Collecting aggregates
Simple grouping to count number of documents in each group and return the best hit in each group:
all( group(a) each(max(1) each(output(summary()))) );Also return the sum of attribute "b":
all( group(a) each(max(1) output(count(), sum(b)) each(output(summary()))) );Also return an XOR of the 64 most significant bits of an MD5 over the concatenation of attributes "a", "b" and "c":
all(group(a) each(max(1) output(count(), sum(b), xor(md5(cat(a, b, c), 64))) each(output(summary()))));
Grouping
Single level grouping on "a" attribute, returning at most 5 groups with full hit count as well as the 69 best hits.
all( group(a) max(5) each(max(69) output(count()) each(output(summary()))) );Two level grouping on "a" and "b" attribute:
all( group(a) max(5) each(output(count()) all(group(b) max(5) each(max(69) output(count()) each(output(summary()))))) );Three level grouping on "a", "b" and "c" attribute:
all( group(a) max(5) each(output(count()) all(group(b) max(5) each(output(count()) all(group(c) max(5) each(max(69) output(count()) each(output(summary()))))) );As above, but also collect best hit in level 2:
all( group(a) max(5) each(output(count()) all(group(b) max(5) each(output(count()) all(max(1) each(output(summary()))) all(group(c) max(5) each(max(69) output(count()) each(output(summary()))))) );As above, but also collect best hit in level 1:
all( group(a) max(5) each(output(count()) all(max(1) each(output(summary()))) all(group(b) max(5) each(output(count()) all(max(1) each(output(summary()))) all(group(c) max(5) each(max(69) output(count()) each(output(summary()))))) );As above, but using different document summaries on each level:
all( group(a) max(5) each(output(count()) all(max(1) each(output(summary(complexsummary)))) all(group(b) max(5) each(output(count()) all(max(1) each(output(summary(simplesummary)))) all(group(c) max(5) each(max(69) output(count()) each(output(summary(fastsummary)))))) );Deep grouping with counting and hit collection on all levels:
all( group(a) max(5) each(output(count()) all(max(1) each(output(summary()))) all(group(b) each(output(count()) all(max(1) each(output(summary()))) all(group(c) each(output(count()) all(max(1) each(output(summary())))))))) );
Time and date
Group by year:
all( group(time.year(a)) each(output(count())) );Group by year, then by month:
all( group(time.year(a)) each(output(count()) all(group(time.month(a)) each(output(count())))) );Group by year, then by month, then day, then by hour:
all( group(time.year(a)) each(output(count()) all(group(time.monthofyear(a)) each(output(count()) all(group(time.dayofmonth(a)) each(output(count()) all(group(time.hourofday(a)) each(output(count())))))))) );Groups today, yesterday, lastweek, and lastmonth using
predefined
aggregator, and groups each day within each of these separately:
all( group(predefined((now() - a) / (60 * 60 * 24), bucket(0,1), bucket(1,2), bucket(3,7), bucket(8,31))) each(output(count()) all(max(2) each(output(summary()))) all(group((now() - a) / (60 * 60 * 24)) each(output(count()) all(max(2) each(output(summary())))))) );
Counting unique groups
The count
aggregator can be applied on list of groups to determine the number of unique groups
without having to explicitly retrieve all groups. Note that that this count is an estimate using HyperLogLog++ which is an algorithm for the count-distinct problem.
To get an accurate count one needs to explicitly retrieve all groups and count them in a custom component or in the middle tier calling out to Vespa.
This is network intensive and might not be feasible in cases with many unique groups.
Another use case for this aggregator is counting the number of unique instances matching a given expression.
Output an estimate of the number of groups, which is equivalent to the number of unique values for attribute "a":
all( group(a) output(count()) )Output an estimate of the number of unique string lengths for the attribute "name":
all( group(strlen(name)) output(count()) )Output the sum of the "b" attribute for each group in addition to the accurate count of the overall number of unique groups as the inner each causes all groups to be returned.
all( group(a) output(count()) each(output(sum(b))) )The
max
clause is used to restrict the number of groups returned.
The query outputs the sum for the 3 best groups. The count
clause
outputs the estimated number of groups (potentially >3). The count
becomes an estimate here as the number of groups is limited by maxwhile in the above example it's not limited by max:
all( group(a) max(3) output(count()) each(output(sum(b))) )Output the number of top level groups, and for the 10 best groups, output the number of unique values for attribute "b":
all( group(a) max(10) output(count()) each(group(b) output(count())) )
Impression forecasting
Using impression logs for a given user, one can make a function that maps from rank score to the number of impressions an advertisement would get - example:
Score Integer (# impressions for this user) 0.200 0 0.210 1 0.220 2 0.240 3 0.320 4 0.420 5 0.560 6 0.700 7 0.800 8 0.880 9 0.920 10 0.940 11 0.950 12Storing just the first column (the rank scores, including a rank score for 0 impressions) in an array attribute named impressions, the grouping operation
interpolatedlookup(impressions, relevance())
can be used to figure out how many times a given advertisement would have been shown to this particular user.
So if the rank score is 0.420 for a specific user/ad/bid combination,
then interpolatedlookup(impressions,relevance())
would return 5.0.
If the the bid is increased so the rankscore gets to 0.490 it would get 5.5 as the return value instead.
In this context a count of 5.5 isn't meaningful for the past of a single user, but it gives more information that may be used as a forecast. Summing this across many different users may then be used to forecast the total of future impressions for the advertisement.