This is an automated email from the ASF dual-hosted git repository.
yiguolei pushed a commit to branch branch-4.1
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/branch-4.1 by this push:
new 95ec58f7e91 [feat](inverted-index) Backport configurable ngram size
difference to branch-4.1 (#68561)
95ec58f7e91 is described below
commit 95ec58f7e91d4bb2c05c113be69346928c74cade
Author: Jack <[email protected]>
AuthorDate: Tue Sep 29 15:37:22 2026 +0800
[feat](inverted-index) Backport configurable ngram size difference to
branch-4.1 (#68561)
### What problem does this PR solve?
Related PRs: #67917 (source feature), #68550 (merged prerequisite).
Target: `branch-4.1`.
Source squash commit: `6a79f954e2617c807173aad9796cfd6138c14055`.
Problem Summary:
Backport configurable `max_ngram_diff` for custom ngram tokenizers. The
default is 1. Explicit values must be ASCII integers from 0 through 255,
and newly created policies limit `min_gram` and `max_gram` to 1024.
Policies persisted before this change keep their former absolute-size
behavior. Invalid replayed policies cannot block valid replacements. The
admission-only `max_ngram_diff` setting is excluded from analyzer
identity.
After #68550 merged, this PR conflicted in six files. The resolution
preserves its analyzer identity, invalid-component, policy-name, and
malformed UTF-8 behavior, then adds the remaining ngram admission and
replay semantics. The PR now includes the updated `branch-4.1` through a
merge commit, so it can be pushed without rewriting its published
history. All source hunks are accounted for below;
`AnalyzerIdentityBuilder.java` needs no PR diff because #68550 already
supplies its source behavior. The range-diff pairs the source squash
commit `6a79f954e26` with the original backport `b668aca4154`; merge
commit `eb4f0e89330` integrates the updated target.
### Release note
Allow custom ngram tokenizers to configure the maximum difference
between `max_gram` and `min_gram` with `max_ngram_diff` values from 0
through 255.
### Check List (For Author)
- Test
- [x] FE build and Checkstyle passed with zero violations.
- [x] FE `PolicyValidatorTests` 44/44 and `AnalyzerIdentityBuilderTest`
64/64 passed (108/108 total).
- [x] ASAN BE build passed with `-j192`; clang-format 16 checked all
five changed C++ files.
- [x] ASAN BE `NGramTokenizerTest` passed 21/21, including the two
malformed UTF-8 cases from #68550.
- [x] Regression `test_ngram_max_diff_custom_analyzer` passed 1/1.
- [x] Regression `test_analyzer_identity_semantics` from #68550 passed
1/1.
- [x] Changed-line clang-tidy passed on all five changed C++ files.
- [x] Merged-tree format preflight passed against the latest fetched
`branch-4.1` (clang-format and FE Checkstyle).
- Behavior changed:
- [x] Yes. New custom ngram policies can opt into a wider gram-size
difference; old persisted policies retain their prior absolute-size
behavior.
- Does this need documentation?
- [x] Yes. The custom analyzer documentation should describe
`max_ngram_diff`, its 0 through 255 range, and the 1024 absolute-size
limit for newly created policies. The source PR did not include a
documentation change.
The local BE link used a task-owned compatible Lance dependency copy
with an uncommitted shim for missing Lance symbols; this work did not
validate Lance behavior. The dependency copy and shim are outside the PR
diff. The isolated regression instance disabled Arrow Flight SQL after
its local flight port bind failed; the tested NGram path does not use
Arrow Flight SQL.
### Source hunk audit
# PR #67917 source hunk audit after merging #68550
Source squash commit: `6a79f954e2617c807173aad9796cfd6138c14055`. Status
is relative to current `branch-4.1`; every row refers to one source `@@`
hunk unless file bytes match exactly.
### `be/src/storage/index/inverted/tokenizer/ngram/ngram_tokenizer.cpp`
| Source hunk | Status | Evidence |
|---|---|---|
| `@@ -106 +106,2 @@ void NGramTokenizer::init(int32_t min_gram, int32_t
max_gram, bool edges_only) {` | Ported | Retain the configured ngram
size in the 4.1 tokenizer layout. |
### `be/src/storage/index/inverted/tokenizer/ngram/ngram_tokenizer.h`
| Source hunk | Status | Evidence |
|---|---|---|
| `@@ -78 +78 @@ private:` | Ported | Retain the source field change and
the 4.1 compile-check bracketing. |
###
`be/src/storage/index/inverted/tokenizer/ngram/ngram_tokenizer_factory.cpp`
| Source hunk | Status | Evidence |
|---|---|---|
| All hunks | Ported (verbatim) | Source and final merged file bytes
match. |
###
`be/src/storage/index/inverted/tokenizer/ngram/ngram_tokenizer_factory.h`
| Source hunk | Status | Evidence |
|---|---|---|
| `@@ -28,0 +29,5 @@ public:` | Ported | The configurable difference is
exposed alongside the current factory interface. |
| `@@ -68 +73 @@ private:` | Ported | The configurable difference is
exposed alongside the current factory interface. |
### `be/test/storage/index/inverted/tokenizer/ngram_tokenizer_test.cpp`
| Source hunk | Status | Evidence |
|---|---|---|
| `@@ -89,0 +90,81 @@ TEST(NGramTokenizerTest, InvalidMinMaxDifference)
{` | Ported | All seven difference and size-limit cases remain beside
the malformed UTF-8 tests from #68550. |
| `@@ -205 +286 @@ TEST(NGramTokenizerTest, WhitespaceTokenization) {` |
Already in target | The target already has the source EOF normalization.
|
###
`fe/fe-core/src/main/java/org/apache/doris/analysis/invertedindex/AnalyzerIdentityBuilder.java`
| Source hunk | Status | Evidence |
|---|---|---|
| `@@ -30,0 +31,2 @@ public final class AnalyzerIdentityBuilder {` |
Already in target | Merged #68550 already contains this ngram identity
change. |
| `@@ -171,0 +174,3 @@ public final class AnalyzerIdentityBuilder {` |
Already in target | Merged #68550 already contains this ngram identity
change. |
| `@@ -179,0 +185,5 @@ public final class AnalyzerIdentityBuilder {` |
Already in target | Merged #68550 already contains this ngram identity
change. |
###
`fe/fe-core/src/main/java/org/apache/doris/indexpolicy/IndexPolicy.java`
| Source hunk | Status | Evidence |
|---|---|---|
| `@@ -132 +132 @@ public class IndexPolicy implements Writable,
GsonPostProcessable {` | Already in target | Merged #68550 supplies the
common_grams invalidation path. |
| `@@ -134,0 +135,5 @@ public class IndexPolicy implements Writable,
GsonPostProcessable {` | Adapted | Use isValidPolicy for ngram replay
while retaining common_grams invalidation. |
###
`fe/fe-core/src/main/java/org/apache/doris/indexpolicy/IndexPolicyMgr.java`
| Source hunk | Status | Evidence |
|---|---|---|
| `@@ -113 +113 @@ public class IndexPolicyMgr implements Writable,
GsonPostProcessable {` | Already in target | Merged #68550 uses shared
component validation. |
| `@@ -120,4 +120,3 @@ public class IndexPolicyMgr implements Writable,
GsonPostProcessable {` | Already in target | Merged #68550 has the
shared replay-validation comment. |
| `@@ -125 +124 @@ public class IndexPolicyMgr implements Writable,
GsonPostProcessable {` | Already in target | Merged #68550 has the
shared component-validation helper. |
| `@@ -127,2 +126,13 @@ public class IndexPolicyMgr implements Writable,
GsonPostProcessable {` | Already in target | Merged #68550 rejects
analyzers referencing invalid tokenizers. |
| `@@ -189,2 +199,10 @@ public class IndexPolicyMgr implements Writable,
GsonPostProcessable {` | Adapted | Persist max_ngram_diff after
validation and before policy creation under the current manager lock. |
| `@@ -339,0 +358,3 @@ public class IndexPolicyMgr implements Writable,
GsonPostProcessable {` | Already in target | Merged #68550 rejects
invalid referenced policies. |
| `@@ -670,4 +691,4 @@ public class IndexPolicyMgr implements Writable,
GsonPostProcessable {` | Already in target | Merged #68550 logs invalid
policies on image and edit-log replay. |
###
`fe/fe-core/src/main/java/org/apache/doris/indexpolicy/NGramTokenizerValidator.java`
| Source hunk | Status | Evidence |
|---|---|---|
| All hunks | Ported (verbatim) | Source and final merged file bytes
match. |
###
`fe/fe-core/src/test/java/org/apache/doris/analysis/invertedindex/AnalyzerIdentityBuilderTest.java`
| Source hunk | Status | Evidence |
|---|---|---|
| `@@ -19,0 +20,2 @@ package org.apache.doris.analysis.invertedindex;` |
Already in target | Merged #68550 supplies this test import. |
| `@@ -20,0 +23,2 @@ import org.apache.doris.indexpolicy.IndexPolicy;` |
Already in target | Merged #68550 supplies this test import. |
| `@@ -23,0 +28,2 @@ import org.junit.jupiter.api.Test;` | Already in
target | Merged #68550 supplies this test import. |
| `@@ -103,0 +110,101 @@ public class AnalyzerIdentityBuilderTest {` |
Adapted | Retain the source difference-limit identity and replay cases
beside the target default-bound, malformed-policy, and legacy-size
cases. |
###
`fe/fe-core/src/test/java/org/apache/doris/indexpolicy/PolicyValidatorTests.java`
| Source hunk | Status | Evidence |
|---|---|---|
| `@@ -19,0 +20 @@ package org.apache.doris.indexpolicy;` | Already in
target | Merged #68550 supplies this test import. |
| `@@ -20,0 +22 @@ import org.apache.doris.common.DdlException;` |
Already in target | Merged #68550 supplies this test import. |
| `@@ -23,0 +26,2 @@ import org.junit.jupiter.api.Test;` | Already in
target | Merged #68550 supplies this test import. |
| `@@ -132,0 +137 @@ public class PolicyValidatorTests {` | Already in
target | Merged #68550 already covers basic ngram validation. |
| `@@ -135,0 +141,124 @@ public class PolicyValidatorTests {` | Adapted
| Keep ten source validation/replay cases and the policy round-trip
helper; adjust the target default-valid case to a difference of one. |
###
`regression-test/data/inverted_index_p0/analyzer/test_ngram_max_diff_custom_analyzer.out`
| Source hunk | Status | Evidence |
|---|---|---|
| All hunks | Ported (verbatim) | Source and final merged file bytes
match. |
###
`regression-test/suites/inverted_index_p0/analyzer/test_ngram_max_diff_custom_analyzer.groovy`
| Source hunk | Status | Evidence |
|---|---|---|
| All hunks | Ported (verbatim) | Source and final merged file bytes
match. |
---
.../inverted/tokenizer/ngram/ngram_tokenizer.cpp | 3 +-
.../inverted/tokenizer/ngram/ngram_tokenizer.h | 2 +-
.../tokenizer/ngram/ngram_tokenizer_factory.cpp | 29 ++++-
.../tokenizer/ngram/ngram_tokenizer_factory.h | 7 +-
.../inverted/tokenizer/ngram_tokenizer_test.cpp | 81 +++++++++++++
.../org/apache/doris/indexpolicy/IndexPolicy.java | 6 +-
.../apache/doris/indexpolicy/IndexPolicyMgr.java | 5 +
.../doris/indexpolicy/NGramTokenizerValidator.java | 55 ++++++++-
.../invertedindex/AnalyzerIdentityBuilderTest.java | 75 ++++++++++++
.../doris/indexpolicy/PolicyValidatorTests.java | 132 ++++++++++++++++++++-
.../test_ngram_max_diff_custom_analyzer.out | 4 +
.../test_ngram_max_diff_custom_analyzer.groovy | 69 +++++++++++
12 files changed, 455 insertions(+), 13 deletions(-)
diff --git a/be/src/storage/index/inverted/tokenizer/ngram/ngram_tokenizer.cpp
b/be/src/storage/index/inverted/tokenizer/ngram/ngram_tokenizer.cpp
index e8fcbea9d79..d3d63b7a885 100644
--- a/be/src/storage/index/inverted/tokenizer/ngram/ngram_tokenizer.cpp
+++ b/be/src/storage/index/inverted/tokenizer/ngram/ngram_tokenizer.cpp
@@ -115,7 +115,8 @@ void NGramTokenizer::init(int32_t min_gram, int32_t
max_gram, bool edges_only) {
_min_gram = min_gram;
_max_gram = max_gram;
_edges_only = edges_only;
- _buffer.resize(4 * max_gram + 1024);
+ const size_t buffer_size = static_cast<size_t>(max_gram) * 4 + 1024;
+ _buffer.resize(buffer_size);
}
void NGramTokenizer::update_last_non_token_char() {
diff --git a/be/src/storage/index/inverted/tokenizer/ngram/ngram_tokenizer.h
b/be/src/storage/index/inverted/tokenizer/ngram/ngram_tokenizer.h
index 55515b2a273..72b6110ea86 100644
--- a/be/src/storage/index/inverted/tokenizer/ngram/ngram_tokenizer.h
+++ b/be/src/storage/index/inverted/tokenizer/ngram/ngram_tokenizer.h
@@ -77,4 +77,4 @@ private:
};
#include "common/compile_check_end.h"
-} // namespace doris::segment_v2::inverted_index
\ No newline at end of file
+} // namespace doris::segment_v2::inverted_index
diff --git
a/be/src/storage/index/inverted/tokenizer/ngram/ngram_tokenizer_factory.cpp
b/be/src/storage/index/inverted/tokenizer/ngram/ngram_tokenizer_factory.cpp
index c5b6c5a9c73..982ba376d29 100644
--- a/be/src/storage/index/inverted/tokenizer/ngram/ngram_tokenizer_factory.cpp
+++ b/be/src/storage/index/inverted/tokenizer/ngram/ngram_tokenizer_factory.cpp
@@ -26,12 +26,35 @@ std::unordered_map<std::string, CharMatcherPtr>
NGramTokenizerFactory::MATCHERS;
void NGramTokenizerFactory::initialize(const Settings& settings) {
_min_gram = settings.get_int("min_gram",
NGramTokenizer::DEFAULT_MIN_NGRAM_SIZE);
_max_gram = settings.get_int("max_gram",
NGramTokenizer::DEFAULT_MAX_NGRAM_SIZE);
+ if (_min_gram <= 0 || _max_gram <= 0) {
+ throw Exception(ErrorCode::INVALID_ARGUMENT, "min_gram and max_gram
must be positive");
+ }
+ if (_min_gram > _max_gram) {
+ throw Exception(ErrorCode::INVALID_ARGUMENT, "min_gram must not be
greater than max_gram");
+ }
+ const bool has_max_ngram_diff =
!settings.get_string("max_ngram_diff").empty();
+ if (has_max_ngram_diff && (_min_gram > MAX_NGRAM_SIZE || _max_gram >
MAX_NGRAM_SIZE)) {
+ throw Exception(ErrorCode::INVALID_ARGUMENT,
+ "min_gram and max_gram must be less than or equal to "
+
+ std::to_string(MAX_NGRAM_SIZE));
+ }
+ int32_t max_ngram_diff = settings.get_int("max_ngram_diff", 1);
+ if (max_ngram_diff < 0) {
+ throw Exception(ErrorCode::INVALID_ARGUMENT,
+ "max_ngram_diff must be greater than or equal to 0");
+ }
+ if (max_ngram_diff > MAX_NGRAM_DIFF) {
+ throw Exception(
+ ErrorCode::INVALID_ARGUMENT,
+ "max_ngram_diff must be less than or equal to " +
std::to_string(MAX_NGRAM_DIFF));
+ }
int32_t ngram_diff = _max_gram - _min_gram;
- if (ngram_diff > 1) {
+ if (ngram_diff > max_ngram_diff) {
throw Exception(
ErrorCode::INVALID_ARGUMENT,
"The difference between max_gram and min_gram in NGram
Tokenizer must be less "
- "than or equal to: [ 1 ] but was [" +
+ "than or equal to: [ " +
+ std::to_string(max_ngram_diff) + " ] but was [" +
std::to_string(ngram_diff) + "]");
}
_matcher = parse_token_chars(settings);
@@ -80,4 +103,4 @@ CharMatcherPtr
NGramTokenizerFactory::parse_token_chars(const Settings& settings
return builder.build();
}
-} // namespace doris::segment_v2::inverted_index
\ No newline at end of file
+} // namespace doris::segment_v2::inverted_index
diff --git
a/be/src/storage/index/inverted/tokenizer/ngram/ngram_tokenizer_factory.h
b/be/src/storage/index/inverted/tokenizer/ngram/ngram_tokenizer_factory.h
index d064749d9d5..00334e303cb 100644
--- a/be/src/storage/index/inverted/tokenizer/ngram/ngram_tokenizer_factory.h
+++ b/be/src/storage/index/inverted/tokenizer/ngram/ngram_tokenizer_factory.h
@@ -26,6 +26,11 @@ namespace doris::segment_v2::inverted_index {
class NGramTokenizerFactory : public TokenizerFactory {
public:
+ // A configured range can emit one token per gram size at every input
position.
+ static constexpr int32_t MAX_NGRAM_DIFF = 255;
+ // Bound the per-stream buffer while retaining support for large
application-specific grams.
+ static constexpr int32_t MAX_NGRAM_SIZE = 1024;
+
NGramTokenizerFactory() = default;
~NGramTokenizerFactory() override = default;
@@ -61,4 +66,4 @@ private:
CharMatcherPtr _matcher;
};
-}; // namespace doris::segment_v2::inverted_index
\ No newline at end of file
+}; // namespace doris::segment_v2::inverted_index
diff --git a/be/test/storage/index/inverted/tokenizer/ngram_tokenizer_test.cpp
b/be/test/storage/index/inverted/tokenizer/ngram_tokenizer_test.cpp
index 8529b6b081d..a82e8b8b56b 100644
--- a/be/test/storage/index/inverted/tokenizer/ngram_tokenizer_test.cpp
+++ b/be/test/storage/index/inverted/tokenizer/ngram_tokenizer_test.cpp
@@ -110,6 +110,87 @@ TEST(NGramTokenizerTest, InvalidMinMaxDifference) {
ASSERT_TRUE(exception_thrown);
}
+TEST(NGramTokenizerTest, ConfiguredMinMaxDifference) {
+ NGramTokenizerFactory factory;
+ std::unordered_map<std::string, std::string> args;
+ args["min_gram"] = "1";
+ args["max_gram"] = "8";
+ args["max_ngram_diff"] = "7";
+ Settings settings(args);
+ factory.initialize(settings);
+ auto tokens = tokenize(factory, "abcdefgh");
+
+ std::vector<std::string> expected {
+ "a", "ab", "abc", "abcd", "abcde", "abcdef",
"abcdefg", "abcdefgh", "b",
+ "bc", "bcd", "bcde", "bcdef", "bcdefg", "bcdefgh", "c",
"cd", "cde",
+ "cdef", "cdefg", "cdefgh", "d", "de", "def", "defg",
"defgh", "e",
+ "ef", "efg", "efgh", "f", "fg", "fgh", "g",
"gh", "h"};
+ ASSERT_EQ(tokens, expected);
+}
+
+TEST(NGramTokenizerTest, InvalidConfiguredDifferenceLimit) {
+ NGramTokenizerFactory factory;
+ std::unordered_map<std::string, std::string> args;
+ args["max_ngram_diff"] = "-1";
+ Settings settings(args);
+
+ EXPECT_THROW(factory.initialize(settings), Exception);
+}
+
+TEST(NGramTokenizerTest, ExcessiveConfiguredDifferenceLimit) {
+ NGramTokenizerFactory factory;
+ std::unordered_map<std::string, std::string> args;
+ args["max_ngram_diff"] =
std::to_string(NGramTokenizerFactory::MAX_NGRAM_DIFF + 1);
+ Settings settings(args);
+
+ EXPECT_THROW(factory.initialize(settings), Exception);
+}
+
+TEST(NGramTokenizerTest, ConfiguredDifferenceLimitBoundary) {
+ NGramTokenizerFactory factory;
+ std::unordered_map<std::string, std::string> args;
+ args["min_gram"] = "1";
+ args["max_gram"] = std::to_string(NGramTokenizerFactory::MAX_NGRAM_DIFF +
1);
+ args["max_ngram_diff"] =
std::to_string(NGramTokenizerFactory::MAX_NGRAM_DIFF);
+ Settings settings(args);
+
+ EXPECT_NO_THROW(factory.initialize(settings));
+}
+
+TEST(NGramTokenizerTest, AbsoluteSizeBoundary) {
+ NGramTokenizerFactory factory;
+ std::unordered_map<std::string, std::string> args;
+ args["min_gram"] = std::to_string(NGramTokenizerFactory::MAX_NGRAM_SIZE);
+ args["max_gram"] = std::to_string(NGramTokenizerFactory::MAX_NGRAM_SIZE);
+ args["max_ngram_diff"] = "1";
+ Settings settings(args);
+
+ EXPECT_NO_THROW(factory.initialize(settings));
+ EXPECT_NO_THROW(factory.create());
+}
+
+TEST(NGramTokenizerTest, ExcessiveAbsoluteSize) {
+ NGramTokenizerFactory factory;
+ std::unordered_map<std::string, std::string> args;
+ args["min_gram"] = std::to_string(NGramTokenizerFactory::MAX_NGRAM_SIZE);
+ args["max_gram"] = std::to_string(NGramTokenizerFactory::MAX_NGRAM_SIZE +
1);
+ args["max_ngram_diff"] = "1";
+ Settings settings(args);
+
+ EXPECT_THROW(factory.initialize(settings), Exception);
+}
+
+TEST(NGramTokenizerTest, LegacyFixedSizeAboveCurrentLimit) {
+ NGramTokenizerFactory factory;
+ std::unordered_map<std::string, std::string> args;
+ args["min_gram"] = "2048";
+ args["max_gram"] = "2048";
+ Settings settings(args);
+
+ EXPECT_NO_THROW(factory.initialize(settings));
+ EXPECT_NO_THROW(factory.create());
+}
+
TEST(NGramTokenizerTest, SymbolCharactersHandling) {
NGramTokenizerFactory factory;
std::unordered_map<std::string, std::string> args;
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/indexpolicy/IndexPolicy.java
b/fe/fe-core/src/main/java/org/apache/doris/indexpolicy/IndexPolicy.java
index b4f149c650f..0158abb6e56 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/indexpolicy/IndexPolicy.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/indexpolicy/IndexPolicy.java
@@ -135,11 +135,7 @@ public class IndexPolicy implements Writable,
GsonPostProcessable {
}
if (type == IndexPolicyTypeEnum.TOKENIZER
&& "ngram".equals(properties.get(PROP_TYPE))) {
- try {
- new NGramTokenizerValidator().validate(properties);
- } catch (DdlException | RuntimeException e) {
- return true;
- }
+ return !NGramTokenizerValidator.isValidPolicy(properties);
}
return false;
}
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/indexpolicy/IndexPolicyMgr.java
b/fe/fe-core/src/main/java/org/apache/doris/indexpolicy/IndexPolicyMgr.java
index f00914dc2c1..29838730406 100644
--- a/fe/fe-core/src/main/java/org/apache/doris/indexpolicy/IndexPolicyMgr.java
+++ b/fe/fe-core/src/main/java/org/apache/doris/indexpolicy/IndexPolicyMgr.java
@@ -332,6 +332,11 @@ public class IndexPolicyMgr implements Writable,
GsonPostProcessable {
Map<String, String> storedProperties = properties == null
? null : Maps.newHashMap(properties);
validatePolicyProperties(type, storedProperties);
+ if (type == IndexPolicyTypeEnum.TOKENIZER
+ &&
"ngram".equals(storedProperties.get(IndexPolicy.PROP_TYPE))) {
+ // Persist the marker so replay applies the size limit to
newly created policies.
+ storedProperties.putIfAbsent("max_ngram_diff", "1");
+ }
IndexPolicy indexPolicy = IndexPolicy.create(policyName, type,
storedProperties);
if (nameToIndexPolicy.containsKey(normalizedName)) {
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/indexpolicy/NGramTokenizerValidator.java
b/fe/fe-core/src/main/java/org/apache/doris/indexpolicy/NGramTokenizerValidator.java
index 03c08cbda6c..df16c633e8a 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/indexpolicy/NGramTokenizerValidator.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/indexpolicy/NGramTokenizerValidator.java
@@ -27,14 +27,38 @@ import java.util.Map;
import java.util.Set;
public class NGramTokenizerValidator extends BasePolicyValidator {
+ // A configured range can emit one token per gram size at every input
position.
+ static final int MAX_NGRAM_DIFF = 255;
+ // NGramTokenizer keeps four code-point slots per configured gram plus a
refill margin.
+ static final int MAX_NGRAM_SIZE = 1024;
+
private static final Set<String> ALLOWED_PROPS = ImmutableSet.of(
- "type", "min_gram", "max_gram", "token_chars",
"custom_token_chars");
+ "type", "min_gram", "max_gram", "max_ngram_diff", "token_chars",
"custom_token_chars");
private static final Set<String> VALID_TOKEN_CHARS = ImmutableSet.of(
"letter", "digit", "whitespace", "punctuation", "symbol",
"custom");
+ private final boolean enforceAbsoluteSizeLimit;
+
public NGramTokenizerValidator() {
+ this(true);
+ }
+
+ private NGramTokenizerValidator(boolean enforceAbsoluteSizeLimit) {
super(ALLOWED_PROPS);
+ this.enforceAbsoluteSizeLimit = enforceAbsoluteSizeLimit;
+ }
+
+ static boolean isValidPolicy(Map<String, String> properties) {
+ try {
+ // Policies created before max_ngram_diff existed have no
compatibility marker and
+ // must retain the absolute-size behavior accepted by the previous
release.
+ boolean hasCompatibilityMarker =
properties.containsKey("max_ngram_diff");
+ new
NGramTokenizerValidator(hasCompatibilityMarker).validate(properties);
+ return true;
+ } catch (DdlException | RuntimeException e) {
+ return false;
+ }
}
@Override
@@ -76,6 +100,35 @@ public class NGramTokenizerValidator extends
BasePolicyValidator {
throw new DdlException("max_gram [" + maxGram + "] "
+ "cannot be smaller than min_gram [" + minGram + "]");
}
+ if (enforceAbsoluteSizeLimit
+ && (minGram > MAX_NGRAM_SIZE || maxGram > MAX_NGRAM_SIZE)) {
+ throw new DdlException("min_gram and max_gram must be less than or
equal to " + MAX_NGRAM_SIZE);
+ }
+
+ int maxNgramDiff = 1;
+ if (props.containsKey("max_ngram_diff")) {
+ String value = props.get("max_ngram_diff");
+ if (!value.matches("-?[0-9]+")) {
+ throw new DdlException("max_ngram_diff must be a non-negative
integer");
+ }
+ try {
+ maxNgramDiff = Integer.parseInt(value);
+ if (maxNgramDiff < 0) {
+ throw new DdlException("max_ngram_diff must be greater
than or equal to 0");
+ }
+ if (maxNgramDiff > MAX_NGRAM_DIFF) {
+ throw new DdlException("max_ngram_diff must be less than
or equal to " + MAX_NGRAM_DIFF);
+ }
+ } catch (NumberFormatException e) {
+ throw new DdlException("max_ngram_diff must be a non-negative
integer");
+ }
+ }
+
+ int ngramDiff = maxGram - minGram;
+ if (ngramDiff > maxNgramDiff) {
+ throw new DdlException("The difference between max_gram and
min_gram in NGram Tokenizer must be less "
+ + "than or equal to: [ " + maxNgramDiff + " ] but was [" +
ngramDiff + "]");
+ }
if (props.containsKey("token_chars")) {
String tokenChars = props.get("token_chars");
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/analysis/invertedindex/AnalyzerIdentityBuilderTest.java
b/fe/fe-core/src/test/java/org/apache/doris/analysis/invertedindex/AnalyzerIdentityBuilderTest.java
index 818b834da5b..32b5f668cef 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/analysis/invertedindex/AnalyzerIdentityBuilderTest.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/analysis/invertedindex/AnalyzerIdentityBuilderTest.java
@@ -150,6 +150,42 @@ public class AnalyzerIdentityBuilderTest {
}
}
+ @Test
+ public void testNgramValidationLimitDoesNotChangeAnalyzerIdentity() {
+ IndexPolicyMgr policyMgr = Mockito.mock(IndexPolicyMgr.class);
+ Env env = Mockito.mock(Env.class);
+ Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr);
+
+ Map<String, String> tokenizerProps = new HashMap<>();
+ tokenizerProps.put(IndexPolicy.PROP_TYPE, "ngram");
+ tokenizerProps.put("min_gram", "1");
+ tokenizerProps.put("max_gram", "2");
+ tokenizerProps.put("max_ngram_diff", "7");
+ IndexPolicy tokenizerWithLimit = new IndexPolicy(
+ 1, "ngram_with_limit", IndexPolicyTypeEnum.TOKENIZER,
tokenizerProps);
+
+ Map<String, String> equivalentTokenizerProps = new
HashMap<>(tokenizerProps);
+ equivalentTokenizerProps.remove("max_ngram_diff");
+ IndexPolicy tokenizerWithoutLimit = new IndexPolicy(
+ 2, "ngram_without_limit", IndexPolicyTypeEnum.TOKENIZER,
equivalentTokenizerProps);
+
+ IndexPolicy analyzerWithLimit = analyzerPolicy(3,
"analyzer_with_limit", "ngram_with_limit");
+ IndexPolicy analyzerWithoutLimit = analyzerPolicy(4,
"analyzer_without_limit", "ngram_without_limit");
+
Mockito.when(policyMgr.getPolicyByName("ngram_with_limit")).thenReturn(tokenizerWithLimit);
+
Mockito.when(policyMgr.getPolicyByName("ngram_without_limit")).thenReturn(tokenizerWithoutLimit);
+
Mockito.when(policyMgr.getPolicyByName("analyzer_with_limit")).thenReturn(analyzerWithLimit);
+
Mockito.when(policyMgr.getPolicyByName("analyzer_without_limit")).thenReturn(analyzerWithoutLimit);
+
+ try (MockedStatic<Env> mockedEnv = Mockito.mockStatic(Env.class)) {
+ mockedEnv.when(Env::getCurrentEnv).thenReturn(env);
+ String identityWithLimit =
AnalyzerIdentityBuilder.buildAnalyzerIdentity(
+ nonEmptyProperties(), "analyzer_with_limit", "",
"__default__", "none", null);
+ String identityWithoutLimit =
AnalyzerIdentityBuilder.buildAnalyzerIdentity(
+ nonEmptyProperties(), "analyzer_without_limit", "",
"__default__", "none", null);
+ Assertions.assertEquals(identityWithoutLimit, identityWithLimit);
+ }
+ }
+
@Test
public void testReplayedInvalidNgramDoesNotBlockValidReplacement() throws
Exception {
IndexPolicyMgr policyMgr = new IndexPolicyMgr();
@@ -189,6 +225,45 @@ public class AnalyzerIdentityBuilderTest {
}
}
+ @Test
+ public void testReplayedNgramDifferenceLimitDoesNotBlockValidReplacement()
throws Exception {
+ IndexPolicyMgr policyMgr = new IndexPolicyMgr();
+ Env env = Mockito.mock(Env.class);
+ Mockito.when(env.getIndexPolicyMgr()).thenReturn(policyMgr);
+
+ Map<String, String> invalidProps = new HashMap<>();
+ invalidProps.put(IndexPolicy.PROP_TYPE, "ngram");
+ invalidProps.put("min_gram", "1");
+ invalidProps.put("max_gram", "8");
+ IndexPolicy invalidTokenizer = new IndexPolicy(
+ 10, "replayed_ngram", IndexPolicyTypeEnum.TOKENIZER,
invalidProps);
+
+ Map<String, String> replacementProps = new HashMap<>(invalidProps);
+ replacementProps.put("max_ngram_diff", "7");
+ IndexPolicy replacementTokenizer = new IndexPolicy(
+ 11, "replacement_ngram", IndexPolicyTypeEnum.TOKENIZER,
replacementProps);
+ IndexPolicy invalidAnalyzer = analyzerPolicy(12, "replayed_analyzer",
"replayed_ngram");
+ IndexPolicy replacementAnalyzer = analyzerPolicy(13,
"replacement_analyzer", "replacement_ngram");
+ policyMgr.replayCreateIndexPolicy(invalidTokenizer);
+ policyMgr.replayCreateIndexPolicy(replacementTokenizer);
+ policyMgr.replayCreateIndexPolicy(invalidAnalyzer);
+ policyMgr.replayCreateIndexPolicy(replacementAnalyzer);
+
+ Assertions.assertTrue(invalidTokenizer.isInvalid());
+ Assertions.assertFalse(replacementTokenizer.isInvalid());
+ Assertions.assertThrows(DdlException.class,
+ () -> policyMgr.validateAnalyzerExists("replayed_analyzer"));
+
+ try (MockedStatic<Env> mockedEnv = Mockito.mockStatic(Env.class)) {
+ mockedEnv.when(Env::getCurrentEnv).thenReturn(env);
+ String invalidIdentity =
AnalyzerIdentityBuilder.buildAnalyzerIdentity(
+ nonEmptyProperties(), "replayed_analyzer", "",
"__default__", "none", null);
+ String replacementIdentity =
AnalyzerIdentityBuilder.buildAnalyzerIdentity(
+ nonEmptyProperties(), "replacement_analyzer", "",
"__default__", "none", null);
+ Assertions.assertNotEquals(invalidIdentity, replacementIdentity);
+ }
+ }
+
@Test
public void testReplayedLegacyLargeNgramAnalyzerRemainsUsable() throws
Exception {
IndexPolicyMgr policyMgr = new IndexPolicyMgr();
diff --git
a/fe/fe-core/src/test/java/org/apache/doris/indexpolicy/PolicyValidatorTests.java
b/fe/fe-core/src/test/java/org/apache/doris/indexpolicy/PolicyValidatorTests.java
index 0cc01113262..0a99eaf52f3 100644
---
a/fe/fe-core/src/test/java/org/apache/doris/indexpolicy/PolicyValidatorTests.java
+++
b/fe/fe-core/src/test/java/org/apache/doris/indexpolicy/PolicyValidatorTests.java
@@ -127,10 +127,134 @@ public class PolicyValidatorTests {
NGramTokenizerValidator validator = new NGramTokenizerValidator();
Map<String, String> props = new HashMap<>();
props.put("min_gram", "3");
- props.put("max_gram", "5");
+ props.put("max_gram", "4");
+ validator.validate(props); // Should not throw
+ }
+
+ @Test
+ public void testNGramValidator_DefaultDifferenceLimit() {
+ NGramTokenizerValidator validator = new NGramTokenizerValidator();
+ Map<String, String> props = new HashMap<>();
+ props.put("min_gram", "1");
+ props.put("max_gram", "8");
+
+ Exception exception = Assertions.assertThrows(DdlException.class,
+ () -> validator.validate(props));
+ Assertions.assertTrue(exception.getMessage().contains("less than or
equal to: [ 1 ]"));
+ }
+
+ @Test
+ public void testNGramValidator_ConfiguredDifferenceLimit() throws
Exception {
+ NGramTokenizerValidator validator = new NGramTokenizerValidator();
+ Map<String, String> props = new HashMap<>();
+ props.put("min_gram", "1");
+ props.put("max_gram", "8");
+ props.put("max_ngram_diff", "7");
validator.validate(props); // Should not throw
}
+ @Test
+ public void testNGramValidator_InvalidDifferenceLimit() {
+ NGramTokenizerValidator validator = new NGramTokenizerValidator();
+ Map<String, String> props = new HashMap<>();
+ props.put("max_ngram_diff", "-1");
+
+ Exception exception = Assertions.assertThrows(DdlException.class,
+ () -> validator.validate(props));
+ Assertions.assertTrue(exception.getMessage().contains("greater than or
equal to 0"));
+ }
+
+ @Test
+ public void testNGramValidator_RejectsNonAsciiDifferenceLimit() {
+ NGramTokenizerValidator validator = new NGramTokenizerValidator();
+ Map<String, String> props = new HashMap<>();
+ props.put("max_ngram_diff", "٧");
+
+ Exception exception = Assertions.assertThrows(DdlException.class,
+ () -> validator.validate(props));
+ Assertions.assertTrue(exception.getMessage().contains("non-negative
integer"));
+ }
+
+ @Test
+ public void testNGramValidator_RejectsExcessiveDifferenceLimit() {
+ NGramTokenizerValidator validator = new NGramTokenizerValidator();
+ Map<String, String> props = new HashMap<>();
+ props.put("max_ngram_diff",
Integer.toString(NGramTokenizerValidator.MAX_NGRAM_DIFF + 1));
+
+ Exception exception = Assertions.assertThrows(DdlException.class,
+ () -> validator.validate(props));
+ Assertions.assertTrue(exception.getMessage().contains("less than or
equal to 255"));
+ }
+
+ @Test
+ public void testNGramValidator_AcceptsDifferenceLimitBoundary() {
+ NGramTokenizerValidator validator = new NGramTokenizerValidator();
+ Map<String, String> props = new HashMap<>();
+ props.put("min_gram", "1");
+ props.put("max_gram",
Integer.toString(NGramTokenizerValidator.MAX_NGRAM_DIFF + 1));
+ props.put("max_ngram_diff",
Integer.toString(NGramTokenizerValidator.MAX_NGRAM_DIFF));
+
+ Assertions.assertDoesNotThrow(() -> validator.validate(props));
+ }
+
+ @Test
+ public void testNGramValidator_AcceptsAbsoluteSizeBoundary() {
+ NGramTokenizerValidator validator = new NGramTokenizerValidator();
+ Map<String, String> props = new HashMap<>();
+ props.put("min_gram",
Integer.toString(NGramTokenizerValidator.MAX_NGRAM_SIZE));
+ props.put("max_gram",
Integer.toString(NGramTokenizerValidator.MAX_NGRAM_SIZE));
+
+ Assertions.assertDoesNotThrow(() -> validator.validate(props));
+ }
+
+ @Test
+ public void testNGramValidator_RejectsExcessiveAbsoluteSize() {
+ NGramTokenizerValidator validator = new NGramTokenizerValidator();
+ Map<String, String> props = new HashMap<>();
+ props.put("min_gram",
Integer.toString(NGramTokenizerValidator.MAX_NGRAM_SIZE));
+ props.put("max_gram",
Integer.toString(NGramTokenizerValidator.MAX_NGRAM_SIZE + 1));
+
+ Exception exception = Assertions.assertThrows(DdlException.class,
+ () -> validator.validate(props));
+ Assertions.assertTrue(exception.getMessage().contains("less than or
equal to 1024"));
+ }
+
+ @Test
+ public void
testLegacyNGramPolicyAboveCurrentLimitRemainsValidAfterReplay() throws
Exception {
+ Map<String, String> props = new HashMap<>();
+ props.put(IndexPolicy.PROP_TYPE, "ngram");
+ props.put("min_gram", "2048");
+ props.put("max_gram", "2048");
+
+ IndexPolicy replayed = roundTrip(new IndexPolicy(
+ 1, "legacy_large_ngram", IndexPolicyTypeEnum.TOKENIZER,
props));
+
+ Assertions.assertFalse(replayed.isInvalid());
+
+ props.put("max_ngram_diff", "1");
+ IndexPolicy current = roundTrip(new IndexPolicy(
+ 2, "current_large_ngram", IndexPolicyTypeEnum.TOKENIZER,
props));
+ Assertions.assertTrue(current.isInvalid());
+ }
+
+ @Test
+ public void testNewNGramPolicyPersistsCompatibilityMarker() throws
Exception {
+ Env env = Mockito.mock(Env.class);
+ Mockito.when(env.getNextId()).thenReturn(2L);
+ Mockito.when(env.getEditLog()).thenReturn(Mockito.mock(EditLog.class));
+ IndexPolicyMgr policyMgr = new IndexPolicyMgr();
+ Map<String, String> props = new HashMap<>();
+ props.put(IndexPolicy.PROP_TYPE, "ngram");
+
+ try (MockedStatic<Env> mockedEnv = Mockito.mockStatic(Env.class)) {
+ mockedEnv.when(Env::getCurrentEnv).thenReturn(env);
+ policyMgr.createIndexPolicy(false, "new_ngram",
IndexPolicyTypeEnum.TOKENIZER, props);
+ }
+
+ Assertions.assertEquals("1",
+
policyMgr.getPolicyByName("new_ngram").getProperties().get("max_ngram_diff"));
+ }
+
// StandardTokenizerValidator Tests
@Test
public void testStandardTokenizerValidator_ValidProperties() throws
Exception {
@@ -236,6 +360,12 @@ public class PolicyValidatorTests {
Assertions.assertTrue(exception.getMessage().contains("enclosed in
square brackets"));
}
+ private static IndexPolicy roundTrip(IndexPolicy policy) throws Exception {
+ ByteArrayOutputStream bytes = new ByteArrayOutputStream();
+ policy.write(new DataOutputStream(bytes));
+ return IndexPolicy.read(new DataInputStream(new
ByteArrayInputStream(bytes.toByteArray())));
+ }
+
private static IndexPolicyMgr roundTrip(IndexPolicyMgr manager) throws
Exception {
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
manager.write(new DataOutputStream(bytes));
diff --git
a/regression-test/data/inverted_index_p0/analyzer/test_ngram_max_diff_custom_analyzer.out
b/regression-test/data/inverted_index_p0/analyzer/test_ngram_max_diff_custom_analyzer.out
new file mode 100644
index 00000000000..07b08b46c6e
--- /dev/null
+++
b/regression-test/data/inverted_index_p0/analyzer/test_ngram_max_diff_custom_analyzer.out
@@ -0,0 +1,4 @@
+-- This file is automatically generated. You should know what you did if you
want to edit this
+-- !ngram_tokens --
+[{\n "token": "a"\n }, {\n "token": "ab"\n }, {\n
"token": "abc"\n }, {\n "token": "abcd"\n }, {\n "token":
"abcde"\n }, {\n "token": "abcdef"\n }, {\n "token":
"abcdefg"\n }, {\n "token": "abcdefgh"\n }, {\n "token":
"b"\n }, {\n "token": "bc"\n }, {\n "token": "bcd"\n },
{\n "token": "bcde"\n }, {\n "token": "bcdef"\n }, {\n
"token": "bcdefg"\n }, [...]
+
diff --git
a/regression-test/suites/inverted_index_p0/analyzer/test_ngram_max_diff_custom_analyzer.groovy
b/regression-test/suites/inverted_index_p0/analyzer/test_ngram_max_diff_custom_analyzer.groovy
new file mode 100644
index 00000000000..5e9a84e86d9
--- /dev/null
+++
b/regression-test/suites/inverted_index_p0/analyzer/test_ngram_max_diff_custom_analyzer.groovy
@@ -0,0 +1,69 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+suite("test_ngram_max_diff_custom_analyzer", "p0") {
+ def defaultLimitTokenizer = "test_ngram_default_limit_tokenizer"
+ def ngramTokenizer = "test_ngram_1_8_tokenizer"
+ def ngramAnalyzer = "test_ngram_1_8_analyzer"
+
+ try_sql "DROP INVERTED INDEX ANALYZER IF EXISTS ${ngramAnalyzer}"
+ try_sql "DROP INVERTED INDEX TOKENIZER IF EXISTS ${defaultLimitTokenizer}"
+ try_sql "DROP INVERTED INDEX TOKENIZER IF EXISTS ${ngramTokenizer}"
+
+ test {
+ sql """
+ CREATE INVERTED INDEX TOKENIZER ${defaultLimitTokenizer}
+ PROPERTIES (
+ "type" = "ngram",
+ "min_gram" = "1",
+ "max_gram" = "8"
+ )
+ """
+ exception "less than or equal to: [ 1 ]"
+ }
+
+ sql """
+ CREATE INVERTED INDEX TOKENIZER IF NOT EXISTS ${ngramTokenizer}
+ PROPERTIES (
+ "type" = "ngram",
+ "min_gram" = "1",
+ "max_gram" = "8",
+ "max_ngram_diff" = "7"
+ )
+ """
+ sql """
+ CREATE INVERTED INDEX ANALYZER IF NOT EXISTS ${ngramAnalyzer}
+ PROPERTIES ("tokenizer" = "${ngramTokenizer}")
+ """
+
+ int maxRetry = 30
+ Exception lastException = null
+ for (int i = 0; i < maxRetry; i++) {
+ try {
+ sql """SELECT TOKENIZE('probe', '"analyzer"="${ngramAnalyzer}"')"""
+ lastException = null
+ break
+ } catch (Exception e) {
+ lastException = e
+ sleep(1000)
+ }
+ }
+ assertTrue(lastException == null,
+ "Analyzer ${ngramAnalyzer} was not ready:
${lastException?.message}")
+
+ qt_ngram_tokens """SELECT TOKENIZE('abcdefgh',
'"analyzer"="${ngramAnalyzer}"')"""
+}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]