OAK-12353: Group Elasticsearch dynamic boost nested documents by boost score - #3079
OAK-12353: Group Elasticsearch dynamic boost nested documents by boost score#3079fabriziofortino wants to merge 9 commits into
Conversation
Dynamic boost properties are mapped as nested fields, with one nested document per value. When many values share the same boost score, this generates a lot of nested documents which is expensive in Elasticsearch. Group values sharing the same boost score into a single nested document with an array value, behind FT_OAK-12353 (disabled by default). Querying is unaffected since text fields accept arrays natively.
Cover both the default (grouped) and toggle-disabled (one nested doc per value) behaviour, verifying queries still match on any value grouped into a shared nested document.
Similar to the FT_OAK-12206 test in ElasticIndexWriterTest: fails once the deadline passes, as a reminder to remove FT_OAK-12353 and its guards once the dynamic-boost grouping default has been in production long enough.
… from array to object for downstream consumers
Grouping values by boost score makes the nested "value" field's token count vary with group size, which would otherwise skew BM25 length normalization and change ranking based on how many tags happen to share a boost score. Boost is already applied explicitly via field_value_factor, so length normalization on this field isn't meaningful; disabling norms keeps matching scores stable regardless of group size.
| /** | ||
| * Predicted tags sharing the same boost score are grouped into a single nested document | ||
| * (see {@link ElasticDocument#FT_OAK_12353_ENABLE}). This verifies that querying still | ||
| * matches on any of the grouped values, both with the grouping enabled (default) and | ||
| * disabled. | ||
| */ |
There was a problem hiding this comment.
I don't see how the test below (which asserts on query results) "proves" that the different tags are found within the same nested document. Maybe I missed something ?
There was a problem hiding this comment.
OK, I got confused by the first phrase of the comment. Thought it was a statement. Maybe rephrase ? "Verify that querying give the same results whether grouping by boost (see ...) is active or not.
There was a problem hiding this comment.
I would even try to write the test in such a way that the body of the test with and without the toggle is shared making it straight forward.
@Test
void ungroupedDynamicBoostedQueriesWork() {
// Given
ElasticDocument.FT_OAK_12353_ENABLE.set(false);
// Then
assertSimpleBoostedQueriesWork();
}
@Test
void groupedDynamicBoostedQueriesWork() {
// Given
ElasticDocument.FT_OAK_12353_ENABLE.set(true); // <- explicit intent
// Then
assertSimpleBoostedQueriesWork();
}
void assertSimpleBoostedQueriesWork() {
Tree testParent = createNodeWithType(root.getTree("/"), "test", JcrConstants.NT_UNSTRUCTURED, "");
Tree predicted1 = createAssetNodeWithPredicted(testParent, "asset1", "flower with a lot of red and a bit of blue");
createPredictedTag(predicted1, "red", 5.0);
createPredictedTag(predicted1, "blue", 5.0);
createPredictedTag(predicted1, "green", 5.0);
createPredictedTag(predicted1, "special", 9.0);
root.commit();
assertEventually(() -> {
assertQuery("//element(*, dam:Asset)[jcr:contains(., 'red')]", XPATH, List.of("/test/asset1"));
assertQuery("//element(*, dam:Asset)[jcr:contains(., 'blue')]", XPATH, List.of("/test/asset1"));
assertQuery("//element(*, dam:Asset)[jcr:contains(., 'green')]", XPATH, List.of("/test/asset1"));
assertQuery("//element(*, dam:Asset)[jcr:contains(., 'special')]", XPATH, List.of("/test/asset1"));
});
}
| // norms disabled: values sharing a boost score are grouped into a single nested | ||
| // doc (see ElasticDocument#FT_OAK_12353), so field length varies by group size and | ||
| // would otherwise skew BM25 length normalization; boost is applied explicitly via | ||
| // field_value_factor, so length normalization on this field isn't meaningful anyway. |
There was a problem hiding this comment.
Interesting. So the field length of multi-valued fields is the sum of the lengths of the values ? It varied as well before no ? (Just a curiosity)
ChlineSaurus
left a comment
There was a problem hiding this comment.
Overall looks good. Left one comment
| assertQuery("//element(*, dam:Asset)[jcr:contains(., 'special')]", XPATH, List.of("/test/asset1")); | ||
| }); | ||
| } | ||
|
|
There was a problem hiding this comment.
One thing an Agent flagged (and then verified via testing):
It seems that the order can change when comparing the old vs new behavior.
Without grouping (as expected), the score is the sum of the matching values boosts divided by the number of matching values. With grouping, the boost still adds up all matching values, but the divider counts each boost-group only once instead of each value. So values sharing a group get summed on top yet divide only once.
Example
private static final String RED_BLUE_GREEN =
"select [jcr:path] from [dam:Asset] where contains(*, 'red blue green')";
private void createRankingAssets() throws Exception {
createAssetsIndexAndProperties(false, false);
Tree test = createNodeWithType(root.getTree("/"), "test", JcrConstants.NT_UNSTRUCTURED, "");
// asset1: three tags sharing one boost group (boost 1)
Tree many = createAssetNodeWithPredicted(test, "asset1", "titleone");
createPredictedTag(many, "red", 1.0);
createPredictedTag(many, "blue", 1.0);
createPredictedTag(many, "green", 1.0);
// asset2: one high-boost tag in its own group, the other two effectively zero
Tree single = createAssetNodeWithPredicted(test, "asset2", "titletwo");
createPredictedTag(single, "red", 4.0);
createPredictedTag(single, "blue", 0.01);
createPredictedTag(single, "green", 0.01);
root.commit();
}
@Test
public void rankingWithGroupingDisabled() throws Exception {
// one nested doc per value: score_mode=avg divides by 3 children
// -> asset2's single high boost (4) wins over asset1's three boost-1 values
ElasticDocument.FT_OAK_12353_ENABLE.set(false); // must be set before indexing (root.commit)
createRankingAssets();
assertEventually(() -> assertOrderedQuery(RED_BLUE_GREEN, List.of("/test/asset2", "/test/asset1")));
}
@Test
public void rankingWithGroupingEnabled() throws Exception {
// asset1's three boost-1 values collapse into ONE child; its text score sums the three
// matched terms and score_mode=avg divides by 1 -> asset1 now outranks asset2. Order flips.
ElasticDocument.FT_OAK_12353_ENABLE.set(true); // the OAK-12353 default
createRankingAssets();
assertEventually(() -> assertOrderedQuery(RED_BLUE_GREEN, List.of("/test/asset1", "/test/asset2")));
}
I'm not sure if this is a problem, but I thought it was still worth mentioning, especially as in my understanding we will have a mixed behavior for some time.
Summary
Dynamic boost properties (e.g.
predictedTagsDynamicBoost) are indexed in Elasticsearchas nested documents, with one nested document per value:
For properties with many values sharing the same boost score, this creates a large
number of nested documents, which is expensive to index and store.
This change groups values that share the same boost score into a single nested
document, with value holding an array instead of a scalar:
No mapping or query changes were needed: value is a plain analyzed text field,
which Elasticsearch accepts as an array natively, and the existing nested match /
field_value_factor query in ElasticRequestHandler works unchanged against the
grouped structure.
Feature toggle
Guarded by FT_OAK-12353, enabled by default. Set to false at runtime to revert
to the previous one-nested-document-per-value behavior.
A time-bombed test (ElasticDocumentTest#ft_oak_12353_toggleShouldBeRemoved) will start
failing after 2027-08-12 as a reminder to remove the toggle and its guards once the
grouped format has been running in production long enough.
Changes
by boost score in addDynamicBoostField/getProperties when enabled.
query coverage (grouped and ungrouped) in ElasticDynamicBoostTest.
Test plan
toggle-disabled fallback to the original per-value nested documents
instance, confirming queries still match on values grouped into a shared nested
document, both with grouping enabled (default) and disabled