diff --git a/python/zvec/model/param/__init__.pyi b/python/zvec/model/param/__init__.pyi index a8c3b6560..8fcf80076 100644 --- a/python/zvec/model/param/__init__.pyi +++ b/python/zvec/model/param/__init__.pyi @@ -834,8 +834,8 @@ class FtsIndexParam(IndexParam): Attributes: type (IndexType): Always ``IndexType.FTS``. - tokenizer_name (str): Name of the tokenizer (one of "standard", "jieba", - "whitespace"). + tokenizer_name (str): Name of the tokenizer (one of "standard", "ngram", + "jieba", "whitespace"). Default is "standard". filters (list[str]): List of token filter names applied after tokenization. Supported filters are "lowercase", "ascii_folding", and "stemmer". @@ -845,6 +845,13 @@ class FtsIndexParam(IndexParam): Tokenizers: standard: - "max_token_length" (positive integer). + ngram: + - "ngram_min" (positive integer, default 2). + - "ngram_max" (positive integer, default 2). + - "token_chars" (array of "letter", "digit", + "whitespace", "punctuation", "symbol"; default [] keeps + all valid UTF-8 characters). custom_token_chars is not + supported. jieba: - "jieba_dict_dir" (directory containing jieba.dict.utf8 and hmm_model.utf8). @@ -892,6 +899,13 @@ class FtsIndexParam(IndexParam): Tokenizers: standard: - "max_token_length" (positive integer). + ngram: + - "ngram_min" (positive integer, default 2). + - "ngram_max" (positive integer, default 2). + - "token_chars" (array of "letter", "digit", + "whitespace", "punctuation", "symbol"; default [] + keeps all valid UTF-8 characters). custom_token_chars + is not supported. jieba: - "jieba_dict_dir". - "user_dict_path". diff --git a/src/binding/python/model/param/python_param.cc b/src/binding/python/model/param/python_param.cc index 3c3ff623b..3e9376223 100644 --- a/src/binding/python/model/param/python_param.cc +++ b/src/binding/python/model/param/python_param.cc @@ -257,8 +257,8 @@ Controls the tokenizer pipeline used during indexing and querying. Attributes: type (IndexType): Always ``IndexType.FTS``. - tokenizer_name (str): Name of the tokenizer (one of "standard", "jieba", - "whitespace"). + tokenizer_name (str): Name of the tokenizer (one of "standard", "ngram", + "jieba", "whitespace"). Default is "standard". filters (list[str]): List of token filter names applied after tokenization. Supported values include "lowercase", "ascii_folding", and "stemmer". @@ -268,6 +268,12 @@ Controls the tokenizer pipeline used during indexing and querying. Tokenizers: standard: - "max_token_length" (positive integer). + ngram: + - "ngram_min" (positive integer, default 2). + - "ngram_max" (positive integer, default 2). + - "token_chars" (array of "letter", "digit", "whitespace", + "punctuation", "symbol"; default [] keeps all valid UTF-8 + characters). custom_token_chars is not supported. jieba: - "jieba_dict_dir" (directory containing jieba.dict.utf8 and hmm_model.utf8). @@ -316,6 +322,12 @@ Constructs an FtsIndexParam instance. Tokenizers: standard: - "max_token_length" (positive integer). + ngram: + - "ngram_min" (positive integer, default 2). + - "ngram_max" (positive integer, default 2). + - "token_chars" (array of "letter", "digit", "whitespace", + "punctuation", "symbol"; default [] keeps all valid UTF-8 + characters). custom_token_chars is not supported. jieba: - "jieba_dict_dir". - "user_dict_path". diff --git a/src/db/index/column/fts_column/fts_types.h b/src/db/index/column/fts_column/fts_types.h index 46eee4657..483e42c40 100644 --- a/src/db/index/column/fts_column/fts_types.h +++ b/src/db/index/column/fts_column/fts_types.h @@ -51,7 +51,7 @@ struct FtsSegmentStats { }; struct FtsIndexParams { - // Supported tokenizers: standard, jieba, whitespace. + // Supported tokenizers: standard, ngram, jieba, whitespace. std::string tokenizer_name{"standard"}; // Supported filters: lowercase, ascii_folding. std::vector filters{"lowercase"}; diff --git a/src/db/index/column/fts_column/tokenizer/jieba_tokenizer.cc b/src/db/index/column/fts_column/tokenizer/jieba_tokenizer.cc index 81388127c..eb21cfb49 100644 --- a/src/db/index/column/fts_column/tokenizer/jieba_tokenizer.cc +++ b/src/db/index/column/fts_column/tokenizer/jieba_tokenizer.cc @@ -44,7 +44,7 @@ static std::string resolve_jieba_dict_dir(const ailego::JsonObject &config) { return GlobalConfig::Instance().jieba_dict_dir(); } -bool JiebaTokenizer::init(const ailego::JsonObject &config) { +Status JiebaTokenizer::init(const ailego::JsonObject &config) { std::string user_dict_path = get_string_or_default(config, "user_dict_path", ""); @@ -58,8 +58,8 @@ bool JiebaTokenizer::init(const ailego::JsonObject &config) { } else if (mode_str == "hmm") { cut_mode_ = CutMode::kHmm; } else { - LOG_ERROR("JiebaTokenizer: unknown cut_mode '%s'", mode_str.c_str()); - return false; + return Status::InvalidArgument("JiebaTokenizer: unknown cut_mode '", + mode_str, "'"); } bool needs_dict = cut_mode_ != CutMode::kHmm; @@ -67,12 +67,11 @@ bool JiebaTokenizer::init(const ailego::JsonObject &config) { std::string dict_dir = resolve_jieba_dict_dir(config); if ((needs_dict || needs_model) && dict_dir.empty()) { - LOG_ERROR( + return Status::InvalidArgument( "JiebaTokenizer: jieba_dict_dir not configured. Set via " "extra_params.jieba_dict_dir, ZVEC_JIEBA_DICT_DIR env var, " "or zvec.set_default_jieba_dict_dir() / " "zvec.init(jieba_dict_dir=...)."); - return false; } std::string dict_path = needs_dict ? dict_dir + "/jieba.dict.utf8" : ""; @@ -107,13 +106,13 @@ bool JiebaTokenizer::init(const ailego::JsonObject &config) { } catch (const std::exception &e) { LOG_ERROR("JiebaTokenizer init failed: %s", e.what()); reset(); - return false; + return Status::InvalidArgument("JiebaTokenizer init failed: ", e.what()); } initialized_ = true; LOG_INFO("JiebaTokenizer init success. dict_dir[%s] cut_mode[%s]", dict_dir.c_str(), mode_str.c_str()); - return true; + return Status::OK(); } JiebaTokenizer::~JiebaTokenizer() = default; diff --git a/src/db/index/column/fts_column/tokenizer/jieba_tokenizer.h b/src/db/index/column/fts_column/tokenizer/jieba_tokenizer.h index 591551ab8..2e7527517 100644 --- a/src/db/index/column/fts_column/tokenizer/jieba_tokenizer.h +++ b/src/db/index/column/fts_column/tokenizer/jieba_tokenizer.h @@ -47,7 +47,7 @@ class JiebaTokenizer : public Tokenizer { // jieba_dict_dir resolution: per-field > ZVEC_JIEBA_DICT_DIR > // zvec::GlobalConfig::jieba_dict_dir() (set by SDK on import or via init). // Stop-word filtering belongs to a TokenFilter, not here. - bool init(const ailego::JsonObject &config) override; + Status init(const ailego::JsonObject &config) override; std::vector tokenize(const std::string &text) const override; diff --git a/src/db/index/column/fts_column/tokenizer/ngram_tokenizer.cc b/src/db/index/column/fts_column/tokenizer/ngram_tokenizer.cc new file mode 100644 index 000000000..afb59837e --- /dev/null +++ b/src/db/index/column/fts_column/tokenizer/ngram_tokenizer.cc @@ -0,0 +1,253 @@ +// Copyright 2025-present the zvec project +// +// Licensed 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. + +#include "ngram_tokenizer.h" +#include +#include +#include +#include "unicode_utils.h" + +namespace zvec::fts { + +namespace { + +constexpr uint32_t kDefaultNGramLength = 2; +constexpr uint32_t kMaxNGramDiff = 1; +constexpr size_t kMaxInitialTokenCapacity = 4096; +constexpr uint32_t kNGramTokenCharLetter = 1u << 0; +constexpr uint32_t kNGramTokenCharDigit = 1u << 1; +constexpr uint32_t kNGramTokenCharWhitespace = 1u << 2; +constexpr uint32_t kNGramTokenCharPunctuation = 1u << 3; +constexpr uint32_t kNGramTokenCharSymbol = 1u << 4; + +struct CodepointSpan { + uint32_t start{0}; + uint32_t end{0}; +}; + +size_t estimate_token_capacity(size_t byte_size) { + size_t capacity = byte_size / 4 + 1; + return std::min(capacity, kMaxInitialTokenCapacity); +} + +Status parse_positive_uint32_param(const ailego::JsonObject &config, + const char *key, uint32_t default_value, + uint32_t *value) { + *value = default_value; + auto json_value = config[key]; + if (json_value.is_null()) { + return Status::OK(); + } + if (!json_value.is_integer()) { + return Status::InvalidArgument("NGramTokenizer: ", key, " must be integer"); + } + int64_t configured_value = json_value.as_integer(); + if (configured_value <= 0 || + configured_value > + static_cast(std::numeric_limits::max())) { + return Status::InvalidArgument("NGramTokenizer: ", key, + " must be positive uint32"); + } + *value = static_cast(configured_value); + return Status::OK(); +} + +Status parse_ngram_token_char(const std::string &token_char, uint32_t *mask) { + if (token_char == "letter") { + *mask |= kNGramTokenCharLetter; + return Status::OK(); + } + if (token_char == "digit") { + *mask |= kNGramTokenCharDigit; + return Status::OK(); + } + if (token_char == "whitespace") { + *mask |= kNGramTokenCharWhitespace; + return Status::OK(); + } + if (token_char == "punctuation") { + *mask |= kNGramTokenCharPunctuation; + return Status::OK(); + } + if (token_char == "symbol") { + *mask |= kNGramTokenCharSymbol; + return Status::OK(); + } + return Status::InvalidArgument( + "NGramTokenizer: unsupported token_chars value: ", token_char); +} + +Status parse_ngram_token_chars(const ailego::JsonObject &config, + uint32_t *mask) { + *mask = 0; + auto token_chars_value = config["token_chars"]; + if (token_chars_value.is_null()) { + return Status::OK(); + } + if (!token_chars_value.is_array()) { + return Status::InvalidArgument( + "NGramTokenizer: token_chars must be an array"); + } + + const auto &token_chars = token_chars_value.as_array(); + for (const auto &token_char_value : token_chars) { + if (!token_char_value.is_string()) { + return Status::InvalidArgument( + "NGramTokenizer: token_chars entries must be strings"); + } + auto status = + parse_ngram_token_char(token_char_value.as_stl_string(), mask); + if (!status.ok()) { + return status; + } + } + return Status::OK(); +} + +bool is_ngram_whitespace(uint32_t codepoint, utf8proc_category_t category) { + if ((codepoint >= 0x0009 && codepoint <= 0x000D) || + (codepoint >= 0x001C && codepoint <= 0x001F)) { + return true; + } + if (category == UTF8PROC_CATEGORY_ZL || category == UTF8PROC_CATEGORY_ZP) { + return true; + } + return category == UTF8PROC_CATEGORY_ZS && codepoint != 0x00A0 && + codepoint != 0x2007 && codepoint != 0x202F; +} + +uint32_t ngram_token_char_bit(const UnicodeCodepoint &codepoint) { + auto category = utf8proc_category(codepoint.cp); + if (is_ngram_whitespace(static_cast(codepoint.cp), category)) { + return kNGramTokenCharWhitespace; + } + if (category == UTF8PROC_CATEGORY_ND) { + return kNGramTokenCharDigit; + } + if (category == UTF8PROC_CATEGORY_LU || category == UTF8PROC_CATEGORY_LL || + category == UTF8PROC_CATEGORY_LT || category == UTF8PROC_CATEGORY_LM || + category == UTF8PROC_CATEGORY_LO) { + return kNGramTokenCharLetter; + } + if (category == UTF8PROC_CATEGORY_PC || category == UTF8PROC_CATEGORY_PD || + category == UTF8PROC_CATEGORY_PS || category == UTF8PROC_CATEGORY_PE || + category == UTF8PROC_CATEGORY_PI || category == UTF8PROC_CATEGORY_PF || + category == UTF8PROC_CATEGORY_PO) { + return kNGramTokenCharPunctuation; + } + if (category == UTF8PROC_CATEGORY_SM || category == UTF8PROC_CATEGORY_SC || + category == UTF8PROC_CATEGORY_SK || category == UTF8PROC_CATEGORY_SO) { + return kNGramTokenCharSymbol; + } + return 0; +} + +bool is_ngram_token_char(const UnicodeCodepoint &codepoint, + uint32_t token_char_mask) { + if (!codepoint.valid) { + return false; + } + if (token_char_mask == 0) { + return true; + } + return (ngram_token_char_bit(codepoint) & token_char_mask) != 0; +} + +std::vector> collect_ngram_segments( + const std::string &text, uint32_t token_char_mask) { + std::vector> spans; + spans.reserve(estimate_token_capacity(text.size())); + std::vector codepoints = decode_utf8_codepoints(text); + std::vector span; + + for (const auto &codepoint : codepoints) { + if (is_ngram_token_char(codepoint, token_char_mask)) { + span.push_back({codepoint.start, codepoint.end}); + continue; + } + if (!span.empty()) { + spans.push_back(std::move(span)); + span.clear(); + } + } + if (!span.empty()) { + spans.push_back(std::move(span)); + } + return spans; +} + +void emit_ngram_tokens(const std::string &text, + const std::vector &span, + uint32_t ngram_min, uint32_t ngram_max, + uint32_t *position, std::vector *tokens) { + if (span.size() < ngram_min) { + return; + } + + for (size_t start = 0; start < span.size(); ++start) { + size_t remaining = span.size() - start; + size_t upper = std::min(ngram_max, remaining); + for (size_t length = ngram_min; length <= upper; ++length) { + const CodepointSpan &first = span[start]; + const CodepointSpan &last = span[start + length - 1]; + size_t token_bytes = last.end - first.start; + Token token; + token.text = text.substr(first.start, token_bytes); + token.offset = first.start; + token.position = (*position)++; + tokens->push_back(std::move(token)); + } + } +} + +} // namespace + +Status NGramTokenizer::init(const ailego::JsonObject &config) { + auto status = parse_positive_uint32_param(config, "ngram_min", + kDefaultNGramLength, &ngram_min_); + if (!status.ok()) { + return status; + } + status = parse_positive_uint32_param(config, "ngram_max", kDefaultNGramLength, + &ngram_max_); + if (!status.ok()) { + return status; + } + if (ngram_min_ > ngram_max_) { + return Status::InvalidArgument( + "NGramTokenizer: ngram_min must be <= ngram_max"); + } + if (ngram_max_ - ngram_min_ > kMaxNGramDiff) { + return Status::InvalidArgument( + "NGramTokenizer: ngram_max - ngram_min must be <= ", kMaxNGramDiff); + } + status = parse_ngram_token_chars(config, &token_char_mask_); + if (!status.ok()) { + return status; + } + return Status::OK(); +} + +std::vector NGramTokenizer::tokenize(const std::string &text) const { + std::vector tokens; + tokens.reserve(estimate_token_capacity(text.size())); + uint32_t position = 0; + auto spans = collect_ngram_segments(text, token_char_mask_); + for (const auto &span : spans) { + emit_ngram_tokens(text, span, ngram_min_, ngram_max_, &position, &tokens); + } + return tokens; +} + +} // namespace zvec::fts diff --git a/src/db/index/column/fts_column/tokenizer/ngram_tokenizer.h b/src/db/index/column/fts_column/tokenizer/ngram_tokenizer.h new file mode 100644 index 000000000..3cb0a5754 --- /dev/null +++ b/src/db/index/column/fts_column/tokenizer/ngram_tokenizer.h @@ -0,0 +1,54 @@ +// Copyright 2025-present the zvec project +// +// Licensed 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. + +#pragma once + +#include +#include "tokenizer.h" + +namespace zvec::fts { + +/*! NGram tokenizer + * Unicode-aware tokenizer that emits UTF-8 codepoint ngrams. Consecutive + * matching characters remain in the same base span so CJK ngrams can be + * generated. + */ +class NGramTokenizer : public Tokenizer { + public: + /*! Initialise from JSON config. + * Supported keys: + * "ngram_min" (positive integer, default 2): minimum ngram length. + * "ngram_max" (positive integer, default 2): maximum ngram length. + * ngram_max - ngram_min must not exceed 1. + * "token_chars" (array of strings, default []): character classes included + * in tokens. Supported classes are "letter", "digit", "whitespace", + * "punctuation" and "symbol". Empty array keeps all valid UTF-8 + * characters. + * Returns an error status when the configuration is invalid. + */ + Status init(const ailego::JsonObject &config) override; + + std::vector tokenize(const std::string &text) const override; + + const char *name() const override { + return "ngram"; + } + + private: + uint32_t ngram_min_{2}; + uint32_t ngram_max_{2}; + uint32_t token_char_mask_{0}; +}; + +} // namespace zvec::fts diff --git a/src/db/index/column/fts_column/tokenizer/standard_tokenizer.cc b/src/db/index/column/fts_column/tokenizer/standard_tokenizer.cc index fc612e805..e037d9e3d 100644 --- a/src/db/index/column/fts_column/tokenizer/standard_tokenizer.cc +++ b/src/db/index/column/fts_column/tokenizer/standard_tokenizer.cc @@ -13,9 +13,10 @@ // limitations under the License. #include "standard_tokenizer.h" -#include +#include #include #include +#include "unicode_utils.h" namespace zvec::fts { @@ -60,10 +61,7 @@ enum class WordBreakClass : uint8_t { ExtendedPictographic, }; -struct Codepoint { - utf8proc_int32_t cp{0}; - uint32_t start{0}; - uint32_t end{0}; +struct StandardCodepoint : UnicodeCodepoint { WordBreakClass cls{WordBreakClass::Other}; bool extended_pictographic{false}; bool emoji_modifier_base{false}; @@ -286,11 +284,12 @@ bool is_extend_or_format(WordBreakClass cls) { return cls == WordBreakClass::Extend || cls == WordBreakClass::Format; } -bool previous_is_zwj(const std::vector &codepoints, size_t index) { +bool previous_is_zwj(const std::vector &codepoints, + size_t index) { return index > 0 && codepoints[index - 1].cls == WordBreakClass::ZWJ; } -bool is_extended_pictographic_codepoint(const Codepoint &codepoint) { +bool is_extended_pictographic_codepoint(const StandardCodepoint &codepoint) { return codepoint.cls == WordBreakClass::ExtendedPictographic || codepoint.extended_pictographic; } @@ -330,67 +329,7 @@ bool is_hangul_syllable(uint32_t codepoint) { return codepoint >= 0xAC00 && codepoint <= 0xD7A3; } -bool is_utf8_continuation(unsigned char ch) { - return (ch & 0xC0) == 0x80; -} - -bool decode_utf8_codepoint(const utf8proc_uint8_t *str, size_t len, - size_t index, utf8proc_int32_t *cp, size_t *bytes) { - unsigned char lead = str[index]; - if (lead < 0x80) { - *cp = lead; - *bytes = 1; - return true; - } - - if ((lead & 0xE0) == 0xC0) { - if (index + 1 >= len || !is_utf8_continuation(str[index + 1])) { - return false; - } - uint32_t value = ((lead & 0x1F) << 6) | (str[index + 1] & 0x3F); - if (value < 0x80) { - return false; - } - *cp = static_cast(value); - *bytes = 2; - return true; - } - - if ((lead & 0xF0) == 0xE0) { - if (index + 2 >= len || !is_utf8_continuation(str[index + 1]) || - !is_utf8_continuation(str[index + 2])) { - return false; - } - uint32_t value = ((lead & 0x0F) << 12) | ((str[index + 1] & 0x3F) << 6) | - (str[index + 2] & 0x3F); - if (value < 0x800 || (value >= 0xD800 && value <= 0xDFFF)) { - return false; - } - *cp = static_cast(value); - *bytes = 3; - return true; - } - - if ((lead & 0xF8) == 0xF0) { - if (index + 3 >= len || !is_utf8_continuation(str[index + 1]) || - !is_utf8_continuation(str[index + 2]) || - !is_utf8_continuation(str[index + 3])) { - return false; - } - uint32_t value = ((lead & 0x07) << 18) | ((str[index + 1] & 0x3F) << 12) | - ((str[index + 2] & 0x3F) << 6) | (str[index + 3] & 0x3F); - if (value < 0x10000 || value > kUnicodeMaxCodepoint) { - return false; - } - *cp = static_cast(value); - *bytes = 4; - return true; - } - - return false; -} - -WordBreakClass classify_codepoint(utf8proc_int32_t cp) { +WordBreakClass classify_codepoint(int32_t cp) { uint32_t codepoint = static_cast(cp); if (codepoint <= 0x7F) { return lookup_ascii_word_break_class(codepoint); @@ -431,7 +370,7 @@ WordBreakClass classify_codepoint(utf8proc_int32_t cp) { CodepointProperties lookup_codepoint_properties(uint32_t codepoint) { CodepointProperties props; - props.cls = classify_codepoint(static_cast(codepoint)); + props.cls = classify_codepoint(static_cast(codepoint)); props.extended_pictographic = props.cls == WordBreakClass::ExtendedPictographic; if (!props.extended_pictographic && codepoint >= 0x00A9) { @@ -467,19 +406,19 @@ std::array *codepoint_cache() { return &cache; } -std::vector decode_utf8(const std::string &text) { - std::vector codepoints; +std::vector decode_standard_utf8(const std::string &text) { + std::vector codepoints; codepoints.reserve(estimate_codepoint_capacity(text.size())); auto *cache = codepoint_cache(); - const auto *str = reinterpret_cast(text.data()); + const auto *data = reinterpret_cast(text.data()); size_t len = text.size(); size_t index = 0; while (index < len) { - utf8proc_int32_t cp; + int32_t cp = 0; size_t bytes = 0; - if (!decode_utf8_codepoint(str, len, index, &cp, &bytes)) { - Codepoint item; + if (!decode_utf8_codepoint(data, len, index, &cp, &bytes)) { + StandardCodepoint item; item.start = static_cast(index); item.end = static_cast(index + 1); item.cls = WordBreakClass::Other; @@ -488,10 +427,11 @@ std::vector decode_utf8(const std::string &text) { continue; } - Codepoint item; + StandardCodepoint item; item.cp = cp; item.start = static_cast(index); item.end = static_cast(index + bytes); + item.valid = true; uint32_t codepoint = static_cast(cp); CodepointProperties props = get_codepoint_properties(codepoint, cache); item.cls = props.cls; @@ -504,7 +444,7 @@ std::vector decode_utf8(const std::string &text) { return codepoints; } -size_t next_significant(const std::vector &codepoints, +size_t next_significant(const std::vector &codepoints, size_t index) { while (index < codepoints.size() && is_ignored(codepoints[index].cls)) { ++index; @@ -567,13 +507,13 @@ bool significant_connects(WordBreakClass left, WordBreakClass right) { return false; } -bool is_keycap_base(const Codepoint &codepoint) { +bool is_keycap_base(const StandardCodepoint &codepoint) { return (codepoint.cp >= '0' && codepoint.cp <= '9') || codepoint.cp == '#' || codepoint.cp == '*'; } -size_t consume_extend_or_format(const std::vector &codepoints, - size_t index) { +size_t consume_extend_or_format( + const std::vector &codepoints, size_t index) { while (index < codepoints.size() && is_extend_or_format(codepoints[index].cls)) { ++index; @@ -582,7 +522,7 @@ size_t consume_extend_or_format(const std::vector &codepoints, } size_t consume_extend_format_and_modifier( - const std::vector &codepoints, size_t index) { + const std::vector &codepoints, size_t index) { while (index < codepoints.size() && is_extend_or_format(codepoints[index].cls)) { ++index; @@ -593,7 +533,7 @@ size_t consume_extend_format_and_modifier( return index; } -size_t scan_keycap_token(const std::vector &codepoints, +size_t scan_keycap_token(const std::vector &codepoints, size_t start) { if (!is_keycap_base(codepoints[start])) { return start; @@ -612,8 +552,8 @@ size_t scan_keycap_token(const std::vector &codepoints, return consume_extend_or_format(codepoints, index + 1); } -size_t scan_emoji_modifier_token(const std::vector &codepoints, - size_t start) { +size_t scan_emoji_modifier_token( + const std::vector &codepoints, size_t start) { if (codepoints[start].emoji_modifier) { return consume_extend_or_format(codepoints, start + 1); } @@ -634,7 +574,7 @@ size_t scan_emoji_modifier_token(const std::vector &codepoints, return consume_extend_or_format(codepoints, index + 1); } -size_t scan_emoji_token(const std::vector &codepoints, +size_t scan_emoji_token(const std::vector &codepoints, size_t start) { size_t index = consume_extend_format_and_modifier(codepoints, start + 1); @@ -657,7 +597,7 @@ size_t scan_emoji_token(const std::vector &codepoints, return index; } -size_t scan_zwj_ext_pict_token(const std::vector &codepoints, +size_t scan_zwj_ext_pict_token(const std::vector &codepoints, size_t start) { size_t index = start + 1; if (index >= codepoints.size() || @@ -667,8 +607,8 @@ size_t scan_zwj_ext_pict_token(const std::vector &codepoints, return scan_emoji_token(codepoints, index); } -size_t scan_regional_indicator_token(const std::vector &codepoints, - size_t start) { +size_t scan_regional_indicator_token( + const std::vector &codepoints, size_t start) { size_t index = start + 1; while (index < codepoints.size() && is_ignored(codepoints[index].cls)) { ++index; @@ -683,7 +623,7 @@ size_t scan_regional_indicator_token(const std::vector &codepoints, return index; } -size_t scan_single_token(const std::vector &codepoints, +size_t scan_single_token(const std::vector &codepoints, size_t start) { size_t index = start + 1; while (index < codepoints.size() && is_ignored(codepoints[index].cls)) { @@ -692,7 +632,8 @@ size_t scan_single_token(const std::vector &codepoints, return index; } -size_t scan_word_token(const std::vector &codepoints, size_t start) { +size_t scan_word_token(const std::vector &codepoints, + size_t start) { size_t end = start + 1; size_t last_sig = start; @@ -745,8 +686,8 @@ size_t scan_word_token(const std::vector &codepoints, size_t start) { return end; } -bool span_has_core_token(const std::vector &codepoints, size_t start, - size_t end) { +bool span_has_core_token(const std::vector &codepoints, + size_t start, size_t end) { for (size_t index = start; index < end; ++index) { WordBreakClass cls = codepoints[index].cls; if (is_token_start(cls)) { @@ -756,7 +697,7 @@ bool span_has_core_token(const std::vector &codepoints, size_t start, return false; } -size_t trim_non_core_suffix(const std::vector &codepoints, +size_t trim_non_core_suffix(const std::vector &codepoints, size_t start, size_t end) { size_t trimmed = end; while (trimmed > start) { @@ -770,7 +711,7 @@ size_t trim_non_core_suffix(const std::vector &codepoints, } void emit_non_empty_core_span(const std::string &text, - const std::vector &codepoints, + const std::vector &codepoints, size_t start, size_t end, uint32_t *position, std::vector *tokens) { if (start >= end || !span_has_core_token(codepoints, start, end)) { @@ -785,9 +726,9 @@ void emit_non_empty_core_span(const std::string &text, } void emit_token_span(const std::string &text, - const std::vector &codepoints, size_t start, - size_t end, uint32_t max_token_length, uint32_t *position, - std::vector *tokens) { + const std::vector &codepoints, + size_t start, size_t end, uint32_t max_token_length, + uint32_t *position, std::vector *tokens) { if (end - start <= max_token_length) { Token token; token.text = text.substr(codepoints[start].start, @@ -989,24 +930,24 @@ std::vector tokenize_ascii(const std::string &text, } // namespace -bool StandardTokenizer::init(const ailego::JsonObject &config) { +Status StandardTokenizer::init(const ailego::JsonObject &config) { max_token_length_ = kDefaultMaxTokenLength; auto length_val = config["max_token_length"]; if (!length_val.is_null()) { if (!length_val.is_integer()) { - LOG_ERROR("StandardTokenizer: max_token_length must be integer"); - return false; + return Status::InvalidArgument( + "StandardTokenizer: max_token_length must be integer"); } auto configured_length = length_val.as_integer(); if (configured_length < kMinMaxTokenLength || configured_length > kMaxMaxTokenLength) { - LOG_ERROR("StandardTokenizer: max_token_length out of range: %zu", - (size_t)configured_length); - return false; + return Status::InvalidArgument( + "StandardTokenizer: max_token_length out of range: ", + configured_length); } max_token_length_ = static_cast(configured_length); } - return true; + return Status::OK(); } std::vector StandardTokenizer::tokenize(const std::string &text) const { @@ -1017,7 +958,7 @@ std::vector StandardTokenizer::tokenize(const std::string &text) const { std::vector tokens; tokens.reserve(estimate_token_capacity(text.size())); uint32_t position = 0; - std::vector codepoints = decode_utf8(text); + std::vector codepoints = decode_standard_utf8(text); size_t index = 0; while (index < codepoints.size()) { diff --git a/src/db/index/column/fts_column/tokenizer/standard_tokenizer.h b/src/db/index/column/fts_column/tokenizer/standard_tokenizer.h index 9c46b3b28..cb4fecab1 100644 --- a/src/db/index/column/fts_column/tokenizer/standard_tokenizer.h +++ b/src/db/index/column/fts_column/tokenizer/standard_tokenizer.h @@ -33,9 +33,9 @@ class StandardTokenizer : public Tokenizer { * tokens are split into smaller segments. Combining marks and other * ignored word-break characters may stay attached to the previous * segment to avoid creating mark-only tokens. - * Returns false when the configuration is invalid. + * Returns an error status when the configuration is invalid. */ - bool init(const ailego::JsonObject &config) override; + Status init(const ailego::JsonObject &config) override; std::vector tokenize(const std::string &text) const override; diff --git a/src/db/index/column/fts_column/tokenizer/tokenizer.h b/src/db/index/column/fts_column/tokenizer/tokenizer.h index efc7906fa..91e3d424b 100644 --- a/src/db/index/column/fts_column/tokenizer/tokenizer.h +++ b/src/db/index/column/fts_column/tokenizer/tokenizer.h @@ -19,6 +19,7 @@ #include #include #include +#include namespace zvec::fts { @@ -43,9 +44,9 @@ class Tokenizer { /*! Initialise the tokenizer from a JSON configuration object. * Must be called once before tokenize(). * \param config JSON object containing tokenizer-specific parameters. - * \return true on success, false on failure. + * \return OK on success, or an error describing invalid config. */ - virtual bool init(const ailego::JsonObject &config) = 0; + virtual Status init(const ailego::JsonObject &config) = 0; /*! Tokenize input text * \param text UTF-8 encoded input text diff --git a/src/db/index/column/fts_column/tokenizer/tokenizer_factory.cc b/src/db/index/column/fts_column/tokenizer/tokenizer_factory.cc index 41927c312..1de40502e 100644 --- a/src/db/index/column/fts_column/tokenizer/tokenizer_factory.cc +++ b/src/db/index/column/fts_column/tokenizer/tokenizer_factory.cc @@ -17,50 +17,49 @@ #include #include "ascii_folding_token_filter.h" #include "jieba_tokenizer.h" +#include "ngram_tokenizer.h" #include "standard_tokenizer.h" #include "stemmer_token_filter.h" #include "whitespace_tokenizer.h" namespace zvec::fts { -TokenizerPipelinePtr TokenizerFactory::create(const FtsIndexParams ¶ms) { +Result TokenizerFactory::create( + const FtsIndexParams ¶ms) { // Parse extra_params JSON string into a JsonObject. // Empty string is treated as an empty object; malformed JSON fails. ailego::JsonObject extra_json; if (!params.extra_params.empty()) { ailego::JsonValue parsed; if (!parsed.parse(params.extra_params.c_str())) { - LOG_ERROR("[TokenizerFactory] failed to parse extra_params JSON: %s", - params.extra_params.c_str()); - return nullptr; + return tl::make_unexpected(Status::InvalidArgument( + "TokenizerFactory: failed to parse extra_params JSON: ", + params.extra_params)); } if (!parsed.is_object()) { - LOG_ERROR("[TokenizerFactory] extra_params is not a JSON object: %s", - params.extra_params.c_str()); - return nullptr; + return tl::make_unexpected(Status::InvalidArgument( + "TokenizerFactory: extra_params is not a JSON object: ", + params.extra_params)); } extra_json = parsed.as_object(); } - TokenizerPtr tokenizer = create_tokenizer(params.tokenizer_name, extra_json); - if (!tokenizer) { - LOG_ERROR("[TokenizerFactory] failed to create tokenizer: %s", - params.tokenizer_name.c_str()); - return nullptr; + TokenizerPtr tokenizer; + auto status = create_tokenizer(params.tokenizer_name, extra_json, &tokenizer); + if (!status.ok()) { + return tl::make_unexpected(std::move(status)); } std::vector filters; for (const auto &filter_name : params.filters) { TokenFilterPtr filter = create_filter(filter_name); if (!filter) { - LOG_ERROR("[TokenizerFactory] failed to create filter: %s", - filter_name.c_str()); - return nullptr; + return tl::make_unexpected(Status::InvalidArgument( + "TokenizerFactory: unknown filter name: ", filter_name)); } if (!filter->init(extra_json)) { - LOG_ERROR("[TokenizerFactory] failed to init filter: %s", - filter_name.c_str()); - return nullptr; + return tl::make_unexpected(Status::InvalidArgument( + "TokenizerFactory: failed to init filter: ", filter_name)); } filters.push_back(std::move(filter)); } @@ -77,27 +76,28 @@ std::vector TokenizerPipeline::process(const std::string &text) const { return tokens; } -TokenizerPtr TokenizerFactory::create_tokenizer( - const std::string &tokenizer_name, const ailego::JsonObject &extra_json) { - TokenizerPtr tokenizer; +Status TokenizerFactory::create_tokenizer(const std::string &tokenizer_name, + const ailego::JsonObject &extra_json, + TokenizerPtr *tokenizer) { if (tokenizer_name.empty() || tokenizer_name == "standard") { - tokenizer = std::make_shared(); + *tokenizer = std::make_shared(); + } else if (tokenizer_name == "ngram") { + *tokenizer = std::make_shared(); } else if (tokenizer_name == "jieba") { - tokenizer = std::make_shared(); + *tokenizer = std::make_shared(); } else if (tokenizer_name == "whitespace") { - tokenizer = std::make_shared(); + *tokenizer = std::make_shared(); } else { - LOG_ERROR("[TokenizerFactory] unknown tokenizer name: %s", - tokenizer_name.c_str()); - return nullptr; + return Status::InvalidArgument("TokenizerFactory: unknown tokenizer name: ", + tokenizer_name); } - if (!tokenizer->init(extra_json)) { - LOG_ERROR("[TokenizerFactory] failed to init tokenizer: %s", - tokenizer_name.c_str()); - return nullptr; + auto status = (*tokenizer)->init(extra_json); + if (!status.ok()) { + tokenizer->reset(); + return status; } - return tokenizer; + return Status::OK(); } TokenFilterPtr TokenizerFactory::create_filter(const std::string &filter_name) { diff --git a/src/db/index/column/fts_column/tokenizer/tokenizer_factory.h b/src/db/index/column/fts_column/tokenizer/tokenizer_factory.h index 22a3481f4..665149844 100644 --- a/src/db/index/column/fts_column/tokenizer/tokenizer_factory.h +++ b/src/db/index/column/fts_column/tokenizer/tokenizer_factory.h @@ -52,13 +52,14 @@ class TokenizerFactory { * and extra_params (JSON string for tokenizer-specific * configuration). The stemmer filter reads stemmer_lang from * extra_params and uses Snowball English by default. - * \return Tokenizer pipeline, returns nullptr on failure + * \return Tokenizer pipeline, or a detailed error status. */ - static TokenizerPipelinePtr create(const FtsIndexParams ¶ms); + static Result create(const FtsIndexParams ¶ms); private: - static TokenizerPtr create_tokenizer(const std::string &tokenizer_name, - const ailego::JsonObject &extra_json); + static Status create_tokenizer(const std::string &tokenizer_name, + const ailego::JsonObject &extra_json, + TokenizerPtr *tokenizer); static TokenFilterPtr create_filter(const std::string &filter_name); }; diff --git a/src/db/index/column/fts_column/tokenizer/tokenizer_pipeline_manager.cc b/src/db/index/column/fts_column/tokenizer/tokenizer_pipeline_manager.cc index b3261319d..96703a1cc 100644 --- a/src/db/index/column/fts_column/tokenizer/tokenizer_pipeline_manager.cc +++ b/src/db/index/column/fts_column/tokenizer/tokenizer_pipeline_manager.cc @@ -63,14 +63,16 @@ TokenizerPipelinePtr TokenizerPipelineManager::acquire( // Create the pipeline outside of the lock to avoid blocking other // acquire/release calls during the (potentially expensive) construction. - TokenizerPipelinePtr pipeline = TokenizerFactory::create(params); - if (!pipeline) { + auto pipeline_result = TokenizerFactory::create(params); + if (!pipeline_result.has_value()) { LOG_ERROR( "TokenizerPipelineManager: failed to create pipeline for " - "tokenizer[%s] key[%s]", - params.tokenizer_name.c_str(), key.c_str()); + "tokenizer[%s] key[%s]: %s", + params.tokenizer_name.c_str(), key.c_str(), + pipeline_result.error().message().c_str()); return nullptr; } + TokenizerPipelinePtr pipeline = std::move(pipeline_result).value(); // Re-acquire the lock and check whether another thread has already // created a pipeline with the same key while we were constructing ours. diff --git a/src/db/index/column/fts_column/tokenizer/unicode_utils.cc b/src/db/index/column/fts_column/tokenizer/unicode_utils.cc new file mode 100644 index 000000000..41d0da3fe --- /dev/null +++ b/src/db/index/column/fts_column/tokenizer/unicode_utils.cc @@ -0,0 +1,40 @@ +// Copyright 2025-present the zvec project +// +// Licensed 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. + +#include "unicode_utils.h" + +namespace zvec::fts { + +std::vector decode_utf8_codepoints(const std::string &text) { + std::vector codepoints; + codepoints.reserve(text.size() / 2 + 1); + const auto *data = reinterpret_cast(text.data()); + size_t offset = 0; + while (offset < text.size()) { + int32_t codepoint = 0; + size_t bytes = 0; + if (!decode_utf8_codepoint(data, text.size(), offset, &codepoint, &bytes)) { + codepoints.push_back({0, static_cast(offset), + static_cast(offset + 1), false}); + ++offset; + continue; + } + codepoints.push_back({codepoint, static_cast(offset), + static_cast(offset + bytes), true}); + offset += bytes; + } + return codepoints; +} + +} // namespace zvec::fts diff --git a/src/db/index/column/fts_column/tokenizer/unicode_utils.h b/src/db/index/column/fts_column/tokenizer/unicode_utils.h new file mode 100644 index 000000000..160c14c0e --- /dev/null +++ b/src/db/index/column/fts_column/tokenizer/unicode_utils.h @@ -0,0 +1,104 @@ +// Copyright 2025-present the zvec project +// +// Licensed 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. + +#pragma once + +#include +#include +#include +#include + +namespace zvec::fts { + +/*! A decoded Unicode codepoint and its byte range in the original UTF-8 text. + * Invalid UTF-8 bytes are represented as one-byte entries with valid=false. + */ +struct UnicodeCodepoint { + int32_t cp{0}; + uint32_t start{0}; + uint32_t end{0}; + bool valid{false}; +}; + +/*! Decode one UTF-8 codepoint starting at offset. + * Returns false for malformed or truncated input without advancing offset. + */ +inline bool decode_utf8_codepoint(const uint8_t *data, size_t length, + size_t offset, int32_t *codepoint, + size_t *bytes) { + constexpr uint32_t kUnicodeMaxCodepoint = 0x10FFFF; + auto is_continuation = [](uint8_t byte) { return (byte & 0xC0) == 0x80; }; + + uint8_t lead = data[offset]; + if (lead < 0x80) { + *codepoint = lead; + *bytes = 1; + return true; + } + + if ((lead & 0xE0) == 0xC0) { + if (offset + 1 >= length || !is_continuation(data[offset + 1])) { + return false; + } + uint32_t value = ((lead & 0x1F) << 6) | (data[offset + 1] & 0x3F); + if (value < 0x80) { + return false; + } + *codepoint = static_cast(value); + *bytes = 2; + return true; + } + + if ((lead & 0xF0) == 0xE0) { + if (offset + 2 >= length || !is_continuation(data[offset + 1]) || + !is_continuation(data[offset + 2])) { + return false; + } + uint32_t value = ((lead & 0x0F) << 12) | ((data[offset + 1] & 0x3F) << 6) | + (data[offset + 2] & 0x3F); + if (value < 0x800 || (value >= 0xD800 && value <= 0xDFFF)) { + return false; + } + *codepoint = static_cast(value); + *bytes = 3; + return true; + } + + if ((lead & 0xF8) == 0xF0) { + if (offset + 3 >= length || !is_continuation(data[offset + 1]) || + !is_continuation(data[offset + 2]) || + !is_continuation(data[offset + 3])) { + return false; + } + uint32_t value = ((lead & 0x07) << 18) | ((data[offset + 1] & 0x3F) << 12) | + ((data[offset + 2] & 0x3F) << 6) | + (data[offset + 3] & 0x3F); + if (value < 0x10000 || value > kUnicodeMaxCodepoint) { + return false; + } + *codepoint = static_cast(value); + *bytes = 4; + return true; + } + + return false; +} + +/*! Decode UTF-8 text while preserving the byte range of every codepoint. + * Each malformed byte becomes a separate invalid entry so callers can use it + * as a token boundary without losing the original byte offsets. + */ +std::vector decode_utf8_codepoints(const std::string &text); + +} // namespace zvec::fts diff --git a/src/db/index/column/fts_column/tokenizer/whitespace_tokenizer.h b/src/db/index/column/fts_column/tokenizer/whitespace_tokenizer.h index e2668c671..775799dc7 100644 --- a/src/db/index/column/fts_column/tokenizer/whitespace_tokenizer.h +++ b/src/db/index/column/fts_column/tokenizer/whitespace_tokenizer.h @@ -25,8 +25,8 @@ namespace zvec::fts { class WhitespaceTokenizer : public Tokenizer { public: // WhitespaceTokenizer requires no configuration; always succeeds. - bool init(const ailego::JsonObject & /*config*/) override { - return true; + Status init(const ailego::JsonObject & /*config*/) override { + return Status::OK(); } std::vector tokenize(const std::string &text) const override; diff --git a/src/db/index/common/schema.cc b/src/db/index/common/schema.cc index 532696803..424f98f71 100644 --- a/src/db/index/common/schema.cc +++ b/src/db/index/common/schema.cc @@ -76,10 +76,10 @@ static Status validate_fts_index_params(const FieldSchema &field) { internal_params.extra_params = params->extra_params(); auto pipeline = fts::TokenizerFactory::create(internal_params); - if (!pipeline) { + if (!pipeline.has_value()) { return Status::InvalidArgument( "schema validate failed: invalid FTS index params for field[", - field.name(), "]"); + field.name(), "]: ", pipeline.error().message()); } return Status::OK(); } diff --git a/src/include/zvec/c_api.h b/src/include/zvec/c_api.h index 2d048689b..5070f45ce 100644 --- a/src/include/zvec/c_api.h +++ b/src/include/zvec/c_api.h @@ -1173,7 +1173,7 @@ ZVEC_EXPORT zvec_error_code_t ZVEC_CALL zvec_index_params_set_invert_params( * @brief Set FTS index specific parameters * @param params Index parameters (must be FTS type) * @param tokenizer_name Tokenizer pipeline name (NULL keeps current value). - * Supported values are "standard", "jieba", and "whitespace". + * Supported values are "standard", "ngram", "jieba", and "whitespace". * @param filters Token filter names (NULL keeps current value). Supported * values are "lowercase", "ascii_folding", and "stemmer". * @param extra_params Additional tokenizer/filter parameters (NULL keeps @@ -1182,6 +1182,12 @@ ZVEC_EXPORT zvec_error_code_t ZVEC_CALL zvec_index_params_set_invert_params( * Tokenizers: * standard: * - "max_token_length" (positive integer). + * ngram: + * - "ngram_min" (positive integer, default 2). + * - "ngram_max" (positive integer, default 2). + * - "token_chars" (array of "letter", "digit", "whitespace", + * "punctuation", "symbol"; default [] keeps all valid UTF-8 + * characters). custom_token_chars is not supported. * jieba: * - "jieba_dict_dir" (directory containing jieba.dict.utf8 and * hmm_model.utf8). diff --git a/src/include/zvec/db/index_params.h b/src/include/zvec/db/index_params.h index 5509e1ead..426133480 100644 --- a/src/include/zvec/db/index_params.h +++ b/src/include/zvec/db/index_params.h @@ -718,7 +718,7 @@ class ZVEC_API VamanaIndexParams : public VectorIndexParams { /* * FTS (Full-Text Search) index params - * Supported tokenizers: "standard", "jieba", "whitespace". + * Supported tokenizers: "standard", "ngram", "jieba", "whitespace". * Supported filters: "lowercase", "ascii_folding", "stemmer". * * extra_params must be either empty or a JSON object string. Supported keys are @@ -726,6 +726,13 @@ class ZVEC_API VamanaIndexParams : public VectorIndexParams { * Tokenizers: * standard: * - "max_token_length" (positive integer). + * ngram: + * - "ngram_min" (positive integer, default 2). + * - "ngram_max" (positive integer, default 2). + * ngram_max - ngram_min must not exceed 1. + * - "token_chars" (array of "letter", "digit", "whitespace", + * "punctuation", "symbol"; default [] keeps all valid UTF-8 + * characters). custom_token_chars is not supported. * jieba: * - "jieba_dict_dir" (directory containing jieba.dict.utf8 and * hmm_model.utf8). diff --git a/tests/db/fts_query_test.cc b/tests/db/fts_query_test.cc index cc9c907dd..44e8cee2c 100644 --- a/tests/db/fts_query_test.cc +++ b/tests/db/fts_query_test.cc @@ -57,6 +57,20 @@ class FtsQueryTest : public ::testing::Test { return schema; } + static CollectionSchema::Ptr CreateNGramFtsSchema() { + auto schema = std::make_shared("ngram_fts_demo"); + schema->add_field(std::make_shared("title", DataType::STRING)); + auto fts_params = std::make_shared( + "ngram", std::vector{}, + R"({"ngram_min":2,"ngram_max":2,"token_chars":["letter"]})"); + schema->add_field(std::make_shared( + "content", DataType::STRING, false, std::move(fts_params))); + schema->add_field(std::make_shared( + "vec", DataType::VECTOR_FP32, 4, false, + std::make_shared(MetricType::IP))); + return schema; + } + static Doc MakeDoc(uint64_t id, const std::string &title, const std::string &content) { Doc doc; @@ -105,6 +119,51 @@ TEST_F(FtsQueryTest, BasicFtsQuery) { ASSERT_LE(results.size(), 3u); } +TEST_F(FtsQueryTest, NGramTokenizerEndToEnd) { + auto schema = CreateNGramFtsSchema(); + CollectionOptions options; + options.read_only_ = false; + + auto result = Collection::CreateAndOpen(kTestPath, *schema, options); + ASSERT_TRUE(result.has_value()) << result.error().message(); + auto col = result.value(); + + std::vector docs; + docs.push_back(MakeDoc(0, "chinese", + "\xE4\xB8\xAD\xE6\x96\x87\xE5\x88\x86\xE8\xAF\x8D")); + docs.push_back(MakeDoc(1, "english", "english tokenizer")); + auto insert_result = col->Insert(docs); + ASSERT_TRUE(insert_result.has_value()) << insert_result.error().message(); + + SearchQuery query; + query.target_.field_name_ = "content"; + query.topk_ = 10; + FtsClause fts; + fts.query_string_ = "\xE4\xB8\xAD\xE6\x96\x87"; + query.target_.clause_ = fts; + + auto query_result = col->Query(query); + ASSERT_TRUE(query_result.has_value()) << query_result.error().message(); + ASSERT_EQ(query_result.value().size(), 1u); +} + +TEST_F(FtsQueryTest, NGramConfigErrorIsReturnedByCreateCollection) { + auto schema = CreateNGramFtsSchema(); + auto field = schema->get_field("content"); + auto params = + std::dynamic_pointer_cast(field->index_params()); + ASSERT_NE(params, nullptr); + params->set_extra_params(R"({"ngram_min":1,"ngram_max":3})"); + + CollectionOptions options; + options.read_only_ = false; + auto result = Collection::CreateAndOpen(kTestPath, *schema, options); + + ASSERT_FALSE(result.has_value()); + EXPECT_NE(result.error().message().find("ngram_max - ngram_min must be <= 1"), + std::string::npos); +} + TEST_F(FtsQueryTest, FtsQueryEmptyField) { auto schema = CreateFtsSchema(); CollectionOptions options; diff --git a/tests/db/index/column/fts_column/fts_column_indexer_test.cc b/tests/db/index/column/fts_column/fts_column_indexer_test.cc index 0b0702a2c..a88901827 100644 --- a/tests/db/index/column/fts_column/fts_column_indexer_test.cc +++ b/tests/db/index/column/fts_column/fts_column_indexer_test.cc @@ -61,7 +61,7 @@ static zvec::fts::TokenizerPipelinePtr make_whitespace_pipeline() { zvec::fts::FtsIndexParams params; params.tokenizer_name = "whitespace"; params.filters = {"lowercase"}; - return zvec::fts::TokenizerFactory::create(params); + return zvec::fts::TokenizerFactory::create(params).value(); } // Helper: parse a query string and call search() on a reader/indexer. @@ -797,7 +797,7 @@ TEST_F(FtsColumnIndexerJiebaTest, OpenWithJiebaTokenizerFailsWithoutDictDir) { bad_params.tokenizer_name = "jieba"; bad_params.extra_params = ""; auto pipeline = TokenizerFactory::create(bad_params); - EXPECT_EQ(pipeline, nullptr); + EXPECT_FALSE(pipeline.has_value()); } // Insert a Chinese sentence and verify that total_docs and total_tokens are @@ -915,7 +915,7 @@ static zvec::fts::TokenizerPipelinePtr make_jieba_pipeline_for_test() { params.filters = {"lowercase"}; params.extra_params = std::string(R"({"jieba_dict_dir":")") + kJiebaDictDir + R"("})"; - return zvec::fts::TokenizerFactory::create(params); + return zvec::fts::TokenizerFactory::create(params).value(); } // Phrase queries on a jieba-indexed doc must hit when the query goes through @@ -1841,7 +1841,7 @@ static zvec::fts::TokenizerPipelinePtr make_stemmer_pipeline() { zvec::fts::FtsIndexParams params; params.tokenizer_name = "standard"; params.filters = {"lowercase", "stemmer"}; - return zvec::fts::TokenizerFactory::create(params); + return zvec::fts::TokenizerFactory::create(params).value(); } class FtsStemmerIndexerTest : public FtsColumnIndexerTest { diff --git a/tests/db/index/column/fts_column/fts_rocksdb_reducer_test.cc b/tests/db/index/column/fts_column/fts_rocksdb_reducer_test.cc index 469bc4954..851112c21 100644 --- a/tests/db/index/column/fts_column/fts_rocksdb_reducer_test.cc +++ b/tests/db/index/column/fts_column/fts_rocksdb_reducer_test.cc @@ -43,7 +43,7 @@ static zvec::fts::TokenizerPipelinePtr make_reducer_test_pipeline() { zvec::fts::FtsIndexParams params; params.tokenizer_name = "whitespace"; params.filters = {"lowercase"}; - return zvec::fts::TokenizerFactory::create(params); + return zvec::fts::TokenizerFactory::create(params).value(); } // Helper: parse a query string and call search() on a reader. diff --git a/tests/db/index/column/fts_column/ngram_tokenizer_test.cc b/tests/db/index/column/fts_column/ngram_tokenizer_test.cc new file mode 100644 index 000000000..c7741d78c --- /dev/null +++ b/tests/db/index/column/fts_column/ngram_tokenizer_test.cc @@ -0,0 +1,201 @@ +// Copyright 2025-present the zvec project +// +// Licensed 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. + +#include +#include +#include +#include "db/index/column/fts_column/fts_types.h" +#include "db/index/column/fts_column/tokenizer/tokenizer_factory.h" + +namespace { + +using zvec::fts::FtsIndexParams; +using zvec::fts::Token; +using zvec::fts::TokenizerFactory; +using zvec::fts::TokenizerPipelinePtr; + +std::vector token_texts(const std::vector &tokens) { + std::vector texts; + texts.reserve(tokens.size()); + for (const auto &token : tokens) { + texts.push_back(token.text); + } + return texts; +} + +TokenizerPipelinePtr make_pipeline(const std::string &extra_params = "") { + FtsIndexParams params; + params.tokenizer_name = "ngram"; + params.filters.clear(); + params.extra_params = extra_params; + auto pipeline = TokenizerFactory::create(params); + return pipeline.has_value() ? pipeline.value() : nullptr; +} + +class NGramTokenizerTest : public ::testing::Test { + protected: + void SetUp() override { + pipeline_ = make_pipeline(); + ASSERT_NE(pipeline_, nullptr); + } + + std::vector tokenize(const std::string &text) { + return pipeline_->process(text); + } + + TokenizerPipelinePtr pipeline_; +}; + +TEST_F(NGramTokenizerTest, DefaultBigramAscii) { + auto tokens = tokenize("hello"); + std::vector expected = {"he", "el", "ll", "lo"}; + EXPECT_EQ(token_texts(tokens), expected); +} + +TEST_F(NGramTokenizerTest, CustomRange) { + auto pipeline = make_pipeline(R"({"ngram_min":2,"ngram_max":3})"); + ASSERT_NE(pipeline, nullptr); + + auto tokens = pipeline->process("hello"); + std::vector expected = {"he", "hel", "el", "ell", + "ll", "llo", "lo"}; + EXPECT_EQ(token_texts(tokens), expected); +} + +TEST_F(NGramTokenizerTest, ChineseBigram) { + auto tokens = tokenize("\xE4\xB8\xAD\xE6\x96\x87\xE5\x88\x86\xE8\xAF\x8D"); + std::vector expected = {"\xE4\xB8\xAD\xE6\x96\x87", + "\xE6\x96\x87\xE5\x88\x86", + "\xE5\x88\x86\xE8\xAF\x8D"}; + EXPECT_EQ(token_texts(tokens), expected); +} + +TEST_F(NGramTokenizerTest, DefaultTokenCharsKeepsAllValidCharacters) { + auto tokens = tokenize( + "foobar\xE6\x9C\xAA\xE8\xB7\x9F\xE8\xB8\xAA" + "\xE6\x96\x87\xE4\xBB\xB6"); + std::vector expected = {"fo", + "oo", + "ob", + "ba", + "ar", + "r\xE6\x9C\xAA", + "\xE6\x9C\xAA\xE8\xB7\x9F", + "\xE8\xB7\x9F\xE8\xB8\xAA", + "\xE8\xB8\xAA\xE6\x96\x87", + "\xE6\x96\x87\xE4\xBB\xB6"}; + EXPECT_EQ(token_texts(tokens), expected); +} + +TEST_F(NGramTokenizerTest, DistinguishesNullCodepointFromMalformedUtf8) { + auto null_tokens = tokenize(std::string("a\0b", 3)); + std::vector null_expected = {std::string("a\0", 2), + std::string("\0b", 2)}; + EXPECT_EQ(token_texts(null_tokens), null_expected); + + std::string malformed = "ab"; + malformed.push_back(static_cast(0xFF)); + malformed += "cd"; + auto malformed_tokens = tokenize(malformed); + std::vector malformed_expected = {"ab", "cd"}; + EXPECT_EQ(token_texts(malformed_tokens), malformed_expected); +} + +TEST_F(NGramTokenizerTest, LetterAndDigitTokenCharsSplitOnPunctuation) { + auto pipeline = make_pipeline( + R"({"ngram_min":2,"ngram_max":2,"token_chars":["letter","digit"]})"); + ASSERT_NE(pipeline, nullptr); + + auto tokens = pipeline->process( + "ab cd,\xE4\xB8\xAD\xE6\x96\x87!" + "de"); + std::vector expected = {"ab", "cd", "\xE4\xB8\xAD\xE6\x96\x87", + "de"}; + EXPECT_EQ(token_texts(tokens), expected); +} + +TEST_F(NGramTokenizerTest, WhitespacePunctuationAndSymbolTokenChars) { + auto pipeline = make_pipeline( + R"({"ngram_min":2,"ngram_max":2,"token_chars":["whitespace","punctuation","symbol"]})"); + ASSERT_NE(pipeline, nullptr); + + auto tokens = pipeline->process("a !$b"); + std::vector expected = {" !", "!$"}; + EXPECT_EQ(token_texts(tokens), expected); +} + +TEST_F(NGramTokenizerTest, TokenCharClassesMatchElasticsearchCategories) { + auto whitespace_pipeline = make_pipeline( + R"({"ngram_min":1,"ngram_max":1,"token_chars":["whitespace"]})"); + ASSERT_NE(whitespace_pipeline, nullptr); + auto whitespace_tokens = + whitespace_pipeline->process("\t \n\xE2\x80\xA8\xC2\xA0"); + std::vector whitespace_expected = {"\t", " ", "\n", + "\xE2\x80\xA8"}; + EXPECT_EQ(token_texts(whitespace_tokens), whitespace_expected); + + auto symbol_pipeline = make_pipeline( + R"({"ngram_min":1,"ngram_max":1,"token_chars":["symbol"]})"); + ASSERT_NE(symbol_pipeline, nullptr); + std::string symbol_text = "$\xCC\x81"; + symbol_text.push_back('\x01'); + symbol_text += "\xE2\x88\x9A"; + auto symbol_tokens = symbol_pipeline->process(symbol_text); + std::vector symbol_expected = {"$", "\xE2\x88\x9A"}; + EXPECT_EQ(token_texts(symbol_tokens), symbol_expected); +} + +TEST_F(NGramTokenizerTest, Utf8OffsetsUseOriginalByteOffsets) { + auto tokens = tokenize("\xE4\xB8\xAD\xE6\x96\x87\xE5\x88\x86"); + ASSERT_EQ(tokens.size(), 2u); + EXPECT_EQ(tokens[0].text, "\xE4\xB8\xAD\xE6\x96\x87"); + EXPECT_EQ(tokens[0].offset, 0u); + EXPECT_EQ(tokens[1].text, "\xE6\x96\x87\xE5\x88\x86"); + EXPECT_EQ(tokens[1].offset, 3u); +} + +TEST_F(NGramTokenizerTest, PositionsAreConsecutiveAcrossSegments) { + auto pipeline = make_pipeline(R"({"token_chars":["letter"]})"); + ASSERT_NE(pipeline, nullptr); + auto tokens = pipeline->process("abcd \xE4\xB8\xAD\xE6\x96\x87\xE5\x88\x86"); + ASSERT_EQ(tokens.size(), 5u); + for (size_t i = 0; i < tokens.size(); ++i) { + EXPECT_EQ(tokens[i].position, static_cast(i)); + } +} + +TEST(NGramTokenizerConfigTest, InvalidConfigFailsPipelineCreation) { + EXPECT_EQ(make_pipeline(R"({"ngram_min":"2"})"), nullptr); + EXPECT_EQ(make_pipeline(R"({"ngram_min":0})"), nullptr); + EXPECT_EQ(make_pipeline(R"({"ngram_max":0})"), nullptr); + EXPECT_EQ(make_pipeline(R"({"ngram_min":3,"ngram_max":2})"), nullptr); + EXPECT_EQ(make_pipeline(R"({"ngram_min":1,"ngram_max":3})"), nullptr); + EXPECT_EQ(make_pipeline(R"({"token_chars":"letter"})"), nullptr); + EXPECT_EQ(make_pipeline(R"({"token_chars":[1]})"), nullptr); + EXPECT_EQ(make_pipeline(R"({"token_chars":["custom"]})"), nullptr); +} + +TEST(NGramTokenizerConfigTest, FactoryPreservesValidationError) { + FtsIndexParams params; + params.tokenizer_name = "ngram"; + params.extra_params = R"({"ngram_min":1,"ngram_max":3})"; + auto pipeline = TokenizerFactory::create(params); + + EXPECT_FALSE(pipeline.has_value()); + EXPECT_NE( + pipeline.error().message().find("ngram_max - ngram_min must be <= 1"), + std::string::npos); +} + +} // namespace diff --git a/tests/db/index/column/fts_column/standard_tokenizer_test.cc b/tests/db/index/column/fts_column/standard_tokenizer_test.cc index 7d8fb57fe..0feacf075 100644 --- a/tests/db/index/column/fts_column/standard_tokenizer_test.cc +++ b/tests/db/index/column/fts_column/standard_tokenizer_test.cc @@ -35,7 +35,7 @@ class StandardTokenizerTest : public ::testing::Test { FtsIndexParams params; params.tokenizer_name = "standard"; params.filters.clear(); - pipeline_ = TokenizerFactory::create(params); + pipeline_ = TokenizerFactory::create(params).value(); ASSERT_NE(pipeline_, nullptr); } @@ -128,7 +128,7 @@ TEST_F(StandardTokenizerTest, MaxTokenLengthDoesNotCreateMarkOnlyToken) { params.tokenizer_name = "standard"; params.filters.clear(); params.extra_params = R"({"max_token_length":2})"; - auto pipeline = TokenizerFactory::create(params); + auto pipeline = TokenizerFactory::create(params).value(); ASSERT_NE(pipeline, nullptr); // ab + U+0301 + c should not split into a standalone combining mark token. @@ -254,7 +254,7 @@ TEST_F(StandardTokenizerTest, CJKRespectsMaxTokenLength) { params.tokenizer_name = "standard"; params.filters.clear(); params.extra_params = R"({"max_token_length":1})"; - auto pipeline = TokenizerFactory::create(params); + auto pipeline = TokenizerFactory::create(params).value(); ASSERT_NE(pipeline, nullptr); // "a中bc" → "a", "中", "b", "c" (bc split into b and c) @@ -274,7 +274,7 @@ TEST_F(StandardTokenizerTest, MaxTokenLengthSplitsLongWords) { params.tokenizer_name = "standard"; params.filters.clear(); params.extra_params = R"({"max_token_length":5})"; - auto pipeline = TokenizerFactory::create(params); + auto pipeline = TokenizerFactory::create(params).value(); ASSERT_NE(pipeline, nullptr); auto tokens = pipeline->process("abcdefgh"); @@ -290,7 +290,7 @@ TEST_F(StandardTokenizerTest, MaxTokenLengthCountsCodepointsNotBytes) { params4.tokenizer_name = "standard"; params4.filters.clear(); params4.extra_params = R"({"max_token_length":4})"; - auto pipeline4 = TokenizerFactory::create(params4); + auto pipeline4 = TokenizerFactory::create(params4).value(); ASSERT_NE(pipeline4, nullptr); auto tokens4 = pipeline4->process("caf\xC3\xA9"); ASSERT_EQ(tokens4.size(), 1u); @@ -301,7 +301,7 @@ TEST_F(StandardTokenizerTest, MaxTokenLengthCountsCodepointsNotBytes) { params3.tokenizer_name = "standard"; params3.filters.clear(); params3.extra_params = R"({"max_token_length":3})"; - auto pipeline3 = TokenizerFactory::create(params3); + auto pipeline3 = TokenizerFactory::create(params3).value(); ASSERT_NE(pipeline3, nullptr); auto tokens3 = pipeline3->process("caf\xC3\xA9"); ASSERT_EQ(tokens3.size(), 2u); @@ -314,7 +314,7 @@ TEST_F(StandardTokenizerTest, MaxTokenLengthDropsConnectorOnlySplitSegments) { params3.tokenizer_name = "standard"; params3.filters.clear(); params3.extra_params = R"({"max_token_length":3})"; - auto pipeline3 = TokenizerFactory::create(params3); + auto pipeline3 = TokenizerFactory::create(params3).value(); ASSERT_NE(pipeline3, nullptr); auto tokens3 = pipeline3->process("dog's"); ASSERT_EQ(tokens3.size(), 2u); @@ -325,7 +325,7 @@ TEST_F(StandardTokenizerTest, MaxTokenLengthDropsConnectorOnlySplitSegments) { params1.tokenizer_name = "standard"; params1.filters.clear(); params1.extra_params = R"({"max_token_length":1})"; - auto pipeline1 = TokenizerFactory::create(params1); + auto pipeline1 = TokenizerFactory::create(params1).value(); ASSERT_NE(pipeline1, nullptr); auto leading = pipeline1->process("_lead"); std::vector expected_leading = {"l", "e", "a", "d"}; @@ -475,11 +475,11 @@ TEST(StandardTokenizerConfigTest, MaxTokenLengthValidation) { params.filters.clear(); params.extra_params = R"({"max_token_length":0})"; - EXPECT_EQ(TokenizerFactory::create(params), nullptr); + EXPECT_FALSE(TokenizerFactory::create(params).has_value()); params.extra_params = R"({"max_token_length":1048577})"; - EXPECT_EQ(TokenizerFactory::create(params), nullptr); + EXPECT_FALSE(TokenizerFactory::create(params).has_value()); params.extra_params = R"({"max_token_length":1})"; - EXPECT_NE(TokenizerFactory::create(params), nullptr); + EXPECT_TRUE(TokenizerFactory::create(params).has_value()); } diff --git a/tests/db/index/column/fts_column/stemmer_token_filter_test.cc b/tests/db/index/column/fts_column/stemmer_token_filter_test.cc index b350c7755..bd432862f 100644 --- a/tests/db/index/column/fts_column/stemmer_token_filter_test.cc +++ b/tests/db/index/column/fts_column/stemmer_token_filter_test.cc @@ -41,19 +41,20 @@ static FtsIndexParams make_stemmer_params( // ============================================================ TEST(StemmerTokenFilterTest, CreatePipelineDefaultEnglish) { - auto pipeline = TokenizerFactory::create(make_stemmer_params()); + auto pipeline = TokenizerFactory::create(make_stemmer_params()).value(); ASSERT_NE(pipeline, nullptr); } TEST(StemmerTokenFilterTest, CreatePipelineExplicitLanguage) { - auto pipeline = TokenizerFactory::create(make_stemmer_params("german")); + auto pipeline = + TokenizerFactory::create(make_stemmer_params("german")).value(); ASSERT_NE(pipeline, nullptr); } TEST(StemmerTokenFilterTest, CreatePipelineInvalidLanguageFails) { auto pipeline = TokenizerFactory::create(make_stemmer_params("nonexistent_lang")); - EXPECT_EQ(pipeline, nullptr); + EXPECT_FALSE(pipeline.has_value()); } // ============================================================ @@ -61,7 +62,7 @@ TEST(StemmerTokenFilterTest, CreatePipelineInvalidLanguageFails) { // ============================================================ TEST(StemmerTokenFilterTest, EnglishStemming) { - auto pipeline = TokenizerFactory::create(make_stemmer_params()); + auto pipeline = TokenizerFactory::create(make_stemmer_params()).value(); ASSERT_NE(pipeline, nullptr); auto tokens = pipeline->process("running cats easily connection"); @@ -73,7 +74,7 @@ TEST(StemmerTokenFilterTest, EnglishStemming) { } TEST(StemmerTokenFilterTest, AlreadyStemmedWordsUnchanged) { - auto pipeline = TokenizerFactory::create(make_stemmer_params()); + auto pipeline = TokenizerFactory::create(make_stemmer_params()).value(); ASSERT_NE(pipeline, nullptr); auto tokens = pipeline->process("run cat"); @@ -83,7 +84,7 @@ TEST(StemmerTokenFilterTest, AlreadyStemmedWordsUnchanged) { } TEST(StemmerTokenFilterTest, EmptyInput) { - auto pipeline = TokenizerFactory::create(make_stemmer_params()); + auto pipeline = TokenizerFactory::create(make_stemmer_params()).value(); ASSERT_NE(pipeline, nullptr); auto tokens = pipeline->process(""); @@ -91,7 +92,7 @@ TEST(StemmerTokenFilterTest, EmptyInput) { } TEST(StemmerTokenFilterTest, PreservesOffsetAndPosition) { - auto pipeline = TokenizerFactory::create(make_stemmer_params()); + auto pipeline = TokenizerFactory::create(make_stemmer_params()).value(); ASSERT_NE(pipeline, nullptr); auto tokens = pipeline->process("running dogs"); @@ -107,7 +108,7 @@ TEST(StemmerTokenFilterTest, PreservesOffsetAndPosition) { // ============================================================ TEST(StemmerTokenFilterTest, LowercaseThenStem) { - auto pipeline = TokenizerFactory::create(make_stemmer_params()); + auto pipeline = TokenizerFactory::create(make_stemmer_params()).value(); ASSERT_NE(pipeline, nullptr); auto tokens = pipeline->process("Running Cats EASILY"); @@ -123,7 +124,7 @@ TEST(StemmerTokenFilterTest, LowercaseThenStem) { TEST(StemmerTokenFilterTest, StemmerOnlyNoLowercase) { auto pipeline = - TokenizerFactory::create(make_stemmer_params("", {"stemmer"})); + TokenizerFactory::create(make_stemmer_params("", {"stemmer"})).value(); ASSERT_NE(pipeline, nullptr); auto tokens = pipeline->process("running"); @@ -136,7 +137,8 @@ TEST(StemmerTokenFilterTest, StemmerOnlyNoLowercase) { // ============================================================ TEST(StemmerTokenFilterTest, GermanStemming) { - auto pipeline = TokenizerFactory::create(make_stemmer_params("german")); + auto pipeline = + TokenizerFactory::create(make_stemmer_params("german")).value(); ASSERT_NE(pipeline, nullptr); auto tokens = pipeline->process("laufen"); @@ -149,7 +151,7 @@ TEST(StemmerTokenFilterTest, GermanStemming) { // ============================================================ TEST(StemmerTokenFilterTest, LanguageByISOCode) { - auto pipeline = TokenizerFactory::create(make_stemmer_params("en")); + auto pipeline = TokenizerFactory::create(make_stemmer_params("en")).value(); ASSERT_NE(pipeline, nullptr); auto tokens = pipeline->process("running"); @@ -158,7 +160,8 @@ TEST(StemmerTokenFilterTest, LanguageByISOCode) { } TEST(StemmerTokenFilterTest, PorterAlgorithm) { - auto pipeline = TokenizerFactory::create(make_stemmer_params("porter")); + auto pipeline = + TokenizerFactory::create(make_stemmer_params("porter")).value(); ASSERT_NE(pipeline, nullptr); auto tokens = pipeline->process("running"); diff --git a/tests/db/sqlengine/fts_parser_test.cc b/tests/db/sqlengine/fts_parser_test.cc index 798c0bf8a..23c80979d 100644 --- a/tests/db/sqlengine/fts_parser_test.cc +++ b/tests/db/sqlengine/fts_parser_test.cc @@ -33,7 +33,7 @@ class FtsParserTest : public ::testing::Test { FtsIndexParams params; params.tokenizer_name = "standard"; params.filters = {"lowercase"}; - pipeline_ = TokenizerFactory::create(params); + pipeline_ = TokenizerFactory::create(params).value(); ASSERT_NE(pipeline_, nullptr); } @@ -814,7 +814,7 @@ class FtsParserUnescapeTest : public ::testing::Test { FtsIndexParams params; params.tokenizer_name = "whitespace"; params.filters = {}; - pipeline_ = TokenizerFactory::create(params); + pipeline_ = TokenizerFactory::create(params).value(); ASSERT_NE(pipeline_, nullptr); }