This is an automated email from the ASF dual-hosted git repository.
airborne12 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git
The following commit(s) were added to refs/heads/master by this push:
new 6a79f954e26 [feat](inverted-index) Support configurable ngram size
difference (#67917)
6a79f954e26 is described below
commit 6a79f954e2617c807173aad9796cfd6138c14055
Author: Jack <[email protected]>
AuthorDate: Tue Sep 15 10:05:00 2026 +0800
[feat](inverted-index) Support configurable ngram size difference (#67917)
### What problem does this PR solve?
Issue Number:
Related PR: None
Problem Summary:
Custom ngram tokenizers hard-code the allowed difference between
max_gram and min_gram to 1. This prevents valid wider ngram ranges while
offering no explicit override.
This PR adds max_ngram_diff, keeps 1 as the backward-compatible default,
requires ASCII integer syntax, and validates the same 0 through 255
range in FE and BE. The upper bound limits per-position token fan-out.
Newly created policies also cap absolute custom ngram sizes at 1024,
while an explicit persisted compatibility marker preserves marker-less
policies accepted before that cap during rolling upgrades. Invalid
replayed policies cannot block valid replacements. Because
max_ngram_diff controls policy admission but does not affect emitted
tokens, valid policies exclude it from analyzer identity so equivalent
analyzers cannot bypass duplicate-index detection.
### 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.
---
.../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 | 83 ++++++++++++-
.../invertedindex/AnalyzerIdentityBuilder.java | 10 ++
.../org/apache/doris/indexpolicy/IndexPolicy.java | 7 +-
.../apache/doris/indexpolicy/IndexPolicyMgr.java | 49 +++++---
.../doris/indexpolicy/NGramTokenizerValidator.java | 55 ++++++++-
.../invertedindex/AnalyzerIdentityBuilderTest.java | 107 +++++++++++++++++
.../doris/indexpolicy/PolicyValidatorTests.java | 129 +++++++++++++++++++++
.../test_ngram_max_diff_custom_analyzer.out | 4 +
.../test_ngram_max_diff_custom_analyzer.groovy | 69 +++++++++++
13 files changed, 531 insertions(+), 23 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 a0b253720b5..33a39c1f0ae 100644
--- a/be/src/storage/index/inverted/tokenizer/ngram/ngram_tokenizer.cpp
+++ b/be/src/storage/index/inverted/tokenizer/ngram/ngram_tokenizer.cpp
@@ -103,7 +103,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 92267becbd7..ffc45bf3de9 100644
--- a/be/src/storage/index/inverted/tokenizer/ngram/ngram_tokenizer.h
+++ b/be/src/storage/index/inverted/tokenizer/ngram/ngram_tokenizer.h
@@ -75,4 +75,4 @@ private:
std::string _utf8_buffer;
};
-} // 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 2ee428e32ff..8b91369b51d 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;
@@ -65,4 +70,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 b9dca64838f..ac3d42cdcca 100644
--- a/be/test/storage/index/inverted/tokenizer/ngram_tokenizer_test.cpp
+++ b/be/test/storage/index/inverted/tokenizer/ngram_tokenizer_test.cpp
@@ -87,6 +87,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;
@@ -202,4 +283,4 @@ TEST(NGramTokenizerTest, WhitespaceTokenization) {
ASSERT_EQ(tokens, expected);
}
-} // namespace doris::segment_v2
\ No newline at end of file
+} // namespace doris::segment_v2
diff --git
a/fe/fe-core/src/main/java/org/apache/doris/analysis/invertedindex/AnalyzerIdentityBuilder.java
b/fe/fe-core/src/main/java/org/apache/doris/analysis/invertedindex/AnalyzerIdentityBuilder.java
index 1e640fd7d22..310c442c3ce 100644
---
a/fe/fe-core/src/main/java/org/apache/doris/analysis/invertedindex/AnalyzerIdentityBuilder.java
+++
b/fe/fe-core/src/main/java/org/apache/doris/analysis/invertedindex/AnalyzerIdentityBuilder.java
@@ -28,6 +28,8 @@ import java.util.Map;
import java.util.TreeMap;
public final class AnalyzerIdentityBuilder {
+ private static final String PROP_MAX_NGRAM_DIFF = "max_ngram_diff";
+
private AnalyzerIdentityBuilder() {
}
@@ -169,6 +171,9 @@ public final class AnalyzerIdentityBuilder {
if (policy == null || policy.getType() != expectedType) {
return name;
}
+ if (policy.isInvalid()) {
+ return "invalid-policy:" + policy.getId() + ":" +
policy.getName();
+ }
Map<String, String> props = policy.getProperties();
if (props == null || props.isEmpty()) {
@@ -177,6 +182,11 @@ public final class AnalyzerIdentityBuilder {
// Build identity from sorted properties
TreeMap<String, String> sortedProps = new TreeMap<>(props);
+ if (expectedType == IndexPolicyTypeEnum.TOKENIZER
+ && "ngram".equals(sortedProps.get(IndexPolicy.PROP_TYPE)))
{
+ // This setting only limits policy creation; it does not
change emitted tokens.
+ sortedProps.remove(PROP_MAX_NGRAM_DIFF);
+ }
return sortedProps.toString();
} catch (RuntimeException e) {
return name;
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 31d51a06c99..12454b52c70 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
@@ -129,8 +129,13 @@ public class IndexPolicy implements Writable,
GsonPostProcessable {
ImmutableSet.of("common_grams");
public boolean isInvalid() {
- return type == IndexPolicyTypeEnum.TOKEN_FILTER
+ boolean hasUnsupportedTokenFilter = type ==
IndexPolicyTypeEnum.TOKEN_FILTER
&& properties != null
&&
LEGACY_UNSUPPORTED_TOKEN_FILTER_TYPES.contains(properties.get(PROP_TYPE));
+ boolean hasInvalidNgramTokenizer = type ==
IndexPolicyTypeEnum.TOKENIZER
+ && properties != null
+ && "ngram".equals(properties.get(PROP_TYPE))
+ && !NGramTokenizerValidator.isValidPolicy(properties);
+ return hasUnsupportedTokenFilter || hasInvalidNgramTokenizer;
}
}
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 b547c482d81..f7b5b81c108 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
@@ -110,22 +110,32 @@ public class IndexPolicyMgr implements Writable,
GsonPostProcessable {
if (policy.isInvalid()) {
throw new DdlException("Analyzer '" + analyzerName + "' is
invalid");
}
- validateReferencedTokenFiltersUsableLocked(analyzerName, policy);
+ validateReferencedComponentsUsableLocked(analyzerName, policy);
} finally {
readUnlock();
}
}
/**
- * Older metadata may retain token filter types no longer supported by BE,
such as common_grams.
- * Load these policies so a single obsolete policy cannot prevent FE
startup, but reject any
- * analyzer that references them at use time, before BE reports an unknown
token filter during
- * index construction or querying.
+ * Older metadata may retain components that current validation rejects.
Load these policies so
+ * one obsolete policy cannot prevent FE startup, but reject analyzers
that reference them before
+ * BE tries to construct the analyzer during index construction or
querying.
*/
- private void validateReferencedTokenFiltersUsableLocked(String
analyzerName, IndexPolicy analyzer)
+ private void validateReferencedComponentsUsableLocked(String analyzerName,
IndexPolicy analyzer)
throws DdlException {
- String tokenFilterNames = analyzer.getProperties() == null
- ? null :
analyzer.getProperties().get(IndexPolicy.PROP_TOKEN_FILTER);
+ Map<String, String> analyzerProperties = analyzer.getProperties();
+ if (analyzerProperties == null) {
+ return;
+ }
+ String tokenizerName =
analyzerProperties.get(IndexPolicy.PROP_TOKENIZER);
+ IndexPolicy tokenizer = tokenizerName == null
+ ? null : nameToIndexPolicy.get(normalizeKey(tokenizerName));
+ if (tokenizer != null && tokenizer.isInvalid()) {
+ throw new DdlException("Analyzer '" + analyzerName + "' references
invalid tokenizer '"
+ + tokenizerName + "'");
+ }
+
+ String tokenFilterNames =
analyzerProperties.get(IndexPolicy.PROP_TOKEN_FILTER);
if (tokenFilterNames == null || tokenFilterNames.isEmpty()) {
return;
}
@@ -186,8 +196,16 @@ public class IndexPolicyMgr implements Writable,
GsonPostProcessable {
writeLock();
try {
- validatePolicyProperties(type, properties);
- IndexPolicy indexPolicy = IndexPolicy.create(policyName, type,
properties);
+ Map<String, String> storedProperties = properties == null
+ ? null : Maps.newHashMap(properties);
+ validatePolicyProperties(type, storedProperties);
+ if (type == IndexPolicyTypeEnum.TOKENIZER
+ &&
"ngram".equals(storedProperties.get(IndexPolicy.PROP_TYPE))) {
+ // Presence distinguishes policies created with the
absolute-size limit from
+ // compatible policies replayed from a version before
max_ngram_diff existed.
+ storedProperties.putIfAbsent("max_ngram_diff", "1");
+ }
+ IndexPolicy indexPolicy = IndexPolicy.create(policyName, type,
storedProperties);
if (nameToIndexPolicy.containsKey(normalizedName)) {
if (ifNotExists) {
@@ -337,6 +355,9 @@ public class IndexPolicyMgr implements Writable,
GsonPostProcessable {
throw new DdlException("Referenced policy '" + name + "' is of
type "
+ policy.getType() + " but expected " + expectedType);
}
+ if (policy.isInvalid()) {
+ throw new DdlException("Referenced " + expectedType + " policy '"
+ name + "' is invalid");
+ }
}
private void validateTokenizerProperties(Map<String, String> properties)
throws DdlException {
@@ -667,10 +688,10 @@ public class IndexPolicyMgr implements Writable,
GsonPostProcessable {
private static void warnIfUnsupported(IndexPolicy indexPolicy) {
if (indexPolicy.isInvalid()) {
- LOG.error("Index policy '{}' (id={}) uses token filter type '{}',
which this version"
- + " no longer supports; analyzers referencing it will be
rejected. Drop the"
- + " indexes and policies that depend on it.",
indexPolicy.getName(),
- indexPolicy.getId(),
indexPolicy.getProperties().get(IndexPolicy.PROP_TYPE));
+ LOG.error("Index policy '{}' (id={}, type={}) is not valid in this
version; analyzers"
+ + " referencing it will be rejected. Drop the indexes and
policies that depend"
+ + " on it.", indexPolicy.getName(), indexPolicy.getId(),
+ indexPolicy.getProperties().get(IndexPolicy.PROP_TYPE));
}
}
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 e264b9831bb..c65afc497a1 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
@@ -17,10 +17,16 @@
package org.apache.doris.analysis.invertedindex;
+import org.apache.doris.catalog.Env;
+import org.apache.doris.common.DdlException;
import org.apache.doris.indexpolicy.IndexPolicy;
+import org.apache.doris.indexpolicy.IndexPolicyMgr;
+import org.apache.doris.indexpolicy.IndexPolicyTypeEnum;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
import java.util.HashMap;
import java.util.Iterator;
@@ -101,4 +107,105 @@ public class AnalyzerIdentityBuilderTest {
null);
Assertions.assertEquals("standard", identity);
}
+
+ @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();
+ 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();
+ Map<String, String> legacyProps = new HashMap<>();
+ legacyProps.put(IndexPolicy.PROP_TYPE, "ngram");
+ legacyProps.put("min_gram", "2048");
+ legacyProps.put("max_gram", "2048");
+ IndexPolicy legacyTokenizer = new IndexPolicy(
+ 20, "legacy_large_ngram", IndexPolicyTypeEnum.TOKENIZER,
legacyProps);
+ IndexPolicy legacyAnalyzer = analyzerPolicy(
+ 21, "legacy_large_analyzer", "legacy_large_ngram");
+
+ policyMgr.replayCreateIndexPolicy(legacyTokenizer);
+ policyMgr.replayCreateIndexPolicy(legacyAnalyzer);
+
+ Assertions.assertFalse(legacyTokenizer.isInvalid());
+ Assertions.assertDoesNotThrow(
+ () ->
policyMgr.validateAnalyzerExists("legacy_large_analyzer"));
+ }
+
+ private IndexPolicy analyzerPolicy(long id, String name, String tokenizer)
{
+ Map<String, String> properties = new HashMap<>();
+ properties.put(IndexPolicy.PROP_TOKENIZER, tokenizer);
+ return new IndexPolicy(id, name, IndexPolicyTypeEnum.ANALYZER,
properties);
+ }
}
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 4418d6270a4..dba7ab0d5b5 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
@@ -17,10 +17,14 @@
package org.apache.doris.indexpolicy;
+import org.apache.doris.catalog.Env;
import org.apache.doris.common.DdlException;
+import org.apache.doris.persist.EditLog;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
+import org.mockito.MockedStatic;
+import org.mockito.Mockito;
// import org.junit.jupiter.params.ParameterizedTest;
// import org.junit.jupiter.params.provider.ValueSource;
@@ -130,9 +134,134 @@ public class PolicyValidatorTests {
Map<String, String> props = new HashMap<>();
props.put("min_gram", "3");
props.put("max_gram", "5");
+ props.put("max_ngram_diff", "2");
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 {
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]