fix: align fulltext bloom tokens with matches_term and use them for LIKE (#9470)

* fix: align fulltext bloom tokens with matches_term and use them for LIKE

The bloom fulltext index stored tokens that did not follow
matches_term's word boundaries (`_`, ASCII next to Han, Han substrings),
so pruning dropped rows matches_term accepts.

Tokens and query probes now share the character classes of
matches_term, Han runs are indexed as bigrams, and LIKE patterns use the
same probes. The bloom blob is written under a new key; blobs from older
versions are no longer probed. Tantivy no longer serves matches_term.

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

* chore: drop unused lazy_static from index and avoid typo-flagged test tokens

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

* docs: link like_probes to tokenize instead of the downstream ScriptTokenizer

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

* refactor: name probe extraction with verbs and document CharClass

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

* feat: rebuild legacy fulltext bloom indexes and probe lower(col)

The manifest records the fulltext bloom version per column, so ADMIN
BUILD_INDEX now rebuilds SSTs whose bloom index predates the current
tokens, and ssts_index_meta shows the version.

Case-insensitive bloom indexes also store the tokens of the whole
lowercased text for rows where it can differ (U+0130, U+212A, sigma),
so matches_term(lower(col), ..) and lower(col) LIKE .. use the index.

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

* test: cover legacy bloom index rewrite by compaction in the compat case

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

* chore: log outdated fulltext bloom indexes in the consistency check

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

* docs: explain the unrecorded fulltext bloom version

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>

---------

Signed-off-by: Dennis Zhuang <killme2008@gmail.com>
This commit is contained in:
dennis zhuang
2026-10-14 08:07:24 +00:00
committed by GitHub
parent 09bbe1cf9f
commit 5c4d9eb4f8
35 changed files with 2687 additions and 1026 deletions
Generated
+1 -3
View File
@@ -2312,6 +2312,7 @@ dependencies = [
"bytes",
"common-test-util",
"futures",
"icu_properties",
"lazy_static",
"pin-project",
"regex",
@@ -2507,7 +2508,6 @@ dependencies = [
"geohash",
"h3o",
"hyperloglogplus",
"icu_properties",
"jsonb",
"memchr",
"mito-codec",
@@ -6954,8 +6954,6 @@ dependencies = [
"futures",
"greptime-proto",
"itertools 0.14.0",
"jieba-rs",
"lazy_static",
"mockall",
"pin-project",
"prost 0.14.1",
+1
View File
@@ -14,6 +14,7 @@ async-trait.workspace = true
bitvec = "1.0"
bytes.workspace = true
futures.workspace = true
icu_properties.workspace = true
lazy_static.workspace = true
pin-project.workspace = true
regex.workspace = true
+1
View File
@@ -25,6 +25,7 @@ pub mod readable_size;
pub mod regex_pattern;
pub mod secrets;
pub mod serde;
pub mod term_token;
pub type AffectedRows = usize;
+280
View File
@@ -0,0 +1,280 @@
// Copyright 2023 Greptime Team
//
// 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.
//! Word boundaries shared by `matches_term` and the fulltext bloom index.
//!
//! Bloom pruning is only sound if the index splits text exactly where
//! `matches_term` sees word boundaries, so both use [`classify_char`], and the
//! query side derives its probes with [`extract_term_probes`] and [`extract_like_probes`].
use std::ops::Range;
use icu_properties::props::Script;
use icu_properties::{CodePointMapData, CodePointMapDataBorrowed};
/// Classes of characters that decide where `matches_term` sees a word boundary.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CharClass {
/// `[0-9A-Za-z]`.
AsciiWord,
/// Characters of the Han script, alphanumeric or not.
Han,
/// Non-ASCII alphanumerics outside the Han script, such as `é` or `д`.
UnicodeWord,
/// Everything else, including `_`.
Other,
}
/// Returns the [`CharClass`] of `c`. [`tokenize`] splits text wherever the class
/// changes and at every [`CharClass::Other`] character.
pub fn classify_char(c: char) -> CharClass {
if c.is_ascii_alphanumeric() {
CharClass::AsciiWord
} else if is_han(c) {
CharClass::Han
} else if c.is_alphanumeric() {
CharClass::UnicodeWord
} else {
CharClass::Other
}
}
static HAN_SCRIPT_DATA: CodePointMapDataBorrowed<'static, Script> =
CodePointMapData::<Script>::new();
fn is_han(c: char) -> bool {
HAN_SCRIPT_DATA.get(c) == Script::Han
}
/// Returns whether lowercasing `text` as a whole can produce tokens that lowercasing
/// each token of `text` doesn't, which `matches_term(lower(col), ..)` and
/// `lower(col) LIKE ..` rely on. Every other character lowercases to one character
/// of the same class, but U+0130 and U+212A change length or class, and `Σ`
/// lowercases depending on the characters after it.
pub fn has_irregular_lowercase(text: &str) -> bool {
!text.is_ascii() && text.contains(['\u{130}', '\u{212A}', 'Σ'])
}
/// Returns the tokens the fulltext bloom index stores for `text`.
///
/// A token is a maximal run of ASCII alphanumerics or of non-Han alphanumerics;
/// `_` and other symbols only separate tokens. A run of Han characters is
/// emitted as its bigrams, or as itself when it has a single character, since
/// `matches_term` matches Han terms as plain substrings.
pub fn tokenize(text: &str) -> Vec<&str> {
let mut tokens = Vec::new();
for_each_word_run(text, |class, range| {
let run = &text[range];
if class == CharClass::Han {
push_han_tokens(run, &mut tokens);
} else {
tokens.push(run);
}
});
tokens
}
/// Calls `f` on every maximal run of word characters of the same class.
fn for_each_word_run(text: &str, mut f: impl FnMut(CharClass, Range<usize>)) {
if text.is_ascii() {
// ASCII text only has `AsciiWord` and `Other` characters, so scan bytes
// instead of decoding chars. Most log lines take this path.
let mut start = None;
for (i, b) in text.bytes().enumerate() {
match (b.is_ascii_alphanumeric(), start) {
(true, None) => start = Some(i),
(false, Some(s)) => {
f(CharClass::AsciiWord, s..i);
start = None;
}
_ => {}
}
}
if let Some(s) = start {
f(CharClass::AsciiWord, s..text.len());
}
return;
}
let mut run: Option<(CharClass, usize)> = None;
for (i, c) in text.char_indices() {
let class = classify_char(c);
if let Some((run_class, start)) = run {
if run_class == class {
continue;
}
f(run_class, start..i);
run = None;
}
if class != CharClass::Other {
run = Some((class, i));
}
}
if let Some((run_class, start)) = run {
f(run_class, start..text.len());
}
}
fn push_han_tokens<'a>(run: &'a str, tokens: &mut Vec<&'a str>) {
let mut starts = run.char_indices().map(|(i, _)| i).skip(1);
let Some(mut second) = starts.next() else {
tokens.push(run);
return;
};
let mut first = 0;
for next in starts.chain(std::iter::once(run.len())) {
tokens.push(&run[first..next]);
first = second;
second = next;
}
}
/// Returns tokens that every text satisfying `matches_term(text, term)` contains,
/// as produced by [`tokenize`].
pub fn extract_term_probes(term: &str) -> Vec<String> {
// Non-Han terms only match between word boundaries that `tokenize` also
// splits at, so their edge runs are whole tokens. Han-containing terms match
// as plain substrings and give no such guarantee at their edges.
let bounded = !term.chars().any(|c| classify_char(c) == CharClass::Han);
let mut probes = Vec::new();
push_literal_probes(term, bounded, bounded, &mut probes);
probes
}
/// Returns tokens that every text satisfying `text LIKE pattern` contains, as
/// produced by [`tokenize`]. `\` escapes the next character, following
/// arrow's `like` kernel; a trailing `\` is a literal backslash.
pub fn extract_like_probes(pattern: &str) -> Vec<String> {
let mut probes = Vec::new();
let mut literal = String::new();
// The pattern start and end are anchors; a wildcard can stand for word
// characters, so a run touching it may be part of a longer token.
let mut anchored_left = true;
let mut chars = pattern.chars();
while let Some(c) = chars.next() {
match c {
'%' | '_' => {
push_literal_probes(&literal, anchored_left, false, &mut probes);
literal.clear();
anchored_left = false;
}
'\\' => literal.push(chars.next().unwrap_or('\\')),
c => literal.push(c),
}
}
push_literal_probes(&literal, anchored_left, true, &mut probes);
probes
}
/// Pushes the tokens of `literal` that stay whole tokens wherever `literal`
/// occurs. A side is bounded when the character beyond it can't extend the
/// adjacent run.
fn push_literal_probes(
literal: &str,
left_bounded: bool,
right_bounded: bool,
probes: &mut Vec<String>,
) {
for_each_word_run(literal, |class, range| {
let whole =
(range.start > 0 || left_bounded) && (range.end < literal.len() || right_bounded);
let run = &literal[range];
if class == CharClass::Han && run.chars().nth(1).is_some() {
// Bigrams inside the run occur in every text containing it.
let mut tokens = Vec::new();
push_han_tokens(run, &mut tokens);
probes.extend(tokens.into_iter().map(str::to_string));
} else if whole {
probes.push(run.to_string());
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn han_detection_uses_script_not_all_cjk() {
assert!(is_han('汉'));
assert!(is_han('\u{30000}'));
assert!(!is_han('あ'));
assert!(!is_han('한'));
}
#[test]
fn test_script_tokenizer() {
let cases: [(&str, &[&str]); 7] = [
("hello_world __x__", &["hello", "world", "x"]),
("trace_id=abc", &["trace", "id", "abc"]),
("错误error日志", &["错误", "error", "日志"]),
("连接timeout.5次", &["连接", "timeout", "5", "次"]),
("用户user-123登录", &["用户", "user", "123", "登录"]),
("中国农业银行", &["中国", "国农", "农业", "业银", "银行"]),
("Größe日x", &["Gr", "öß", "e", "日", "x"]),
];
for (text, expected) in cases {
assert_eq!(tokenize(text), expected, "text: {text}");
}
}
#[test]
fn test_irregular_lowercase_chars() {
for c in (0..=0x10FFFF).filter_map(char::from_u32) {
let lower = c.to_lowercase().collect::<Vec<_>>();
let regular = lower.len() == 1 && classify_char(lower[0]) == classify_char(c);
let expect_regular = !matches!(c, '\u{130}' | '\u{212A}');
assert_eq!(regular, expect_regular, "U+{:04X}", c as u32);
}
}
#[test]
fn test_term_probes() {
let cases: [(&str, &[&str]); 8] = [
("world", &["world"]),
("hello world", &["hello", "world"]),
("/start", &["start"]),
("naïve", &["na", "ï", "ve"]),
// Han terms match as substrings: only bigrams and runs enclosed by
// other characters of the term are safe.
("农业", &["农业"]),
("农", &[]),
("机号1888", &["机号"]),
("x错误y日z", &["错误", "y", "日"]),
];
for (term, expected) in cases {
assert_eq!(extract_term_probes(term), expected, "term: {term}");
}
}
#[test]
fn test_like_probes() {
let cases: [(&str, &[&str]); 11] = [
("%timeout%", &[]),
("% timeout %", &["timeout"]),
("timeout%", &[]),
("timeout: %", &["timeout"]),
("%: timeout", &["timeout"]),
("a_b c", &["c"]),
(r"trace\_id=%", &["trace", "id"]),
(r"100\% done", &["100", "done"]),
(r"x\\y", &["x", "y"]),
("%错误error日%", &["错误", "error"]),
("%错误 x%", &["错误"]),
];
for (pattern, expected) in cases {
assert_eq!(extract_like_probes(pattern), expected, "pattern: {pattern}");
}
}
}
-1
View File
@@ -46,7 +46,6 @@ geo-types = { version = "0.7", optional = true }
geohash = { version = "0.13", optional = true }
h3o = { version = "0.6", optional = true }
hyperloglogplus = "0.4"
icu_properties.workspace = true
jsonb.workspace = true
memchr = "2.7"
mito-codec.workspace = true
@@ -15,13 +15,12 @@
use std::fmt;
use std::sync::Arc;
use common_base::term_token::{CharClass, classify_char};
use datafusion_common::arrow::array::{Array, AsArray, BooleanArray, BooleanBuilder};
use datafusion_common::arrow::compute;
use datafusion_common::arrow::datatypes::DataType;
use datafusion_common::{DataFusionError, ScalarValue};
use datafusion_expr::{ColumnarValue, ScalarFunctionArgs, Signature, Volatility};
use icu_properties::props::Script;
use icu_properties::{CodePointMapData, CodePointMapDataBorrowed};
use memchr::memmem;
use crate::function::Function;
@@ -231,14 +230,6 @@ pub struct MatchesTermFinder {
ends_with_other: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum CharClass {
AsciiWord,
Han,
UnicodeWord,
Other,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum TermKind {
AsciiLike,
@@ -246,25 +237,6 @@ enum TermKind {
HanContaining,
}
fn classify_char(c: char) -> CharClass {
if c.is_ascii_alphanumeric() {
CharClass::AsciiWord
} else if is_han(c) {
CharClass::Han
} else if c.is_alphanumeric() {
CharClass::UnicodeWord
} else {
CharClass::Other
}
}
static HAN_SCRIPT_DATA: CodePointMapDataBorrowed<'static, Script> =
CodePointMapData::<Script>::new();
fn is_han(c: char) -> bool {
HAN_SCRIPT_DATA.get(c) == Script::Han
}
fn classify_term(term: &str) -> TermKind {
let mut has_han = false;
let mut has_unicode_word = false;
@@ -508,12 +480,4 @@ mod tests {
assert!(MatchesTermFinder::new("行账号").find("中国农业银行账号"));
assert!(MatchesTermFinder::new("登录").find("登录手机号18888888888的动态key"));
}
#[test]
fn han_detection_uses_script_not_all_cjk() {
assert!(is_han('汉'));
assert!(is_han('\u{30000}'));
assert!(!is_han('あ'));
assert!(!is_han('한'));
}
}
-2
View File
@@ -23,8 +23,6 @@ fst.workspace = true
futures.workspace = true
greptime-proto.workspace = true
itertools.workspace = true
jieba-rs = "0.11"
lazy_static.workspace = true
mockall.workspace = true
pin-project.workspace = true
prost.workspace = true
+2 -2
View File
@@ -31,7 +31,7 @@ use index::bloom_filter::reader::BloomFilterReaderImpl;
use index::external_provider::{ExternalTempFileProvider, Reader, Writer};
use index::fulltext_index::Config;
use index::fulltext_index::create::{BloomFilterFulltextIndexCreator, FulltextIndexCreator};
use index::fulltext_index::tokenizer::{Analyzer, EnglishTokenizer};
use index::fulltext_index::tokenizer::{Analyzer, ScriptTokenizer};
use index::inverted_index::create::InvertedIndexCreator;
use index::inverted_index::create::sort::external_sort::ExternalSorter;
use index::inverted_index::create::sort_create::SortIndexCreator;
@@ -365,7 +365,7 @@ fn bench_bloom_search(c: &mut Criterion) {
let mut group = c.benchmark_group("bloom_search");
let lines = service_logs(ROWS * 10);
let analyzer = Analyzer::new(Box::new(EnglishTokenizer), false);
let analyzer = Analyzer::new(Box::new(ScriptTokenizer), false);
let blob = rt.block_on(async {
let mut creator = bloom_creator();
for line in &lines {
+11 -11
View File
@@ -21,7 +21,7 @@ use async_trait::async_trait;
use criterion::{BatchSize, BenchmarkId, Criterion, Throughput, criterion_group, criterion_main};
use futures::AsyncRead;
use index::fulltext_index::create::{FulltextIndexCreator, TantivyFulltextIndexCreator};
use index::fulltext_index::tokenizer::{ChineseTokenizer, EnglishTokenizer, Tokenizer};
use index::fulltext_index::tokenizer::{ScriptTokenizer, Tokenizer};
use index::fulltext_index::{Analyzer, Config};
use puffin::puffin_manager::{PuffinWriter, PutOptions};
@@ -86,8 +86,8 @@ impl PuffinWriter for NoopPuffinWriter {
}
}
fn bench_english_tokenizer(c: &mut Criterion) {
let tokenizer = EnglishTokenizer;
fn bench_tokenizer_english_text(c: &mut Criterion) {
let tokenizer = ScriptTokenizer;
let texts = vec![
("short", "Hello, world! This is a test."),
@@ -125,7 +125,7 @@ fn bench_english_tokenizer(c: &mut Criterion) {
),
];
let mut group = c.benchmark_group("english_tokenizer");
let mut group = c.benchmark_group("tokenizer_english_text");
for (size, text) in texts {
group.bench_with_input(BenchmarkId::new("tokenize", size), &text, |b, text| {
@@ -136,7 +136,7 @@ fn bench_english_tokenizer(c: &mut Criterion) {
group.finish();
// Benchmark with repeated tokenization to simulate real-world usage
let mut repeat_group = c.benchmark_group("english_tokenizer_repeated");
let mut repeat_group = c.benchmark_group("tokenizer_english_text_repeated");
let sample_text = "The quick brown fox jumps over the lazy dog. This sentence contains most letters of the alphabet.";
@@ -157,9 +157,9 @@ fn bench_english_tokenizer(c: &mut Criterion) {
repeat_group.finish();
}
fn bench_chinese_tokenizer(c: &mut Criterion) {
let tokenizer = ChineseTokenizer;
let mut group = c.benchmark_group("chinese_tokenizer");
fn bench_tokenizer_chinese_text(c: &mut Criterion) {
let tokenizer = ScriptTokenizer;
let mut group = c.benchmark_group("tokenizer_chinese_text");
for (name, text) in CHINESE_TOKENIZER_TEXTS {
group.throughput(Throughput::Bytes(text.len() as u64));
@@ -170,7 +170,7 @@ fn bench_chinese_tokenizer(c: &mut Criterion) {
group.finish();
let mut repeat_group = c.benchmark_group("chinese_tokenizer_repeated");
let mut repeat_group = c.benchmark_group("tokenizer_chinese_text_repeated");
let sample_text = CHINESE_TOKENIZER_TEXTS
.iter()
.find(|(name, _)| *name == "mixed_log")
@@ -253,8 +253,8 @@ fn bench_tantivy_chinese_fulltext_index(c: &mut Criterion) {
criterion_group!(
benches,
bench_english_tokenizer,
bench_chinese_tokenizer,
bench_tokenizer_english_text,
bench_tokenizer_chinese_text,
bench_tantivy_chinese_fulltext_index
);
criterion_main!(benches);
@@ -29,7 +29,7 @@ use crate::fulltext_index::error::{
AbortedSnafu, BiErrorsSnafu, BloomFilterFinishSnafu, ExternalSnafu, PuffinAddBlobSnafu, Result,
SerializeToJsonSnafu,
};
use crate::fulltext_index::tokenizer::{Analyzer, ChineseTokenizer, EnglishTokenizer};
use crate::fulltext_index::tokenizer::{Analyzer, ScriptTokenizer};
use crate::fulltext_index::{Config, KEY_FULLTEXT_CONFIG};
const PIPE_BUFFER_SIZE_FOR_SENDING_BLOB: usize = 8192;
@@ -50,11 +50,9 @@ impl BloomFilterFulltextIndexCreator {
global_memory_usage: Arc<AtomicUsize>,
global_memory_usage_threshold: Option<usize>,
) -> Self {
let tokenizer = match config.analyzer {
crate::fulltext_index::Analyzer::English => Box::new(EnglishTokenizer) as _,
crate::fulltext_index::Analyzer::Chinese => Box::new(ChineseTokenizer) as _,
};
let analyzer = Analyzer::new(tokenizer, config.case_sensitive);
// Both analyzers tokenize the same way: query probes must be derivable from the
// term alone, which a context-dependent segmenter like jieba can't provide.
let analyzer = Analyzer::new(Box::new(ScriptTokenizer), config.case_sensitive);
let inner = BloomFilterCreator::new(
rows_per_segment,
+53 -539
View File
@@ -12,121 +12,32 @@
// See the License for the specific language governing permissions and
// limitations under the License.
use common_base::term_token::{self, has_irregular_lowercase};
use crate::Bytes;
use crate::bloom_filter::element_hash;
use crate::fulltext_index::error::Result;
lazy_static::lazy_static! {
static ref JIEBA: jieba_rs::Jieba = jieba_rs::Jieba::new();
}
/// A-Z, a-z, 0-9, and '_' are true
const VALID_ASCII_TOKEN: [bool; 256] = [
false, false, false, false, false, false, false, false, false, false, false, false, false,
false, false, false, false, false, false, false, false, false, false, false, false, false,
false, false, false, false, false, false, false, false, false, false, false, false, false,
false, false, false, false, false, false, false, false, false, true, true, true, true, true,
true, true, true, true, true, false, false, false, false, false, false, false, true, true,
true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true,
true, true, true, true, true, true, true, true, false, false, false, false, true, false, true,
true, true, true, true, true, true, true, true, true, true, true, true, true, true, true, true,
true, true, true, true, true, true, true, true, true, false, false, false, false, false, false,
false, false, false, false, false, false, false, false, false, false, false, false, false,
false, false, false, false, false, false, false, false, false, false, false, false, false,
false, false, false, false, false, false, false, false, false, false, false, false, false,
false, false, false, false, false, false, false, false, false, false, false, false, false,
false, false, false, false, false, false, false, false, false, false, false, false, false,
false, false, false, false, false, false, false, false, false, false, false, false, false,
false, false, false, false, false, false, false, false, false, false, false, false, false,
false, false, false, false, false, false, false, false, false, false, false, false, false,
false, false, false, false, false, false, false, false, false, false, false, false, false,
false, false, false, false, false, false, false, false, false, false,
];
/// `Tokenizer` tokenizes a text into a list of tokens.
pub trait Tokenizer: Send {
fn tokenize<'a>(&self, text: &'a str) -> Vec<&'a str>;
}
/// `EnglishTokenizer` tokenizes an English text.
///
/// It splits the text by non-alphabetic characters.
/// `ScriptTokenizer` produces the tokens described in [`term_token::tokenize`].
#[derive(Debug, Default)]
pub struct EnglishTokenizer;
pub struct ScriptTokenizer;
impl Tokenizer for EnglishTokenizer {
impl Tokenizer for ScriptTokenizer {
fn tokenize<'a>(&self, text: &'a str) -> Vec<&'a str> {
if text.is_ascii() {
let mut tokens = Vec::new();
let mut start = 0;
for (i, &byte) in text.as_bytes().iter().enumerate() {
if !VALID_ASCII_TOKEN[byte as usize] {
if start < i {
tokens.push(&text[start..i]);
}
start = i + 1;
}
}
if start < text.len() {
tokens.push(&text[start..]);
}
tokens
} else {
text.split(|c: char| !c.is_alphanumeric() && c != '_')
.filter(|s| !s.is_empty())
.collect()
}
term_token::tokenize(text)
}
}
/// `ChineseTokenizer` tokenizes a Chinese text.
///
/// It uses Jieba search-mode tokenization to improve recall for Chinese fulltext search.
/// Enabling HMM also helps merge some unknown fragments into larger tokens, which can reduce
/// token cardinality versus a fully fragmented output.
#[derive(Debug, Default)]
pub struct ChineseTokenizer;
impl Tokenizer for ChineseTokenizer {
fn tokenize<'a>(&self, text: &'a str) -> Vec<&'a str> {
if text.is_ascii() {
EnglishTokenizer {}.tokenize(text)
} else {
// Search-mode tokenization emits finer-grained searchable terms, while HMM helps
// merge some unknown fragments and avoid excessive token fragmentation.
let mut tokens = JIEBA
.cut_for_search(text, true)
.into_iter()
.map(|token| token.word)
.filter(|token| is_indexable_token(token))
.collect::<Vec<_>>();
let english = EnglishTokenizer {};
tokens.extend(
english
.tokenize(text)
.into_iter()
.filter(|token| is_ascii_underscore_token(token)),
);
tokens
}
}
}
fn is_indexable_token(token: &str) -> bool {
token.chars().any(|c| c.is_alphanumeric() || c == '_')
}
fn is_ascii_underscore_token(token: &str) -> bool {
token.is_ascii() && token.chars().any(|c| c == '_')
}
/// `Analyzer` analyzes a text into a list of tokens.
///
/// It uses a `Tokenizer` to tokenize the text and optionally lowercases the tokens.
/// When case-insensitive, it also emits the tokens of the whole lowercased text if
/// they can differ, so that predicates on `lower(col)` can probe the index.
pub struct Analyzer {
tokenizer: Box<dyn Tokenizer>,
case_sensitive: bool,
@@ -152,22 +63,36 @@ impl Analyzer {
buf: &'a mut Vec<u8>,
) -> impl Iterator<Item = u64> + use<'a> {
let case_sensitive = self.case_sensitive;
self.tokenizer.tokenize(text).into_iter().map(move |token| {
if case_sensitive {
element_hash(token.as_bytes())
} else if token.is_ascii() {
buf.clear();
buf.extend(token.bytes().map(|b| b.to_ascii_lowercase()));
element_hash(buf)
} else {
element_hash(token.to_lowercase().as_bytes())
}
})
let lowercased_text_hashes = self
.irregular_lowercased_text(text)
.map(|lowered| {
self.tokenizer
.tokenize(&lowered)
.into_iter()
.map(|token| element_hash(token.as_bytes()))
.collect::<Vec<_>>()
})
.unwrap_or_default();
self.tokenizer
.tokenize(text)
.into_iter()
.map(move |token| {
if case_sensitive {
element_hash(token.as_bytes())
} else if token.is_ascii() {
buf.clear();
buf.extend(token.bytes().map(|b| b.to_ascii_lowercase()));
element_hash(buf)
} else {
element_hash(token.to_lowercase().as_bytes())
}
})
.chain(lowercased_text_hashes)
}
/// Analyzes the given text into a list of tokens.
pub fn analyze_text(&self, text: &str) -> Result<Vec<Bytes>> {
let res = self
let mut res = self
.tokenizer
.tokenize(text)
.iter()
@@ -178,9 +103,22 @@ impl Analyzer {
s.to_lowercase().as_bytes().to_vec()
}
})
.collect();
.collect::<Vec<_>>();
if let Some(lowered) = self.irregular_lowercased_text(text) {
res.extend(
self.tokenizer
.tokenize(&lowered)
.into_iter()
.map(|token| token.as_bytes().to_vec()),
);
}
Ok(res)
}
/// Returns the lowercased text whose tokens must be indexed too, if any.
fn irregular_lowercased_text(&self, text: &str) -> Option<String> {
(!self.case_sensitive && has_irregular_lowercase(text)).then(|| text.to_lowercase())
}
}
#[cfg(test)]
@@ -190,12 +128,8 @@ mod tests {
#[test]
fn test_analyze_text_hashes_matches_analyze_text() {
let text = "Hello, WORLD ship_Ship 清洁表面 ÄÖÜ straße İstanbul x";
for (tokenizer, case_sensitive) in [
(Box::new(EnglishTokenizer) as Box<dyn Tokenizer>, false),
(Box::new(EnglishTokenizer), true),
(Box::new(ChineseTokenizer), false),
] {
let analyzer = Analyzer::new(tokenizer, case_sensitive);
for case_sensitive in [false, true] {
let analyzer = Analyzer::new(Box::new(ScriptTokenizer), case_sensitive);
let expected = analyzer
.analyze_text(text)
.unwrap()
@@ -209,433 +143,13 @@ mod tests {
}
}
#[test]
fn test_english_tokenizer() {
let tokenizer = EnglishTokenizer;
let text = "Hello, world!!! This is a----++ test012_345+67890 ship_ship ship__ship _ __ __IDENTIFIER__ _ship ship_";
let tokens = tokenizer.tokenize(text);
assert_eq!(
tokens,
vec![
"Hello",
"world",
"This",
"is",
"a",
"test012_345",
"67890",
"ship_ship",
"ship__ship",
"_",
"__",
"__IDENTIFIER__",
"_ship",
"ship_"
]
);
}
#[test]
fn test_english_tokenizer_with_utf8() {
let tokenizer = EnglishTokenizer;
let text = "💸unfold the 纸巾😣and gently 清洁表😭面";
let tokens = tokenizer.tokenize(text);
assert_eq!(
tokens,
// Don't care what happens to non-ASCII characters.
// It's kind of a misconfiguration to use EnglishTokenizer on non-ASCII text.
vec!["unfold", "the", "纸巾", "and", "gently", "清洁表", "面"]
);
}
#[test]
fn test_chinese_tokenizer() {
let tokenizer = ChineseTokenizer;
let text = "我喜欢苹果";
let tokens = tokenizer.tokenize(text);
assert_eq!(tokens, vec!["我", "喜欢", "苹果"]);
}
#[test]
fn test_chinese_tokenizer_issue_7943_sample() {
let tokenizer = ChineseTokenizer;
let text = "[2026/04/09/ 13:56:11.031]2026-04-09 13:56:11.031 - [ trace_id=340a6a44b0bd8e37bb7697ss7da61ff0 span_id=085ff5ttf1e0a23b trace_flags=01] - [http-nio-8081-exec-16] INFO c.h.p.xx.web.service.impl.CCCXForwardKKKServiceImpl.pushout(188) - 登录手机号18888888888的动态key:829889AC8 ship_ship ship__ship _ __ __IDENTIFIER__ _ship ship_ EOF";
let tokens = tokenizer.tokenize(text);
// Jieba 0.11 preserves ASCII compounds and emits searchable alphabetic parts.
// The underscore fallback also retains leading, trailing, and repeated underscores.
assert_eq!(
tokens,
vec![
"2026",
"04",
"09",
"13",
"56",
"11.031",
"2026-04-09",
"13",
"56",
"11.031",
"trace",
"id",
"trace_id",
"340a6a44b0bd8e37bb7697ss7da61ff0",
"span",
"id",
"span_id",
"085ff5ttf1e0a23b",
"trace",
"flags",
"trace_flags",
"01",
"http",
"nio",
"exec",
"http-nio-8081-exec-16",
"INFO",
"xx",
"web",
"service",
"impl",
"CCCXForwardKKKServiceImpl",
"pushout",
"c.h.p.xx.web.service.impl.CCCXForwardKKKServiceImpl.pushout",
"188",
"登录",
"手机",
"手机号",
"18888888888",
"的",
"动态",
"key",
"829889AC8",
"ship",
"ship",
"ship_ship",
"ship",
"__",
"ship",
"_",
"__",
"__",
"IDENTIFIER",
"__",
"_",
"ship",
"ship",
"_",
"EOF",
"trace_id",
"span_id",
"trace_flags",
"ship_ship",
"ship__ship",
"_",
"__",
"__IDENTIFIER__",
"_ship",
"ship_"
]
);
}
#[test]
fn test_chinese_tokenizer_keeps_ascii_underscore_compounds() {
let tokenizer = ChineseTokenizer;
let text = "trace_id=abc 登录手机号 dynamic_key=xyz";
let tokens = tokenizer.tokenize(text);
assert!(tokens.contains(&"trace_id"));
assert!(tokens.contains(&"dynamic_key"));
assert!(tokens.contains(&"登录"));
assert!(tokens.contains(&"手机号"));
}
#[test]
fn test_chinese_tokenizer_skips_non_ascii_underscore_tokens() {
let tokenizer = ChineseTokenizer;
let text = "登录_id trace_id 手机号_trace";
let tokens = tokenizer.tokenize(text);
assert_eq!(
tokens,
[
"登录",
"_",
"id",
"trace",
"id",
"trace_id",
"手机",
"手机号",
"_",
"trace",
"trace_id"
]
);
}
#[test]
fn test_chinese_tokenizer_aggressive_tokenization_probe() {
let tokenizer = ChineseTokenizer;
let text = "哈基米哦南北绿豆,噢马自立曼波。登录手机号。中国农业银行。装电视台,中国中央广播电视台。压不缩,笑不活。";
let default_tokens = tokenizer.tokenize(text);
let cut_hmm_false = JIEBA
.cut(text, false)
.into_iter()
.map(|token| token.word)
.collect::<Vec<_>>();
let cut_hmm_true = JIEBA
.cut(text, true)
.into_iter()
.map(|token| token.word)
.collect::<Vec<_>>();
let cut_for_search_hmm_false = JIEBA
.cut_for_search(text, false)
.into_iter()
.map(|token| token.word)
.collect::<Vec<_>>();
let cut_for_search_hmm_true = JIEBA
.cut_for_search(text, true)
.into_iter()
.map(|token| token.word)
.collect::<Vec<_>>();
assert_eq!(
default_tokens,
[
"哈基米",
"哦",
"南北",
"绿豆",
"噢",
"马",
"自立",
"曼波",
"登录",
"手机",
"手机号",
"中国",
"农业",
"银行",
"中国农业银行",
"装",
"电视",
"电视台",
"中国",
"中央",
"广播",
"电视",
"电视台",
"不缩",
"压不缩",
"笑",
"不活",
]
);
assert_eq!(
cut_hmm_false,
[
"哈",
"基",
"米",
"哦",
"南北",
"绿豆",
",",
"噢",
"马",
"自立",
"曼",
"波",
"。",
"登录",
"手机号",
"。",
"中国农业银行",
"。",
"装",
"电视台",
",",
"中国",
"中央",
"广播",
"电视台",
"。",
"压",
"不",
"缩",
",",
"笑",
"不",
"活",
"。"
]
);
assert_eq!(
cut_hmm_true,
[
"哈基米",
"哦",
"南北",
"绿豆",
",",
"噢",
"马",
"自立",
"曼波",
"。",
"登录",
"手机号",
"。",
"中国农业银行",
"。",
"装",
"电视台",
",",
"中国",
"中央",
"广播",
"电视台",
"。",
"压不缩",
",",
"笑",
"不活",
"。"
]
);
assert_eq!(
cut_for_search_hmm_false,
[
"哈",
"基",
"米",
"哦",
"南北",
"绿豆",
",",
"噢",
"马",
"自立",
"曼",
"波",
"。",
"登录",
"手机",
"手机号",
"。",
"中国",
"农业",
"银行",
"中国农业银行",
"。",
"装",
"电视",
"电视台",
",",
"中国",
"中央",
"广播",
"电视",
"电视台",
"。",
"压",
"不",
"缩",
",",
"笑",
"不",
"活",
"。"
]
);
assert_eq!(
cut_for_search_hmm_true,
[
"哈基米",
"哦",
"南北",
"绿豆",
",",
"噢",
"马",
"自立",
"曼波",
"。",
"登录",
"手机",
"手机号",
"。",
"中国",
"农业",
"银行",
"中国农业银行",
"。",
"装",
"电视",
"电视台",
",",
"中国",
"中央",
"广播",
"电视",
"电视台",
"。",
"不缩",
"压不缩",
",",
"笑",
"不活",
"。"
]
);
}
#[test]
fn test_valid_ascii_token_lookup_table() {
// Test all ASCII values in a single loop
for c in 0u8..=255u8 {
let is_valid = VALID_ASCII_TOKEN[c as usize];
let should_be_valid = (c as char).is_ascii_alphanumeric() || c == b'_';
assert_eq!(
is_valid,
should_be_valid,
"Character '{}' (byte {}) validity mismatch: expected {}, got {}",
if c.is_ascii() && !c.is_ascii_control() {
c as char
} else {
'?'
},
c,
should_be_valid,
is_valid
);
}
}
#[test]
fn test_analyzer() {
let tokenizer = EnglishTokenizer;
let analyzer = Analyzer::new(Box::new(tokenizer), false);
let text = "Hello, world! This is a test.";
let tokens = analyzer.analyze_text(text).unwrap();
let analyzer = Analyzer::new(Box::new(ScriptTokenizer), false);
let tokens = analyzer.analyze_text("Hello, World_Two").unwrap();
assert_eq!(
tokens,
vec![
b"hello".to_vec(),
b"world".to_vec(),
b"this".to_vec(),
b"is".to_vec(),
b"a".to_vec(),
b"test".to_vec()
]
vec![b"hello".to_vec(), b"world".to_vec(), b"two".to_vec()]
);
}
}
+10 -2
View File
@@ -23,7 +23,7 @@ use store_api::storage::{ColumnId, FileId};
use crate::metrics::{CACHE_BYTES, CACHE_EVICTION, CACHE_HIT, CACHE_MISS};
use crate::sst::index::fulltext_index::applier::builder::{
FulltextQuery, FulltextRequest, FulltextTerm,
FulltextLike, FulltextQuery, FulltextRequest, FulltextTerm,
};
use crate::sst::parquet::row_selection::RowGroupSelection;
@@ -176,7 +176,12 @@ impl FulltextIndexKey {
.iter()
.map(|term| term.term.len() + size_of::<FulltextTerm>())
.sum::<usize>();
query_size + term_size
let like_size = request
.likes
.iter()
.map(|like| like.pattern.len() + size_of::<FulltextLike>())
.sum::<usize>();
query_size + term_size + like_size
})
.sum();
Self {
@@ -343,6 +348,7 @@ mod tests {
FulltextQuery("another long query string".to_string()),
],
terms: vec![],
likes: vec![],
};
predicates1.insert(1, request1);
let key1 = PredicateKey::new_fulltext(Arc::new(predicates1));
@@ -357,6 +363,7 @@ mod tests {
FulltextQuery("another long query string".to_string()),
],
terms: vec![],
likes: vec![],
};
predicates2.insert(1, request2);
let key2 = PredicateKey::new_fulltext(Arc::new(predicates2));
@@ -427,6 +434,7 @@ mod tests {
col_lowered: false,
term: "test term".to_string(),
}],
likes: vec![],
};
predicates1.insert(1, request1);
let key1 = PredicateKey::new_fulltext(Arc::new(predicates1));
+2 -2
View File
@@ -1262,13 +1262,13 @@ async fn test_all_region_infos() {
async fn test_all_index_metas_list_all_types() {
test_all_index_metas_list_all_types_with_format(false, r#"
PuffinIndexMetaEntry { table_dir: "test/", index_file_path: "test/11_0000000001/index/<file_id>.puffin", region_id: 47244640257(11, 1), table_id: 11, region_number: 1, region_group: 0, region_sequence: 1, file_id: "<file_id>", index_file_size: Some(6000), index_type: "bloom_filter", target_type: "column", target_key: "1", target_json: "{\"column\":1}", blob_size: 751, meta_json: Some("{\"bloom\":{\"bloom_filter_size\":640,\"row_count\":20,\"rows_per_segment\":2,\"segment_count\":10}}"), node_id: None }
PuffinIndexMetaEntry { table_dir: "test/", index_file_path: "test/11_0000000001/index/<file_id>.puffin", region_id: 47244640257(11, 1), table_id: 11, region_number: 1, region_group: 0, region_sequence: 1, file_id: "<file_id>", index_file_size: Some(6000), index_type: "fulltext_bloom", target_type: "column", target_key: "4", target_json: "{\"column\":4}", blob_size: 89, meta_json: Some("{\"bloom\":{\"bloom_filter_size\":64,\"row_count\":20,\"rows_per_segment\":4,\"segment_count\":5},\"fulltext\":{\"analyzer\":\"English\",\"case_sensitive\":false}}"), node_id: None }
PuffinIndexMetaEntry { table_dir: "test/", index_file_path: "test/11_0000000001/index/<file_id>.puffin", region_id: 47244640257(11, 1), table_id: 11, region_number: 1, region_group: 0, region_sequence: 1, file_id: "<file_id>", index_file_size: Some(6000), index_type: "fulltext_bloom", target_type: "column", target_key: "4", target_json: "{\"column\":4}", blob_size: 89, meta_json: Some("{\"bloom\":{\"bloom_filter_size\":64,\"row_count\":20,\"rows_per_segment\":4,\"segment_count\":5},\"fulltext\":{\"analyzer\":\"English\",\"bloom_version\":2,\"case_sensitive\":false}}"), node_id: None }
PuffinIndexMetaEntry { table_dir: "test/", index_file_path: "test/11_0000000001/index/<file_id>.puffin", region_id: 47244640257(11, 1), table_id: 11, region_number: 1, region_group: 0, region_sequence: 1, file_id: "<file_id>", index_file_size: Some(6000), index_type: "fulltext_tantivy", target_type: "column", target_key: "5", target_json: "{\"column\":5}", blob_size: 1000, meta_json: Some("{\"fulltext\":{\"analyzer\":\"Chinese\",\"case_sensitive\":true}}"), node_id: None }
PuffinIndexMetaEntry { table_dir: "test/", index_file_path: "test/11_0000000001/index/<file_id>.puffin", region_id: 47244640257(11, 1), table_id: 11, region_number: 1, region_group: 0, region_sequence: 1, file_id: "<file_id>", index_file_size: Some(6000), index_type: "inverted", target_type: "column", target_key: "1", target_json: "{\"column\":1}", blob_size: 518, meta_json: Some("{\"inverted\":{\"base_offset\":0,\"bitmap_type\":\"Roaring\",\"fst_size\":150,\"inverted_index_size\":518,\"null_bitmap_size\":8,\"relative_fst_offset\":368,\"relative_null_bitmap_offset\":0,\"segment_row_count\":1024,\"total_row_count\":20}}"), node_id: None }
PuffinIndexMetaEntry { table_dir: "test/", index_file_path: "test/11_0000000001/index/<file_id>.puffin", region_id: 47244640257(11, 1), table_id: 11, region_number: 1, region_group: 0, region_sequence: 1, file_id: "<file_id>", index_file_size: Some(6000), index_type: "inverted", target_type: "column", target_key: "2", target_json: "{\"column\":2}", blob_size: 515, meta_json: Some("{\"inverted\":{\"base_offset\":0,\"bitmap_type\":\"Roaring\",\"fst_size\":147,\"inverted_index_size\":515,\"null_bitmap_size\":8,\"relative_fst_offset\":368,\"relative_null_bitmap_offset\":0,\"segment_row_count\":1024,\"total_row_count\":20}}"), node_id: None }"#).await;
test_all_index_metas_list_all_types_with_format(true, r#"
PuffinIndexMetaEntry { table_dir: "test/", index_file_path: "test/11_0000000001/index/<file_id>.puffin", region_id: 47244640257(11, 1), table_id: 11, region_number: 1, region_group: 0, region_sequence: 1, file_id: "<file_id>", index_file_size: Some(6000), index_type: "bloom_filter", target_type: "column", target_key: "1", target_json: "{\"column\":1}", blob_size: 751, meta_json: Some("{\"bloom\":{\"bloom_filter_size\":640,\"row_count\":20,\"rows_per_segment\":2,\"segment_count\":10}}"), node_id: None }
PuffinIndexMetaEntry { table_dir: "test/", index_file_path: "test/11_0000000001/index/<file_id>.puffin", region_id: 47244640257(11, 1), table_id: 11, region_number: 1, region_group: 0, region_sequence: 1, file_id: "<file_id>", index_file_size: Some(6000), index_type: "fulltext_bloom", target_type: "column", target_key: "4", target_json: "{\"column\":4}", blob_size: 89, meta_json: Some("{\"bloom\":{\"bloom_filter_size\":64,\"row_count\":20,\"rows_per_segment\":4,\"segment_count\":5},\"fulltext\":{\"analyzer\":\"English\",\"case_sensitive\":false}}"), node_id: None }
PuffinIndexMetaEntry { table_dir: "test/", index_file_path: "test/11_0000000001/index/<file_id>.puffin", region_id: 47244640257(11, 1), table_id: 11, region_number: 1, region_group: 0, region_sequence: 1, file_id: "<file_id>", index_file_size: Some(6000), index_type: "fulltext_bloom", target_type: "column", target_key: "4", target_json: "{\"column\":4}", blob_size: 89, meta_json: Some("{\"bloom\":{\"bloom_filter_size\":64,\"row_count\":20,\"rows_per_segment\":4,\"segment_count\":5},\"fulltext\":{\"analyzer\":\"English\",\"bloom_version\":2,\"case_sensitive\":false}}"), node_id: None }
PuffinIndexMetaEntry { table_dir: "test/", index_file_path: "test/11_0000000001/index/<file_id>.puffin", region_id: 47244640257(11, 1), table_id: 11, region_number: 1, region_group: 0, region_sequence: 1, file_id: "<file_id>", index_file_size: Some(6000), index_type: "fulltext_tantivy", target_type: "column", target_key: "5", target_json: "{\"column\":5}", blob_size: 1000, meta_json: Some("{\"fulltext\":{\"analyzer\":\"Chinese\",\"case_sensitive\":true}}"), node_id: None }
PuffinIndexMetaEntry { table_dir: "test/", index_file_path: "test/11_0000000001/index/<file_id>.puffin", region_id: 47244640257(11, 1), table_id: 11, region_number: 1, region_group: 0, region_sequence: 1, file_id: "<file_id>", index_file_size: Some(6000), index_type: "inverted", target_type: "column", target_key: "1", target_json: "{\"column\":1}", blob_size: 518, meta_json: Some("{\"inverted\":{\"base_offset\":0,\"bitmap_type\":\"Roaring\",\"fst_size\":150,\"inverted_index_size\":518,\"null_bitmap_size\":8,\"relative_fst_offset\":368,\"relative_null_bitmap_offset\":0,\"segment_row_count\":1024,\"total_row_count\":20}}"), node_id: None }
PuffinIndexMetaEntry { table_dir: "test/", index_file_path: "test/11_0000000001/index/<file_id>.puffin", region_id: 47244640257(11, 1), table_id: 11, region_number: 1, region_group: 0, region_sequence: 1, file_id: "<file_id>", index_file_size: Some(6000), index_type: "inverted", target_type: "column", target_key: "2", target_json: "{\"column\":2}", blob_size: 515, meta_json: Some("{\"inverted\":{\"base_offset\":0,\"bitmap_type\":\"Roaring\",\"fst_size\":147,\"inverted_index_size\":515,\"null_bitmap_size\":8,\"relative_fst_offset\":368,\"relative_null_bitmap_offset\":0,\"segment_row_count\":1024,\"total_row_count\":20}}"), node_id: None }"#).await;
+23 -9
View File
@@ -39,7 +39,8 @@ use crate::cache::index::inverted_index::{CachedInvertedIndexBlobReader, Inverte
use crate::sst::file::RegionIndexId;
use crate::sst::index::bloom_filter::INDEX_BLOB_TYPE as BLOOM_BLOB_TYPE;
use crate::sst::index::fulltext_index::{
INDEX_BLOB_TYPE_BLOOM as FULLTEXT_BLOOM_BLOB_TYPE,
FULLTEXT_BLOOM_VERSION, INDEX_BLOB_TYPE_BLOOM as FULLTEXT_BLOOM_BLOB_TYPE,
INDEX_BLOB_TYPE_BLOOM_V1 as FULLTEXT_BLOOM_V1_BLOB_TYPE,
INDEX_BLOB_TYPE_TANTIVY as FULLTEXT_TANTIVY_BLOB_TYPE,
};
use crate::sst::index::inverted_index::INDEX_BLOB_TYPE as INVERTED_BLOB_TYPE;
@@ -126,7 +127,7 @@ pub(crate) async fn collect_index_entries_from_puffin(
);
entries.push(entry);
}
Some(BlobIndexTypeTargetKey::FulltextBloom(target_key)) => {
Some(BlobIndexTypeTargetKey::FulltextBloom(target_key, bloom_version)) => {
let bloom_meta = try_read_bloom_meta(
&reader,
region_index_id,
@@ -139,7 +140,7 @@ pub(crate) async fn collect_index_entries_from_puffin(
.await;
let bloom_value = bloom_meta.as_deref().map(bloom_meta_value);
let fulltext_value = Some(fulltext_meta_value(blob));
let fulltext_value = Some(fulltext_meta_value(blob, Some(bloom_version)));
let (target_type, target_json) = decode_target_info(target_key);
let meta_json = build_meta_json(bloom_value, fulltext_value, None);
let entry = build_index_entry(
@@ -154,7 +155,7 @@ pub(crate) async fn collect_index_entries_from_puffin(
entries.push(entry);
}
Some(BlobIndexTypeTargetKey::FulltextTantivy(target_key)) => {
let fulltext_value = Some(fulltext_meta_value(blob));
let fulltext_value = Some(fulltext_meta_value(blob, None));
let (target_type, target_json) = decode_target_info(target_key);
let meta_json = build_meta_json(None, fulltext_value, None);
let entry = build_index_entry(
@@ -392,12 +393,16 @@ fn bloom_meta_value(meta: &BloomFilterMeta) -> Value {
})
}
fn fulltext_meta_value(blob: &BlobMetadata) -> Value {
fn fulltext_meta_value(blob: &BlobMetadata, bloom_version: Option<u8>) -> Value {
let config = FulltextConfig::from_blob_metadata(blob).unwrap_or_default();
json!({
let mut value = json!({
"analyzer": config.analyzer.to_str(),
"case_sensitive": config.case_sensitive,
})
});
if let Some(version) = bloom_version {
value["bloom_version"] = json!(version);
}
value
}
fn inverted_meta_value(meta: &InvertedIndexMeta, metas: &InvertedIndexMetas) -> Value {
@@ -441,7 +446,8 @@ fn build_meta_json(
enum BlobIndexTypeTargetKey<'a> {
BloomFilter(&'a str),
FulltextBloom(&'a str),
/// Target key and version of a fulltext bloom blob.
FulltextBloom(&'a str, u8),
FulltextTantivy(&'a str),
Inverted,
}
@@ -451,9 +457,17 @@ impl<'a> BlobIndexTypeTargetKey<'a> {
if let Some(target_key) = Self::target_key_from_blob(blob_type, BLOOM_BLOB_TYPE) {
Some(BlobIndexTypeTargetKey::BloomFilter(target_key))
} else if let Some(target_key) =
// v2 goes first: the v1 type is a prefix of it.
Self::target_key_from_blob(blob_type, FULLTEXT_BLOOM_BLOB_TYPE)
{
Some(BlobIndexTypeTargetKey::FulltextBloom(target_key))
Some(BlobIndexTypeTargetKey::FulltextBloom(
target_key,
FULLTEXT_BLOOM_VERSION,
))
} else if let Some(target_key) =
Self::target_key_from_blob(blob_type, FULLTEXT_BLOOM_V1_BLOB_TYPE)
{
Some(BlobIndexTypeTargetKey::FulltextBloom(target_key, 1))
} else if let Some(target_key) =
Self::target_key_from_blob(blob_type, FULLTEXT_TANTIVY_BLOB_TYPE)
{
+80 -2
View File
@@ -26,6 +26,7 @@ use bytes::Bytes;
use common_base::readable_size::ReadableSize;
use common_telemetry::{debug, error, warn};
use common_time::Timestamp;
use datatypes::schema::FulltextBackend;
use partition::expr::PartitionExpr;
use serde::{Deserialize, Serialize};
use smallvec::SmallVec;
@@ -37,6 +38,7 @@ use crate::access_layer::AccessLayerRef;
use crate::cache::CacheManagerRef;
use crate::cache::file_cache::{FileType, IndexKey};
use crate::sst::file_purger::FilePurgerRef;
use crate::sst::index::fulltext_index::FULLTEXT_BLOOM_VERSION;
use crate::sst::location;
use crate::sst::parquet::SstInfo;
use crate::sst::primary_key::PrimaryKeyRangeMapper;
@@ -397,6 +399,15 @@ pub struct ColumnIndexMetadata {
pub column_id: ColumnId,
/// List of index types that have been successfully created for this column.
pub created_indexes: IndexTypes,
/// Version of the column's fulltext bloom index. `0` means not recorded: the column
/// has no bloom index, or its index is the legacy format, which `ssts_index_meta`
/// reports as version 1.
#[serde(skip_serializing_if = "is_zero")]
pub fulltext_bloom_version: u8,
}
fn is_zero(version: &u8) -> bool {
*version == 0
}
impl FileMeta {
@@ -450,13 +461,14 @@ impl FileMeta {
let id_to_indexes = self
.indexes
.iter()
.map(|index| (index.column_id, index.created_indexes.clone()))
.map(|index| (index.column_id, index))
.collect::<std::collections::HashMap<_, _>>();
for column in metadata {
if !column.column_schema.is_indexed() {
continue;
}
if let Some(indexes) = id_to_indexes.get(&column.column_id) {
if let Some(index) = id_to_indexes.get(&column.column_id) {
let indexes = &index.created_indexes;
if column.column_schema.is_inverted_indexed()
&& !indexes.contains(&IndexType::InvertedIndex)
{
@@ -467,6 +479,26 @@ impl FileMeta {
{
return false;
}
if column
.column_schema
.fulltext_options()
.ok()
.flatten()
.is_some_and(|options| {
options.enable && options.backend == FulltextBackend::Bloom
})
&& index.fulltext_bloom_version < FULLTEXT_BLOOM_VERSION
{
debug!(
"Fulltext bloom index is outdated, region_id: {}, file_id: {}, column_id: {}, version: {}, current: {}",
self.region_id,
self.file_id,
column.column_id,
index.fulltext_bloom_version,
FULLTEXT_BLOOM_VERSION
);
return false;
}
if column.column_schema.is_skipping_indexed()
&& !indexes.contains(&IndexType::BloomFilterIndex)
{
@@ -1038,6 +1070,7 @@ mod tests {
indexes: vec![ColumnIndexMetadata {
column_id: 0,
created_indexes: SmallVec::from_iter([IndexType::InvertedIndex]),
..Default::default()
}],
index_file_size: 0,
index_version: 0,
@@ -1116,6 +1149,7 @@ mod tests {
indexes: vec![ColumnIndexMetadata {
column_id: 0,
created_indexes: SmallVec::from_iter([IndexType::InvertedIndex]),
..Default::default()
}],
index_file_size: 0,
index_version: 0,
@@ -1340,6 +1374,7 @@ mod tests {
indexes: vec![ColumnIndexMetadata {
column_id: 1,
created_indexes: SmallVec::from_iter([IndexType::InvertedIndex]),
..Default::default()
}],
..Default::default()
};
@@ -1353,6 +1388,7 @@ mod tests {
IndexType::InvertedIndex,
IndexType::BloomFilterIndex,
]),
..Default::default()
}];
let region_meta = vec![new_column_meta(1, "tag1", true, false, false)];
assert!(file_meta.is_index_consistent_with_region(&region_meta));
@@ -1361,6 +1397,7 @@ mod tests {
file_meta.indexes = vec![ColumnIndexMetadata {
column_id: 1,
created_indexes: SmallVec::from_iter([IndexType::InvertedIndex]),
..Default::default()
}];
let region_meta = vec![new_column_meta(1, "tag1", true, true, false)]; // Requires fulltext too
assert!(!file_meta.is_index_consistent_with_region(&region_meta));
@@ -1369,6 +1406,7 @@ mod tests {
file_meta.indexes = vec![ColumnIndexMetadata {
column_id: 2, // File only has index for column 2
created_indexes: SmallVec::from_iter([IndexType::InvertedIndex]),
..Default::default()
}];
let region_meta = vec![new_column_meta(1, "tag1", true, false, false)]; // Requires index on column 1
assert!(!file_meta.is_index_consistent_with_region(&region_meta));
@@ -1377,6 +1415,7 @@ mod tests {
file_meta.indexes = vec![ColumnIndexMetadata {
column_id: 1,
created_indexes: SmallVec::from_iter([IndexType::InvertedIndex]),
..Default::default()
}];
let region_meta = vec![new_column_meta(1, "tag1", false, false, false)]; // No index required
assert!(file_meta.is_index_consistent_with_region(&region_meta));
@@ -1391,10 +1430,12 @@ mod tests {
ColumnIndexMetadata {
column_id: 1,
created_indexes: SmallVec::from_iter([IndexType::InvertedIndex]),
..Default::default()
},
ColumnIndexMetadata {
column_id: 2, // Column 2 is missing the required BloomFilterIndex
created_indexes: SmallVec::from_iter([IndexType::FulltextIndex]),
..Default::default()
},
];
let region_meta = vec![
@@ -1403,4 +1444,41 @@ mod tests {
];
assert!(!file_meta.is_index_consistent_with_region(&region_meta));
}
#[test]
fn test_index_consistency_requires_current_fulltext_bloom_version() {
let fulltext_column = |backend| {
let column_schema = ColumnSchema::new("msg", ConcreteDataType::string_datatype(), true)
.with_fulltext_options(FulltextOptions::new_unchecked(
true,
FulltextAnalyzer::English,
false,
backend,
1000,
0.01,
))
.unwrap();
vec![ColumnMetadata {
column_schema,
semantic_type: api::v1::SemanticType::Field,
column_id: 1,
}]
};
let file_meta = |fulltext_bloom_version| FileMeta {
indexes: vec![ColumnIndexMetadata {
column_id: 1,
created_indexes: SmallVec::from_iter([IndexType::FulltextIndex]),
fulltext_bloom_version,
}],
..Default::default()
};
let bloom = fulltext_column(FulltextBackend::Bloom);
// Written before the version was recorded, i.e. with the old bloom blob.
assert!(!file_meta(0).is_index_consistent_with_region(&bloom));
assert!(file_meta(FULLTEXT_BLOOM_VERSION).is_index_consistent_with_region(&bloom));
// Tantivy indexes carry no bloom version.
let tantivy = fulltext_column(FulltextBackend::Tantivy);
assert!(file_meta(0).is_index_consistent_with_region(&tantivy));
}
}
+1
View File
@@ -434,6 +434,7 @@ mod tests {
indexes: vec![ColumnIndexMetadata {
column_id: 0,
created_indexes: SmallVec::from_iter([IndexType::InvertedIndex]),
..Default::default()
}],
index_file_size: 4096,
index_version: 0,
+1
View File
@@ -273,6 +273,7 @@ mod tests {
indexes: vec![ColumnIndexMetadata {
column_id: 0,
created_indexes: SmallVec::from_iter([IndexType::InvertedIndex]),
..Default::default()
}],
index_file_size: 4096,
index_version: 0,
+10
View File
@@ -74,6 +74,7 @@ use crate::sst::file::{
ColumnIndexMetadata, FileHandle, IndexType, IndexTypes, RegionFileId, RegionIndexId,
};
use crate::sst::file_purger::FilePurgerRef;
use crate::sst::index::fulltext_index::FULLTEXT_BLOOM_VERSION;
use crate::sst::index::fulltext_index::creator::FulltextIndexer;
use crate::sst::index::intermediate::IntermediateManager;
use crate::sst::index::inverted_index::creator::InvertedIndexer;
@@ -160,6 +161,8 @@ pub struct IndexOutput {
pub inverted_index: InvertedIndexOutput,
/// Fulltext index output.
pub fulltext_index: FulltextIndexOutput,
/// Columns of `fulltext_index` that use the bloom backend.
pub fulltext_bloom_columns: Vec<ColumnId>,
/// Bloom filter output.
pub bloom_filter: BloomFilterOutput,
}
@@ -203,6 +206,13 @@ impl IndexOutput {
map.into_iter()
.map(|(column_id, created_indexes)| ColumnIndexMetadata {
column_id,
fulltext_bloom_version: if self.fulltext_index.is_available()
&& self.fulltext_bloom_columns.contains(&column_id)
{
FULLTEXT_BLOOM_VERSION
} else {
0
},
created_indexes,
})
.collect::<Vec<_>>()
+10 -1
View File
@@ -16,4 +16,13 @@ pub(crate) mod applier;
pub(crate) mod creator;
pub(crate) const INDEX_BLOB_TYPE_TANTIVY: &str = "greptime-fulltext-index-v1";
pub(crate) const INDEX_BLOB_TYPE_BLOOM: &str = "greptime-fulltext-index-bloom";
/// Bloom blobs tokenized by `ScriptTokenizer`. A new key instead of a config field so that
/// older versions, which would probe these tokens with their own rules and drop rows,
/// don't find the blob and scan without pruning.
pub(crate) const INDEX_BLOB_TYPE_BLOOM: &str = "greptime-fulltext-index-bloom-v2";
/// Version of the bloom blobs stored under [`INDEX_BLOB_TYPE_BLOOM`], recorded in the
/// manifest so that `ADMIN BUILD_INDEX` can find SSTs with older blobs.
pub(crate) const FULLTEXT_BLOOM_VERSION: u8 = 2;
/// Bloom blobs from older versions. Their tokens don't follow `matches_term` boundaries,
/// so queries never probe them; they are kept only for index metadata listing.
pub(crate) const INDEX_BLOB_TYPE_BLOOM_V1: &str = "greptime-fulltext-index-bloom";
+280 -54
View File
@@ -19,12 +19,12 @@ use std::sync::Arc;
use std::time::Instant;
use common_base::range_read::RangeReader;
use common_base::term_token::{extract_like_probes, extract_term_probes};
use common_telemetry::{tracing, warn};
use index::bloom_filter::applier::{BloomFilterApplier, InListPredicate};
use index::bloom_filter::reader::{BloomFilterReadMetrics, BloomFilterReaderImpl};
use index::fulltext_index::Config;
use index::fulltext_index::search::{FulltextIndexSearcher, RowId, TantivyFulltextIndexSearcher};
use index::fulltext_index::tokenizer::{ChineseTokenizer, EnglishTokenizer, Tokenizer};
use index::fulltext_index::{Analyzer, Config};
use index::target::IndexTarget;
use object_store::ObjectStore;
use puffin::puffin_manager::cache::PuffinMetadataCacheRef;
@@ -45,7 +45,7 @@ use crate::error::{
};
use crate::metrics::INDEX_APPLY_ELAPSED;
use crate::sst::file::RegionIndexId;
use crate::sst::index::fulltext_index::applier::builder::{FulltextRequest, FulltextTerm};
use crate::sst::index::fulltext_index::applier::builder::FulltextRequest;
use crate::sst::index::fulltext_index::{INDEX_BLOB_TYPE_BLOOM, INDEX_BLOB_TYPE_TANTIVY};
use crate::sst::index::puffin_manager::{
PuffinManagerFactory, SstPuffinBlob, SstPuffinDir, SstPuffinReader,
@@ -233,7 +233,10 @@ impl FulltextIndexApplier {
let mut row_ids: Option<BTreeSet<RowId>> = None;
for (column_id, request) in self.requests.iter() {
if request.queries.is_empty() && request.terms.is_empty() {
// Tantivy tokens don't follow `matches_term` boundaries, e.g. `SimpleTokenizer`
// keeps "错误error日志" as one token, so terms and patterns are left to the
// bloom backend.
if request.queries.is_empty() {
continue;
}
@@ -307,7 +310,6 @@ impl FulltextIndexApplier {
TantivyFulltextIndexSearcher::new(path, config).context(ApplyFulltextIndexSnafu)?;
let mut row_ids: Option<BTreeSet<RowId>> = None;
// 1. Apply queries
for query in &request.queries {
let result = searcher
.search(&query.0)
@@ -327,21 +329,6 @@ impl FulltextIndexApplier {
}
}
// 2. Apply terms
let query = request.terms_as_query(config.case_sensitive);
if !query.0.is_empty() {
let result = searcher
.search(&query.0)
.await
.context(ApplyFulltextIndexSnafu)?;
if let Some(ids) = row_ids.as_mut() {
ids.retain(|id| result.contains(id));
} else {
row_ids = Some(result);
}
}
Ok(row_ids)
}
}
@@ -376,8 +363,7 @@ impl FulltextIndexApplier {
let mut applied = false;
for (column_id, request) in self.requests.iter() {
if request.terms.is_empty() {
// only apply terms
if request.terms.is_empty() && request.likes.is_empty() {
continue;
}
@@ -386,7 +372,7 @@ impl FulltextIndexApplier {
file_id,
file_size_hint,
*column_id,
&request.terms,
request,
&mut output,
metrics.as_deref_mut(),
)
@@ -417,7 +403,7 @@ impl FulltextIndexApplier {
file_id: RegionIndexId,
file_size_hint: Option<u64>,
column_id: ColumnId,
terms: &[FulltextTerm],
request: &FulltextRequest,
output: &mut [(usize, Vec<Range<usize>>)],
mut metrics: Option<&mut FulltextIndexApplyMetrics>,
) -> Result<bool> {
@@ -435,7 +421,7 @@ impl FulltextIndexApplier {
let config =
Config::from_blob_metadata(reader.metadata()).context(ApplyFulltextIndexSnafu)?;
let predicates = Self::terms_to_predicates(terms, &config);
let predicates = Self::request_to_predicates(request, &config);
if predicates.is_empty() {
return Ok(false);
}
@@ -524,43 +510,39 @@ impl FulltextIndexApplier {
}
}
/// Converts terms to predicates.
///
/// Split terms by non-alphanumeric characters and convert them to lowercase if case-insensitive.
/// Multiple terms are combined with AND semantics.
fn terms_to_predicates(terms: &[FulltextTerm], config: &Config) -> Vec<InListPredicate> {
let mut probes = HashSet::new();
for term in terms {
if config.case_sensitive && term.col_lowered {
// lowercased terms are not indexed
continue;
/// Converts terms and `LIKE` patterns to predicates, one per token that a matching
/// row must contain. Multiple predicates are combined with AND semantics.
fn request_to_predicates(request: &FulltextRequest, config: &Config) -> Vec<InListPredicate> {
// Probes of a lowercased column come from lowercased text and are indexed as is;
// a case-sensitive index has no tokens of the lowercased text.
let probes = |col_lowered: bool, probes: Vec<String>| -> Vec<Vec<u8>> {
match (col_lowered, config.case_sensitive) {
(true, true) => vec![],
(false, false) => probes
.iter()
.map(|p| p.to_lowercase().into_bytes())
.collect(),
_ => probes.into_iter().map(String::into_bytes).collect(),
}
probes.extend(Self::term_to_probes(&term.term, config));
}
};
let term_probes = request
.terms
.iter()
.flat_map(|term| probes(term.col_lowered, extract_term_probes(&term.term)));
let like_probes = request
.likes
.iter()
.flat_map(|like| probes(like.col_lowered, extract_like_probes(&like.pattern)));
probes
term_probes
.chain(like_probes)
.collect::<HashSet<_>>()
.into_iter()
.map(|p| InListPredicate {
list: iter::once(p).collect(),
})
.collect::<Vec<_>>()
}
fn term_to_probes<'a>(term: &'a str, config: &'a Config) -> impl Iterator<Item = Vec<u8>> + 'a {
let tokens = match config.analyzer {
Analyzer::English => EnglishTokenizer {}.tokenize(term),
Analyzer::Chinese => ChineseTokenizer {}.tokenize(term),
};
tokens.into_iter().map(|t| {
if !config.case_sensitive {
t.to_lowercase()
} else {
t.to_string()
}
.into_bytes()
})
}
}
/// The source of the index.
@@ -774,3 +756,247 @@ impl IndexSource {
Ok(reader)
}
}
#[cfg(test)]
mod tests {
use common_function::scalars::matches_term::MatchesTermFinder;
use datatypes::arrow::array::{Scalar, StringArray};
use datatypes::arrow::compute::kernels::comparison::like;
use index::fulltext_index::Analyzer;
use index::fulltext_index::tokenizer::{self, ScriptTokenizer};
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
use super::*;
use crate::sst::index::fulltext_index::applier::builder::{FulltextLike, FulltextTerm};
/// A `matches_term` or `LIKE` predicate on `col`, or on `lower(col)` when `lowered`.
enum Predicate<'a> {
Term(&'a str),
Like(&'a str),
LowerTerm(&'a str),
LowerLike(&'a str),
}
impl Predicate<'_> {
fn eval(&self, text: &str) -> bool {
let like_matches = |text: &str, pattern: &str| {
like(
&StringArray::from(vec![text]),
&Scalar::new(StringArray::from(vec![pattern])),
)
.unwrap()
.value(0)
};
match self {
Predicate::Term(term) => MatchesTermFinder::new(term).find(text),
Predicate::Like(pattern) => like_matches(text, pattern),
Predicate::LowerTerm(term) => {
MatchesTermFinder::new(term).find(&text.to_lowercase())
}
Predicate::LowerLike(pattern) => like_matches(&text.to_lowercase(), pattern),
}
}
fn request(&self) -> FulltextRequest {
let mut request = FulltextRequest::default();
let (term, like, col_lowered) = match *self {
Predicate::Term(term) => (Some(term), None, false),
Predicate::Like(pattern) => (None, Some(pattern), false),
Predicate::LowerTerm(term) => (Some(term), None, true),
Predicate::LowerLike(pattern) => (None, Some(pattern), true),
};
request.terms.extend(term.map(|term| FulltextTerm {
col_lowered,
term: term.to_string(),
}));
request.likes.extend(like.map(|pattern| FulltextLike {
col_lowered,
pattern: pattern.to_string(),
}));
request
}
/// Asserts that every probe is among `text`'s indexed tokens, i.e. the bloom
/// filter keeps the row, and returns the number of probes.
fn assert_probes_indexed(&self, text: &str) -> usize {
let mut num_probes = 0;
for case_sensitive in [true, false] {
let config = Config {
analyzer: Analyzer::English,
case_sensitive,
};
let tokens = tokenizer::Analyzer::new(Box::new(ScriptTokenizer), case_sensitive)
.analyze_text(text)
.unwrap()
.into_iter()
.collect::<HashSet<_>>();
let predicates =
FulltextIndexApplier::request_to_predicates(&self.request(), &config);
for predicate in &predicates {
assert!(
predicate.list.iter().any(|probe| tokens.contains(probe)),
"probe {:?} not indexed, text: {text:?}, case_sensitive: {case_sensitive}",
predicate
.list
.iter()
.map(|p| String::from_utf8_lossy(p))
.collect::<Vec<_>>(),
);
}
num_probes = predicates.len();
}
num_probes
}
}
#[test]
fn test_probes_keep_matching_rows() {
// (text, predicate, whether the predicate yields probes)
let cases = [
("hello_world", Predicate::Term("world"), true),
("trace_id=abc", Predicate::Term("id"), true),
("错误error日志", Predicate::Term("error"), true),
(
"登录手机号18888888888的动态key",
Predicate::Term("手机号"),
true,
),
(
"登录手机号18888888888的动态key",
Predicate::Term("机号"),
true,
),
(
"登录手机号18888888888的动态key",
Predicate::Term("18888888888"),
true,
),
// The ASCII edge of a Han term may continue in the text.
(
"登录手机号18888888888的动态key",
Predicate::Term("机号1888"),
true,
),
("中国农业银行", Predicate::Term("农业"), true),
("中国农业银行", Predicate::Term("农"), false),
("连接timeout.5次", Predicate::Term("timeout"), true),
("用户user-123登录", Predicate::Term("user"), true),
(
"CRITICAL error: disk",
Predicate::Term("CRITICAL error"),
true,
),
("café>", Predicate::Term("café"), true),
(
"connection timeout after 5s",
Predicate::Like("%timeout%"),
false,
),
(
"connection timeout after 5s",
Predicate::Like("% timeout %"),
true,
),
(
"connection timeout after 5s",
Predicate::Like("connection%"),
false,
),
(
"connection timeout after 5s",
Predicate::Like("connection %"),
true,
),
(
"connection timeout after 5s",
Predicate::Like("%after 5s"),
true,
),
("trace_id=abc", Predicate::Like(r"trace\_id=%"), true),
("trace_id=abc", Predicate::Like("trace_id=%"), false),
("100% done", Predicate::Like(r"100\% done"), true),
("错误error日志", Predicate::Like("%错误error日%"), true),
("错误error日志", Predicate::Like("%r日志"), true),
];
let lowered_cases = [
(
"Connection TIMEOUT after 5s",
Predicate::LowerTerm("timeout"),
true,
),
(
"Connection TIMEOUT after 5s",
Predicate::LowerLike("% timeout %"),
true,
),
// Lowercasing changes the class of U+212A KELVIN SIGN, splits U+0130 into
// two characters, and lowercases `Σ` depending on what follows it.
("a\u{212A}b", Predicate::LowerTerm("akb"), true),
("\u{130}stanbul", Predicate::LowerTerm("stanbul"), true),
("ΟΔΟΣ.x", Predicate::LowerTerm("οδοσ"), true),
];
for (text, predicate, has_probes) in cases.into_iter().chain(lowered_cases) {
assert!(predicate.eval(text), "case doesn't match: {text:?}");
assert_eq!(
predicate.assert_probes_indexed(text) > 0,
has_probes,
"text: {text:?}"
);
}
}
#[test]
fn test_probes_keep_matching_rows_random() {
// Word characters of every class, separators, LIKE metacharacters, and
// characters whose lowercase changes length or class.
const ALPHABET: &[char] = &[
'a', 'b', 'A', '1', '_', '-', ' ', '.', '%', '\\', '错', '误', 'é', 'Д', '\u{212A}',
'\u{130}', 'Σ',
];
let mut rng = StdRng::seed_from_u64(42);
let random_char = |rng: &mut StdRng| ALPHABET[rng.random_range(0..ALPHABET.len())];
for _ in 0..20000 {
let text = (0..rng.random_range(0..12))
.map(|_| random_char(&mut rng))
.collect::<String>();
let chars = text.chars().collect::<Vec<_>>();
let start = rng.random_range(0..=chars.len());
let end = rng.random_range(start..=chars.len());
let term = chars[start..end].iter().collect::<String>();
let lowered = term.to_lowercase();
for predicate in [Predicate::Term(&term), Predicate::LowerTerm(&lowered)] {
if predicate.eval(&text) {
predicate.assert_probes_indexed(&text);
}
}
let mut pattern = String::new();
if start > 0 && rng.random_bool(0.5) {
pattern.push('%');
}
for &c in &chars[start..end] {
match rng.random_range(0..10) {
0 => pattern.push('%'),
1 => pattern.push('_'),
_ if matches!(c, '%' | '_' | '\\') => {
pattern.push('\\');
pattern.push(c);
}
_ => pattern.push(c),
}
}
if end < chars.len() && rng.random_bool(0.5) {
pattern.push('%');
}
let lowered = pattern.to_lowercase();
for predicate in [Predicate::Like(&pattern), Predicate::LowerLike(&lowered)] {
if predicate.eval(&text) {
predicate.assert_probes_indexed(&text);
}
}
}
}
}
@@ -14,9 +14,11 @@
use std::collections::BTreeMap;
use common_base::term_token::extract_like_probes;
use datafusion_common::ScalarValue;
use datafusion_expr::expr::ScalarFunction;
use datafusion_expr::expr::{Like, ScalarFunction};
use datafusion_expr::{BinaryExpr, Expr, Operator};
use datatypes::schema::FulltextBackend;
use object_store::ObjectStore;
use puffin::puffin_manager::cache::PuffinMetadataCacheRef;
use store_api::metadata::RegionMetadata;
@@ -36,31 +38,7 @@ use crate::sst::index::puffin_manager::PuffinManagerFactory;
pub struct FulltextRequest {
pub queries: Vec<FulltextQuery>,
pub terms: Vec<FulltextTerm>,
}
impl FulltextRequest {
/// Convert terms to a query string.
///
/// For example, if the terms are ["foo", "bar"], the query string will be `r#"+"foo" +"bar""#`.
/// Need to escape the `"` in the term.
///
/// `skip_lowercased` is used for the situation that lowercased terms are not indexed.
pub fn terms_as_query(&self, skip_lowercased: bool) -> FulltextQuery {
let mut query = String::new();
for term in &self.terms {
if skip_lowercased && term.col_lowered {
continue;
}
// Escape the `"` in the term.
let escaped_term = term.term.replace("\"", "\\\"");
if query.is_empty() {
query = format!("+\"{escaped_term}\"");
} else {
query.push_str(&format!(" +\"{escaped_term}\""));
}
}
FulltextQuery(query)
}
pub likes: Vec<FulltextLike>,
}
/// A query to be matched in fulltext index.
@@ -79,6 +57,16 @@ pub struct FulltextTerm {
pub term: String,
}
/// A case-sensitive `LIKE` pattern to be matched in fulltext index.
///
/// `pattern` is e.g. "%foo bar%" in `SELECT * FROM t WHERE text LIKE '%foo bar%'`.
/// `col_lowered` indicates whether the column is lowercased, e.g. `lower(text) LIKE '%foo bar%'`.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct FulltextLike {
pub col_lowered: bool,
pub pattern: String,
}
/// `FulltextIndexApplierBuilder` is a builder for `FulltextIndexApplier`.
pub struct FulltextIndexApplierBuilder<'a> {
table_dir: String,
@@ -144,9 +132,9 @@ impl<'a> FulltextIndexApplierBuilder<'a> {
}
// Check if any requests have queries or terms
let has_requests = requests
.iter()
.any(|(_, request)| !request.queries.is_empty() || !request.terms.is_empty());
let has_requests = requests.iter().any(|(_, request)| {
!request.queries.is_empty() || !request.terms.is_empty() || !request.likes.is_empty()
});
Ok(has_requests.then(|| {
FulltextIndexApplier::new(
@@ -176,6 +164,11 @@ impl<'a> FulltextIndexApplierBuilder<'a> {
Self::extract_requests(left, metadata, requests);
Self::extract_requests(right, metadata, requests);
}
Expr::Like(like) => {
if let Some((column_id, like)) = Self::expr_to_like(metadata, like) {
requests.entry(column_id).or_default().likes.push(like);
}
}
Expr::ScalarFunction(func) => {
if let Some((column_id, query)) = Self::expr_to_query(metadata, func) {
requests.entry(column_id).or_default().queries.push(query);
@@ -261,6 +254,58 @@ impl<'a> FulltextIndexApplierBuilder<'a> {
))
}
fn expr_to_like(metadata: &RegionMetadata, like: &Like) -> Option<(ColumnId, FulltextLike)> {
// `ILIKE` uses Unicode case folding, which disagrees with the index's
// `to_lowercase` on characters like 'ſ', so its probes could miss rows.
if like.negated || like.case_insensitive {
return None;
}
// Probes assume arrow's default `\` escape.
if !matches!(like.escape_char, None | Some('\\')) {
return None;
}
let (c, col_lowered) = match like.expr.as_ref() {
Expr::Column(c) => (c, false),
Expr::ScalarFunction(f) => match Self::extract_lower_arg(f)? {
Expr::Column(c) => (c, true),
_ => return None,
},
_ => return None,
};
let column = metadata.column_by_name(&c.name)?;
if column.column_schema.data_type != ConcreteDataType::string_datatype() {
return None;
}
// `LIKE` is common on unindexed columns and with patterns like '%word%' that have
// no probe; skip them instead of opening every SST's index for nothing.
let options = column.column_schema.fulltext_options().ok()??;
if !options.enable || options.backend != FulltextBackend::Bloom {
return None;
}
let Expr::Literal(
ScalarValue::Utf8(Some(pattern))
| ScalarValue::LargeUtf8(Some(pattern))
| ScalarValue::Utf8View(Some(pattern)),
_,
) = like.pattern.as_ref()
else {
return None;
};
if extract_like_probes(pattern).is_empty() {
return None;
}
Some((
column.column_id,
FulltextLike {
col_lowered,
pattern: pattern.clone(),
},
))
}
fn extract_lower_arg(lower_func: &ScalarFunction) -> Option<&Expr> {
if lower_func.args.len() != 1 {
return None;
@@ -289,9 +334,9 @@ mod tests {
use common_function::scalars::matches_term::MatchesTermFunction;
use datafusion::functions::string::lower;
use datafusion_common::Column;
use datafusion_expr::expr::ScalarFunction;
use datafusion_expr::expr::{Like, ScalarFunction};
use datafusion_expr::{Literal, ScalarUDF};
use datatypes::schema::ColumnSchema;
use datatypes::schema::{ColumnSchema, FulltextAnalyzer, FulltextOptions};
use store_api::metadata::{ColumnMetadata, RegionMetadataBuilder};
use store_api::storage::RegionId;
@@ -512,6 +557,107 @@ mod tests {
assert_eq!(request.queries[0], FulltextQuery("foo".to_string()));
}
fn mock_metadata_with_fulltext(backend: FulltextBackend) -> RegionMetadata {
let mut builder = RegionMetadataBuilder::new(RegionId::new(1, 2));
builder
.push_column_metadata(ColumnMetadata {
column_schema: ColumnSchema::new("text", ConcreteDataType::string_datatype(), true)
.with_fulltext_options(FulltextOptions::new_unchecked(
true,
FulltextAnalyzer::English,
false,
backend,
10240,
0.01,
))
.unwrap(),
semantic_type: SemanticType::Field,
column_id: 1,
})
.push_column_metadata(ColumnMetadata {
column_schema: ColumnSchema::new(
"ts",
ConcreteDataType::timestamp_millisecond_datatype(),
false,
),
semantic_type: SemanticType::Timestamp,
column_id: 2,
});
builder.build().unwrap()
}
#[test]
fn test_expr_to_like() {
let like = |negated, case_insensitive, escape_char, pattern: &str| Like {
negated,
expr: Box::new(Expr::Column(Column::from_name("text"))),
pattern: Box::new(pattern.lit()),
escape_char,
case_insensitive,
};
let bloom = mock_metadata_with_fulltext(FulltextBackend::Bloom);
for escape_char in [None, Some('\\')] {
assert_eq!(
FulltextIndexApplierBuilder::expr_to_like(
&bloom,
&like(false, false, escape_char, "%foo bar baz%")
),
Some((
1,
FulltextLike {
col_lowered: false,
pattern: "%foo bar baz%".to_string(),
}
))
);
}
let lowered = Like {
expr: Box::new(Expr::ScalarFunction(ScalarFunction {
args: vec![Expr::Column(Column::from_name("text"))],
func: lower(),
})),
..like(false, false, None, "%foo bar baz%")
};
assert_eq!(
FulltextIndexApplierBuilder::expr_to_like(&bloom, &lowered),
Some((
1,
FulltextLike {
col_lowered: true,
pattern: "%foo bar baz%".to_string(),
}
))
);
// NOT LIKE, ILIKE and other escape characters can't use the probes, and
// '%foo%' has none.
for like in [
like(true, false, None, "%foo bar baz%"),
like(false, true, None, "%foo bar baz%"),
like(false, false, Some('!'), "%foo bar baz%"),
like(false, false, None, "%foo%"),
] {
assert_eq!(
FulltextIndexApplierBuilder::expr_to_like(&bloom, &like),
None
);
}
// Only columns with a bloom fulltext index.
for metadata in [
mock_metadata(),
mock_metadata_with_fulltext(FulltextBackend::Tantivy),
] {
assert_eq!(
FulltextIndexApplierBuilder::expr_to_like(
&metadata,
&like(false, false, None, "%foo bar baz%")
),
None
);
}
}
#[test]
fn test_extract_multiple_requests() {
let metadata = mock_metadata();
@@ -551,92 +697,4 @@ mod tests {
}
);
}
#[test]
fn test_terms_as_query() {
// Test with empty terms
let request = FulltextRequest::default();
assert_eq!(request.terms_as_query(false), FulltextQuery(String::new()));
assert_eq!(request.terms_as_query(true), FulltextQuery(String::new()));
// Test with a single term (not lowercased)
let mut request = FulltextRequest::default();
request.terms.push(FulltextTerm {
col_lowered: false,
term: "foo".to_string(),
});
assert_eq!(
request.terms_as_query(false),
FulltextQuery("+\"foo\"".to_string())
);
assert_eq!(
request.terms_as_query(true),
FulltextQuery("+\"foo\"".to_string())
);
// Test with a single lowercased term and skip_lowercased=true
let mut request = FulltextRequest::default();
request.terms.push(FulltextTerm {
col_lowered: true,
term: "foo".to_string(),
});
assert_eq!(
request.terms_as_query(false),
FulltextQuery("+\"foo\"".to_string())
);
assert_eq!(request.terms_as_query(true), FulltextQuery(String::new())); // Should skip lowercased term
// Test with multiple terms, mix of lowercased and not
let mut request = FulltextRequest::default();
request.terms.push(FulltextTerm {
col_lowered: false,
term: "foo".to_string(),
});
request.terms.push(FulltextTerm {
col_lowered: true,
term: "bar".to_string(),
});
assert_eq!(
request.terms_as_query(false),
FulltextQuery("+\"foo\" +\"bar\"".to_string())
);
assert_eq!(
request.terms_as_query(true),
FulltextQuery("+\"foo\"".to_string()) // Only the non-lowercased term
);
// Test with term containing quotes that need escaping
let mut request = FulltextRequest::default();
request.terms.push(FulltextTerm {
col_lowered: false,
term: "foo\"bar".to_string(),
});
assert_eq!(
request.terms_as_query(false),
FulltextQuery("+\"foo\\\"bar\"".to_string())
);
// Test with a complex mix of terms
let mut request = FulltextRequest::default();
request.terms.push(FulltextTerm {
col_lowered: false,
term: "foo".to_string(),
});
request.terms.push(FulltextTerm {
col_lowered: true,
term: "bar\"quoted\"".to_string(),
});
request.terms.push(FulltextTerm {
col_lowered: false,
term: "baz\\escape".to_string(),
});
assert_eq!(
request.terms_as_query(false),
FulltextQuery("+\"foo\" +\"bar\\\"quoted\\\"\" +\"baz\\escape\"".to_string())
);
assert_eq!(
request.terms_as_query(true),
FulltextQuery("+\"foo\" +\"baz\\escape\"".to_string()) // Skips the lowercased term
);
}
}
+14 -220
View File
@@ -229,6 +229,14 @@ impl FulltextIndexer {
pub fn column_ids(&self) -> impl Iterator<Item = ColumnId> + '_ {
self.creators.keys().copied()
}
/// Returns IDs of columns indexed with the bloom backend.
pub fn bloom_column_ids(&self) -> impl Iterator<Item = ColumnId> + '_ {
self.creators
.values()
.filter(|c| matches!(c.inner, AltFulltextCreator::Bloom(_)))
.map(|c| c.column_id)
}
}
impl FulltextIndexer {
@@ -778,28 +786,8 @@ mod tests {
let row_ids = applier_factory(vec![(1, "World")], vec![], None).await;
assert_eq!(row_ids, Some(rows([3])));
// Terms are left to the bloom backend.
let row_ids = applier_factory(vec![], vec![(1, vec![(false, "hello")])], None).await;
assert_eq!(row_ids, Some(rows([0])));
let row_ids = applier_factory(vec![], vec![(1, vec![(true, "hello")])], None).await;
assert_eq!(row_ids, None);
let row_ids = applier_factory(vec![], vec![(1, vec![(false, "world")])], None).await;
assert_eq!(row_ids, Some(rows([1])));
let row_ids = applier_factory(vec![], vec![(1, vec![(true, "world")])], None).await;
assert_eq!(row_ids, None);
let row_ids = applier_factory(vec![], vec![(1, vec![(false, "Hello")])], None).await;
assert_eq!(row_ids, Some(rows([3])));
let row_ids = applier_factory(vec![], vec![(1, vec![(true, "Hello")])], None).await;
assert_eq!(row_ids, None);
let row_ids = applier_factory(vec![], vec![(1, vec![(false, "Hello, World")])], None).await;
assert_eq!(row_ids, Some(rows([3])));
let row_ids = applier_factory(vec![], vec![(1, vec![(true, "Hello, World")])], None).await;
assert_eq!(row_ids, None);
}
@@ -939,30 +927,6 @@ mod tests {
let row_ids = applier_factory(vec![(2, "World")], vec![], None).await;
assert_eq!(row_ids, Some(rows([2, 3])));
let row_ids = applier_factory(vec![], vec![(2, vec![(false, "hello")])], None).await;
assert_eq!(row_ids, Some(rows([0, 3])));
let row_ids = applier_factory(vec![], vec![(2, vec![(true, "hello")])], None).await;
assert_eq!(row_ids, Some(rows([0, 3])));
let row_ids = applier_factory(vec![], vec![(2, vec![(false, "world")])], None).await;
assert_eq!(row_ids, Some(rows([2, 3])));
let row_ids = applier_factory(vec![], vec![(2, vec![(true, "world")])], None).await;
assert_eq!(row_ids, Some(rows([2, 3])));
let row_ids = applier_factory(vec![], vec![(2, vec![(false, "Hello")])], None).await;
assert_eq!(row_ids, Some(rows([0, 3])));
let row_ids = applier_factory(vec![], vec![(2, vec![(true, "Hello")])], None).await;
assert_eq!(row_ids, Some(rows([0, 3])));
let row_ids = applier_factory(vec![], vec![(2, vec![(false, "World")])], None).await;
assert_eq!(row_ids, Some(rows([2, 3])));
let row_ids = applier_factory(vec![], vec![(2, vec![(true, "World")])], None).await;
assert_eq!(row_ids, Some(rows([2, 3])));
}
#[tokio::test]
@@ -995,22 +959,6 @@ mod tests {
.await;
assert_eq!(row_ids, Some(rows([3])));
let row_ids = applier_factory(
vec![],
vec![(2, vec![(true, "hello")])],
Some(BitVec::from_slice(&[0b1111])),
)
.await;
assert_eq!(row_ids, Some(rows([0, 3])));
let row_ids = applier_factory(
vec![],
vec![(2, vec![(true, "hello")])],
Some(BitVec::from_slice(&[0b1110])), // row 0 is filtered out
)
.await;
assert_eq!(row_ids, Some(rows([3])));
let row_ids = applier_factory(
vec![],
vec![(2, vec![(false, "world")])],
@@ -1027,22 +975,6 @@ mod tests {
.await;
assert_eq!(row_ids, Some(rows([3])));
let row_ids = applier_factory(
vec![],
vec![(2, vec![(true, "world")])],
Some(BitVec::from_slice(&[0b1111])),
)
.await;
assert_eq!(row_ids, Some(rows([2, 3])));
let row_ids = applier_factory(
vec![],
vec![(2, vec![(true, "world")])],
Some(BitVec::from_slice(&[0b1011])), // row 2 is filtered out
)
.await;
assert_eq!(row_ids, Some(rows([3])));
let row_ids = applier_factory(
vec![],
vec![(2, vec![(false, "Hello")])],
@@ -1059,22 +991,6 @@ mod tests {
.await;
assert_eq!(row_ids, Some(rows([0])));
let row_ids = applier_factory(
vec![],
vec![(2, vec![(true, "Hello")])],
Some(BitVec::from_slice(&[0b1111])),
)
.await;
assert_eq!(row_ids, Some(rows([0, 3])));
let row_ids = applier_factory(
vec![],
vec![(2, vec![(true, "Hello")])],
Some(BitVec::from_slice(&[0b1110])), // row 0 is filtered out
)
.await;
assert_eq!(row_ids, Some(rows([3])));
let row_ids = applier_factory(
vec![],
vec![(2, vec![(false, "World")])],
@@ -1093,19 +1009,11 @@ mod tests {
let row_ids = applier_factory(
vec![],
vec![(2, vec![(true, "World")])],
vec![(2, vec![(true, "hello")])],
Some(BitVec::from_slice(&[0b1111])),
)
.await;
assert_eq!(row_ids, Some(rows([2, 3])));
let row_ids = applier_factory(
vec![],
vec![(2, vec![(true, "World")])],
Some(BitVec::from_slice(&[0b1011])), // row 2 is filtered out
)
.await;
assert_eq!(row_ids, Some(rows([3])));
assert_eq!(row_ids, Some(rows([0, 3])));
}
#[tokio::test]
@@ -1127,12 +1035,6 @@ mod tests {
let row_ids = applier_factory(vec![(3, "世界")], vec![], None).await;
assert_eq!(row_ids, Some(rows([2, 3])));
let row_ids = applier_factory(vec![], vec![(3, vec![(false, "你好")])], None).await;
assert_eq!(row_ids, Some(rows([0, 3])));
let row_ids = applier_factory(vec![], vec![(3, vec![(false, "世界")])], None).await;
assert_eq!(row_ids, Some(rows([2, 3])));
}
#[tokio::test]
@@ -1182,61 +1084,6 @@ mod tests {
assert_eq!(row_ids, Some(rows([3])));
}
#[tokio::test]
async fn test_fulltext_index_multi_terms_case_sensitive_tantivy() {
let applier_factory = build_fulltext_applier_factory(
"test_fulltext_index_multi_terms_case_sensitive_tantivy_",
FulltextBackend::Tantivy,
&[
(Some("Hello"), None, None),
(Some("World"), None, None),
(None, None, None),
(Some("Hello, World"), None, None),
],
)
.await;
let row_ids = applier_factory(
vec![],
vec![(1, vec![(false, "hello"), (false, "world")])],
None,
)
.await;
assert_eq!(row_ids, Some(rows([])));
let row_ids = applier_factory(
vec![],
vec![(1, vec![(false, "Hello"), (false, "World")])],
None,
)
.await;
assert_eq!(row_ids, Some(rows([3])));
let row_ids = applier_factory(
vec![],
vec![(1, vec![(true, "Hello"), (false, "World")])],
None,
)
.await;
assert_eq!(row_ids, Some(rows([1, 3])));
let row_ids = applier_factory(
vec![],
vec![(1, vec![(false, "Hello"), (true, "World")])],
None,
)
.await;
assert_eq!(row_ids, Some(rows([0, 3])));
let row_ids = applier_factory(
vec![],
vec![(1, vec![(true, "Hello"), (true, "World")])],
None,
)
.await;
assert_eq!(row_ids, None);
}
#[tokio::test]
async fn test_fulltext_index_multi_terms_case_sensitive_bloom() {
let applier_factory = build_fulltext_applier_factory(
@@ -1292,53 +1139,6 @@ mod tests {
assert_eq!(row_ids, None);
}
#[tokio::test]
async fn test_fulltext_index_multi_terms_case_insensitive_tantivy() {
let applier_factory = build_fulltext_applier_factory(
"test_fulltext_index_multi_terms_case_insensitive_tantivy_",
FulltextBackend::Tantivy,
&[
(None, Some("hello"), None),
(None, None, None),
(None, Some("world"), None),
(None, Some("Hello, World"), None),
],
)
.await;
let row_ids = applier_factory(
vec![],
vec![(2, vec![(false, "hello"), (false, "world")])],
None,
)
.await;
assert_eq!(row_ids, Some(rows([3])));
let row_ids = applier_factory(
vec![],
vec![(2, vec![(true, "hello"), (false, "world")])],
None,
)
.await;
assert_eq!(row_ids, Some(rows([3])));
let row_ids = applier_factory(
vec![],
vec![(2, vec![(false, "hello"), (true, "world")])],
None,
)
.await;
assert_eq!(row_ids, Some(rows([3])));
let row_ids = applier_factory(
vec![],
vec![(2, vec![(true, "hello"), (true, "world")])],
None,
)
.await;
assert_eq!(row_ids, Some(rows([3])));
}
#[tokio::test]
async fn test_fulltext_index_multi_terms_case_insensitive_bloom() {
let applier_factory = build_fulltext_applier_factory(
@@ -1404,16 +1204,10 @@ mod tests {
)
.await;
let row_ids = applier_factory(
vec![(1, "Hello"), (3, "你好")],
vec![(2, vec![(false, "world")])],
None,
)
.await;
assert_eq!(row_ids, Some(rows([3])));
let row_ids = applier_factory(vec![(1, "Hello"), (3, "你好")], vec![], None).await;
assert_eq!(row_ids, Some(rows([0, 3])));
let row_ids =
applier_factory(vec![(2, "World")], vec![(1, vec![(false, "World")])], None).await;
let row_ids = applier_factory(vec![(1, "World"), (2, "World")], vec![], None).await;
assert_eq!(row_ids, Some(rows([1, 3])));
}
@@ -160,6 +160,7 @@ impl Indexer {
};
let column_ids = indexer.column_ids().collect();
index_output.fulltext_bloom_columns = indexer.bloom_column_ids().collect();
let err = match indexer.finish(puffin_writer).await {
Ok((row_count, byte_count)) => {
self.fill_fulltext_index_output(
+4 -7
View File
@@ -18,6 +18,7 @@ use std::hash::{Hash, Hasher};
use std::sync::Arc;
use arrow::array::BooleanArray;
use common_base::term_token::extract_term_probes;
use common_function::scalars::matches_term::MatchesTermFinder;
use datafusion::config::ConfigOptions;
use datafusion::error::Result as DfResult;
@@ -179,12 +180,8 @@ impl PhysicalOptimizerRule for MatchesConstantTermOptimizer {
{
let finder = MatchesTermFinder::new(term);
// For debugging purpose. Not really precise but enough for most cases.
let probes = term
.split(|c: char| !c.is_alphanumeric() && c != '_')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
// Shown in plans to explain fulltext bloom pruning.
let probes = extract_term_probes(term);
let expr = PreCompiledMatchesTermExpr {
text: args[0].clone(),
@@ -495,7 +492,7 @@ mod tests {
let plan_str = get_plan_string(&physical_plan).join("\n");
assert!(plan_str.contains("MatchesConstTerm(text@0, term: \"foo\", probes: [\"foo\"]"));
assert!(plan_str.contains(
"MatchesConstTerm(text@0, term: \"hello wo_rld\", probes: [\"hello\", \"wo_rld\"]"
"MatchesConstTerm(text@0, term: \"hello wo_rld\", probes: [\"hello\", \"wo\", \"rld\"]"
));
assert!(plan_str.contains("MatchesConstTerm(text@0, term: \"world\", probes: [\"world\"]"));
assert!(
@@ -147,7 +147,7 @@ WHERE table_id = (
+------------+
| index_size |
+------------+
| 318 |
| 321 |
+------------+
-- SQLNESS ARG restart=true
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,296 @@
-- Fulltext index pruning must never drop rows that `matches_term` or `LIKE` accepts.
-- Each table holds the same rows in a single flushed SST. The unfiltered
-- projections are the full-scan answers; every filtered query below must return
-- exactly the rows marked true there.
CREATE TABLE ft_en_bloom (
ts TIMESTAMP TIME INDEX,
msg STRING FULLTEXT INDEX WITH(analyzer = 'English', backend = 'bloom', case_sensitive = 'false')
);
INSERT INTO ft_en_bloom VALUES
(1, 'hello_world'),
(2, 'trace_id=abc'),
(3, '错误error日志'),
(4, '登录手机号18888888888的动态key'),
(5, '中国农业银行'),
(6, '连接timeout.5次'),
(7, '用户user-123登录'),
(8, 'Disk QUOTA Exceeded on VOL_7');
ADMIN flush_table('ft_en_bloom');
SELECT ts, msg @@ 'world' AS "world", msg @@ 'id' AS "id", msg @@ 'error' AS "error", msg @@ '手机号' AS "手机号", msg @@ '手机' AS "手机", msg @@ '机号' AS "机号", msg @@ '18888888888' AS "18888888888", msg @@ '农业' AS "农业", msg @@ 'timeout' AS "timeout", msg @@ 'user' AS "user" FROM ft_en_bloom ORDER BY ts;
SELECT ts, msg FROM ft_en_bloom WHERE msg @@ 'world' ORDER BY ts;
SELECT ts, msg FROM ft_en_bloom WHERE msg @@ 'id' ORDER BY ts;
SELECT ts, msg FROM ft_en_bloom WHERE msg @@ 'error' ORDER BY ts;
SELECT ts, msg FROM ft_en_bloom WHERE msg @@ '手机号' ORDER BY ts;
SELECT ts, msg FROM ft_en_bloom WHERE msg @@ '手机' ORDER BY ts;
SELECT ts, msg FROM ft_en_bloom WHERE msg @@ '机号' ORDER BY ts;
SELECT ts, msg FROM ft_en_bloom WHERE msg @@ '18888888888' ORDER BY ts;
SELECT ts, msg FROM ft_en_bloom WHERE msg @@ '农业' ORDER BY ts;
SELECT ts, msg FROM ft_en_bloom WHERE msg @@ 'timeout' ORDER BY ts;
SELECT ts, msg FROM ft_en_bloom WHERE msg @@ 'user' ORDER BY ts;
SELECT ts, msg LIKE '%world' AS "%world", msg LIKE 'hello\_world' AS "hello\_world", msg LIKE 'hello_world' AS "hello_world", msg LIKE '%id=%' AS "%id=%", msg LIKE 'trace%abc' AS "trace%abc", msg LIKE '%error%' AS "%error%", msg LIKE '%手机号1888%' AS "%手机号1888%", msg LIKE '%农业%' AS "%农业%", msg LIKE '连接timeout.%' AS "连接timeout.%", msg LIKE '%user-123%' AS "%user-123%" FROM ft_en_bloom ORDER BY ts;
SELECT ts, msg FROM ft_en_bloom WHERE msg LIKE '%world' ORDER BY ts;
SELECT ts, msg FROM ft_en_bloom WHERE msg LIKE 'hello\_world' ORDER BY ts;
SELECT ts, msg FROM ft_en_bloom WHERE msg LIKE 'hello_world' ORDER BY ts;
SELECT ts, msg FROM ft_en_bloom WHERE msg LIKE '%id=%' ORDER BY ts;
SELECT ts, msg FROM ft_en_bloom WHERE msg LIKE 'trace%abc' ORDER BY ts;
SELECT ts, msg FROM ft_en_bloom WHERE msg LIKE '%error%' ORDER BY ts;
SELECT ts, msg FROM ft_en_bloom WHERE msg LIKE '%手机号1888%' ORDER BY ts;
SELECT ts, msg FROM ft_en_bloom WHERE msg LIKE '%农业%' ORDER BY ts;
SELECT ts, msg FROM ft_en_bloom WHERE msg LIKE '连接timeout.%' ORDER BY ts;
SELECT ts, msg FROM ft_en_bloom WHERE msg LIKE '%user-123%' ORDER BY ts;
SELECT ts, matches_term(lower(msg), 'quota') AS "matches_term(lower(msg), 'quota')", matches_term(lower(msg), 'vol') AS "matches_term(lower(msg), 'vol')", lower(msg) LIKE '%disk quota exceeded%' AS "lower(msg) LIKE '%disk quota exceeded%'", lower(msg) LIKE 'disk quota%' AS "lower(msg) LIKE 'disk quota%'" FROM ft_en_bloom ORDER BY ts;
SELECT ts, msg FROM ft_en_bloom WHERE matches_term(lower(msg), 'quota') ORDER BY ts;
SELECT ts, msg FROM ft_en_bloom WHERE matches_term(lower(msg), 'vol') ORDER BY ts;
SELECT ts, msg FROM ft_en_bloom WHERE lower(msg) LIKE '%disk quota exceeded%' ORDER BY ts;
SELECT ts, msg FROM ft_en_bloom WHERE lower(msg) LIKE 'disk quota%' ORDER BY ts;
DROP TABLE ft_en_bloom;
CREATE TABLE ft_zh_bloom (
ts TIMESTAMP TIME INDEX,
msg STRING FULLTEXT INDEX WITH(analyzer = 'Chinese', backend = 'bloom', case_sensitive = 'false')
);
INSERT INTO ft_zh_bloom VALUES
(1, 'hello_world'),
(2, 'trace_id=abc'),
(3, '错误error日志'),
(4, '登录手机号18888888888的动态key'),
(5, '中国农业银行'),
(6, '连接timeout.5次'),
(7, '用户user-123登录'),
(8, 'Disk QUOTA Exceeded on VOL_7');
ADMIN flush_table('ft_zh_bloom');
SELECT ts, msg @@ 'world' AS "world", msg @@ 'id' AS "id", msg @@ 'error' AS "error", msg @@ '手机号' AS "手机号", msg @@ '手机' AS "手机", msg @@ '机号' AS "机号", msg @@ '18888888888' AS "18888888888", msg @@ '农业' AS "农业", msg @@ 'timeout' AS "timeout", msg @@ 'user' AS "user" FROM ft_zh_bloom ORDER BY ts;
SELECT ts, msg FROM ft_zh_bloom WHERE msg @@ 'world' ORDER BY ts;
SELECT ts, msg FROM ft_zh_bloom WHERE msg @@ 'id' ORDER BY ts;
SELECT ts, msg FROM ft_zh_bloom WHERE msg @@ 'error' ORDER BY ts;
SELECT ts, msg FROM ft_zh_bloom WHERE msg @@ '手机号' ORDER BY ts;
SELECT ts, msg FROM ft_zh_bloom WHERE msg @@ '手机' ORDER BY ts;
SELECT ts, msg FROM ft_zh_bloom WHERE msg @@ '机号' ORDER BY ts;
SELECT ts, msg FROM ft_zh_bloom WHERE msg @@ '18888888888' ORDER BY ts;
SELECT ts, msg FROM ft_zh_bloom WHERE msg @@ '农业' ORDER BY ts;
SELECT ts, msg FROM ft_zh_bloom WHERE msg @@ 'timeout' ORDER BY ts;
SELECT ts, msg FROM ft_zh_bloom WHERE msg @@ 'user' ORDER BY ts;
SELECT ts, msg LIKE '%world' AS "%world", msg LIKE 'hello\_world' AS "hello\_world", msg LIKE 'hello_world' AS "hello_world", msg LIKE '%id=%' AS "%id=%", msg LIKE 'trace%abc' AS "trace%abc", msg LIKE '%error%' AS "%error%", msg LIKE '%手机号1888%' AS "%手机号1888%", msg LIKE '%农业%' AS "%农业%", msg LIKE '连接timeout.%' AS "连接timeout.%", msg LIKE '%user-123%' AS "%user-123%" FROM ft_zh_bloom ORDER BY ts;
SELECT ts, msg FROM ft_zh_bloom WHERE msg LIKE '%world' ORDER BY ts;
SELECT ts, msg FROM ft_zh_bloom WHERE msg LIKE 'hello\_world' ORDER BY ts;
SELECT ts, msg FROM ft_zh_bloom WHERE msg LIKE 'hello_world' ORDER BY ts;
SELECT ts, msg FROM ft_zh_bloom WHERE msg LIKE '%id=%' ORDER BY ts;
SELECT ts, msg FROM ft_zh_bloom WHERE msg LIKE 'trace%abc' ORDER BY ts;
SELECT ts, msg FROM ft_zh_bloom WHERE msg LIKE '%error%' ORDER BY ts;
SELECT ts, msg FROM ft_zh_bloom WHERE msg LIKE '%手机号1888%' ORDER BY ts;
SELECT ts, msg FROM ft_zh_bloom WHERE msg LIKE '%农业%' ORDER BY ts;
SELECT ts, msg FROM ft_zh_bloom WHERE msg LIKE '连接timeout.%' ORDER BY ts;
SELECT ts, msg FROM ft_zh_bloom WHERE msg LIKE '%user-123%' ORDER BY ts;
SELECT ts, matches_term(lower(msg), 'quota') AS "matches_term(lower(msg), 'quota')", matches_term(lower(msg), 'vol') AS "matches_term(lower(msg), 'vol')", lower(msg) LIKE '%disk quota exceeded%' AS "lower(msg) LIKE '%disk quota exceeded%'", lower(msg) LIKE 'disk quota%' AS "lower(msg) LIKE 'disk quota%'" FROM ft_zh_bloom ORDER BY ts;
SELECT ts, msg FROM ft_zh_bloom WHERE matches_term(lower(msg), 'quota') ORDER BY ts;
SELECT ts, msg FROM ft_zh_bloom WHERE matches_term(lower(msg), 'vol') ORDER BY ts;
SELECT ts, msg FROM ft_zh_bloom WHERE lower(msg) LIKE '%disk quota exceeded%' ORDER BY ts;
SELECT ts, msg FROM ft_zh_bloom WHERE lower(msg) LIKE 'disk quota%' ORDER BY ts;
DROP TABLE ft_zh_bloom;
CREATE TABLE ft_en_tantivy (
ts TIMESTAMP TIME INDEX,
msg STRING FULLTEXT INDEX WITH(analyzer = 'English', backend = 'tantivy', case_sensitive = 'false')
);
INSERT INTO ft_en_tantivy VALUES
(1, 'hello_world'),
(2, 'trace_id=abc'),
(3, '错误error日志'),
(4, '登录手机号18888888888的动态key'),
(5, '中国农业银行'),
(6, '连接timeout.5次'),
(7, '用户user-123登录'),
(8, 'Disk QUOTA Exceeded on VOL_7');
ADMIN flush_table('ft_en_tantivy');
SELECT ts, msg @@ 'world' AS "world", msg @@ 'id' AS "id", msg @@ 'error' AS "error", msg @@ '手机号' AS "手机号", msg @@ '手机' AS "手机", msg @@ '机号' AS "机号", msg @@ '18888888888' AS "18888888888", msg @@ '农业' AS "农业", msg @@ 'timeout' AS "timeout", msg @@ 'user' AS "user" FROM ft_en_tantivy ORDER BY ts;
SELECT ts, msg FROM ft_en_tantivy WHERE msg @@ 'world' ORDER BY ts;
SELECT ts, msg FROM ft_en_tantivy WHERE msg @@ 'id' ORDER BY ts;
SELECT ts, msg FROM ft_en_tantivy WHERE msg @@ 'error' ORDER BY ts;
SELECT ts, msg FROM ft_en_tantivy WHERE msg @@ '手机号' ORDER BY ts;
SELECT ts, msg FROM ft_en_tantivy WHERE msg @@ '手机' ORDER BY ts;
SELECT ts, msg FROM ft_en_tantivy WHERE msg @@ '机号' ORDER BY ts;
SELECT ts, msg FROM ft_en_tantivy WHERE msg @@ '18888888888' ORDER BY ts;
SELECT ts, msg FROM ft_en_tantivy WHERE msg @@ '农业' ORDER BY ts;
SELECT ts, msg FROM ft_en_tantivy WHERE msg @@ 'timeout' ORDER BY ts;
SELECT ts, msg FROM ft_en_tantivy WHERE msg @@ 'user' ORDER BY ts;
SELECT ts, msg LIKE '%world' AS "%world", msg LIKE 'hello\_world' AS "hello\_world", msg LIKE 'hello_world' AS "hello_world", msg LIKE '%id=%' AS "%id=%", msg LIKE 'trace%abc' AS "trace%abc", msg LIKE '%error%' AS "%error%", msg LIKE '%手机号1888%' AS "%手机号1888%", msg LIKE '%农业%' AS "%农业%", msg LIKE '连接timeout.%' AS "连接timeout.%", msg LIKE '%user-123%' AS "%user-123%" FROM ft_en_tantivy ORDER BY ts;
SELECT ts, msg FROM ft_en_tantivy WHERE msg LIKE '%world' ORDER BY ts;
SELECT ts, msg FROM ft_en_tantivy WHERE msg LIKE 'hello\_world' ORDER BY ts;
SELECT ts, msg FROM ft_en_tantivy WHERE msg LIKE 'hello_world' ORDER BY ts;
SELECT ts, msg FROM ft_en_tantivy WHERE msg LIKE '%id=%' ORDER BY ts;
SELECT ts, msg FROM ft_en_tantivy WHERE msg LIKE 'trace%abc' ORDER BY ts;
SELECT ts, msg FROM ft_en_tantivy WHERE msg LIKE '%error%' ORDER BY ts;
SELECT ts, msg FROM ft_en_tantivy WHERE msg LIKE '%手机号1888%' ORDER BY ts;
SELECT ts, msg FROM ft_en_tantivy WHERE msg LIKE '%农业%' ORDER BY ts;
SELECT ts, msg FROM ft_en_tantivy WHERE msg LIKE '连接timeout.%' ORDER BY ts;
SELECT ts, msg FROM ft_en_tantivy WHERE msg LIKE '%user-123%' ORDER BY ts;
SELECT ts, matches_term(lower(msg), 'quota') AS "matches_term(lower(msg), 'quota')", matches_term(lower(msg), 'vol') AS "matches_term(lower(msg), 'vol')", lower(msg) LIKE '%disk quota exceeded%' AS "lower(msg) LIKE '%disk quota exceeded%'", lower(msg) LIKE 'disk quota%' AS "lower(msg) LIKE 'disk quota%'" FROM ft_en_tantivy ORDER BY ts;
SELECT ts, msg FROM ft_en_tantivy WHERE matches_term(lower(msg), 'quota') ORDER BY ts;
SELECT ts, msg FROM ft_en_tantivy WHERE matches_term(lower(msg), 'vol') ORDER BY ts;
SELECT ts, msg FROM ft_en_tantivy WHERE lower(msg) LIKE '%disk quota exceeded%' ORDER BY ts;
SELECT ts, msg FROM ft_en_tantivy WHERE lower(msg) LIKE 'disk quota%' ORDER BY ts;
DROP TABLE ft_en_tantivy;
CREATE TABLE ft_zh_tantivy (
ts TIMESTAMP TIME INDEX,
msg STRING FULLTEXT INDEX WITH(analyzer = 'Chinese', backend = 'tantivy', case_sensitive = 'false')
);
INSERT INTO ft_zh_tantivy VALUES
(1, 'hello_world'),
(2, 'trace_id=abc'),
(3, '错误error日志'),
(4, '登录手机号18888888888的动态key'),
(5, '中国农业银行'),
(6, '连接timeout.5次'),
(7, '用户user-123登录'),
(8, 'Disk QUOTA Exceeded on VOL_7');
ADMIN flush_table('ft_zh_tantivy');
SELECT ts, msg @@ 'world' AS "world", msg @@ 'id' AS "id", msg @@ 'error' AS "error", msg @@ '手机号' AS "手机号", msg @@ '手机' AS "手机", msg @@ '机号' AS "机号", msg @@ '18888888888' AS "18888888888", msg @@ '农业' AS "农业", msg @@ 'timeout' AS "timeout", msg @@ 'user' AS "user" FROM ft_zh_tantivy ORDER BY ts;
SELECT ts, msg FROM ft_zh_tantivy WHERE msg @@ 'world' ORDER BY ts;
SELECT ts, msg FROM ft_zh_tantivy WHERE msg @@ 'id' ORDER BY ts;
SELECT ts, msg FROM ft_zh_tantivy WHERE msg @@ 'error' ORDER BY ts;
SELECT ts, msg FROM ft_zh_tantivy WHERE msg @@ '手机号' ORDER BY ts;
SELECT ts, msg FROM ft_zh_tantivy WHERE msg @@ '手机' ORDER BY ts;
SELECT ts, msg FROM ft_zh_tantivy WHERE msg @@ '机号' ORDER BY ts;
SELECT ts, msg FROM ft_zh_tantivy WHERE msg @@ '18888888888' ORDER BY ts;
SELECT ts, msg FROM ft_zh_tantivy WHERE msg @@ '农业' ORDER BY ts;
SELECT ts, msg FROM ft_zh_tantivy WHERE msg @@ 'timeout' ORDER BY ts;
SELECT ts, msg FROM ft_zh_tantivy WHERE msg @@ 'user' ORDER BY ts;
SELECT ts, msg LIKE '%world' AS "%world", msg LIKE 'hello\_world' AS "hello\_world", msg LIKE 'hello_world' AS "hello_world", msg LIKE '%id=%' AS "%id=%", msg LIKE 'trace%abc' AS "trace%abc", msg LIKE '%error%' AS "%error%", msg LIKE '%手机号1888%' AS "%手机号1888%", msg LIKE '%农业%' AS "%农业%", msg LIKE '连接timeout.%' AS "连接timeout.%", msg LIKE '%user-123%' AS "%user-123%" FROM ft_zh_tantivy ORDER BY ts;
SELECT ts, msg FROM ft_zh_tantivy WHERE msg LIKE '%world' ORDER BY ts;
SELECT ts, msg FROM ft_zh_tantivy WHERE msg LIKE 'hello\_world' ORDER BY ts;
SELECT ts, msg FROM ft_zh_tantivy WHERE msg LIKE 'hello_world' ORDER BY ts;
SELECT ts, msg FROM ft_zh_tantivy WHERE msg LIKE '%id=%' ORDER BY ts;
SELECT ts, msg FROM ft_zh_tantivy WHERE msg LIKE 'trace%abc' ORDER BY ts;
SELECT ts, msg FROM ft_zh_tantivy WHERE msg LIKE '%error%' ORDER BY ts;
SELECT ts, msg FROM ft_zh_tantivy WHERE msg LIKE '%手机号1888%' ORDER BY ts;
SELECT ts, msg FROM ft_zh_tantivy WHERE msg LIKE '%农业%' ORDER BY ts;
SELECT ts, msg FROM ft_zh_tantivy WHERE msg LIKE '连接timeout.%' ORDER BY ts;
SELECT ts, msg FROM ft_zh_tantivy WHERE msg LIKE '%user-123%' ORDER BY ts;
SELECT ts, matches_term(lower(msg), 'quota') AS "matches_term(lower(msg), 'quota')", matches_term(lower(msg), 'vol') AS "matches_term(lower(msg), 'vol')", lower(msg) LIKE '%disk quota exceeded%' AS "lower(msg) LIKE '%disk quota exceeded%'", lower(msg) LIKE 'disk quota%' AS "lower(msg) LIKE 'disk quota%'" FROM ft_zh_tantivy ORDER BY ts;
SELECT ts, msg FROM ft_zh_tantivy WHERE matches_term(lower(msg), 'quota') ORDER BY ts;
SELECT ts, msg FROM ft_zh_tantivy WHERE matches_term(lower(msg), 'vol') ORDER BY ts;
SELECT ts, msg FROM ft_zh_tantivy WHERE lower(msg) LIKE '%disk quota exceeded%' ORDER BY ts;
SELECT ts, msg FROM ft_zh_tantivy WHERE lower(msg) LIKE 'disk quota%' ORDER BY ts;
DROP TABLE ft_zh_tantivy;
@@ -120,9 +120,9 @@ SELECT * FROM information_schema.ssts_index_meta ORDER BY meta_json;
+----------------------------+---------------------------------------------------------------------------------------------+---------------+----------+---------------+--------------+-----------------+--------------------------------------+-----------------+----------------+-------------+------------+--------------+-----------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+---------+
| table_dir | index_file_path | region_id | table_id | region_number | region_group | region_sequence | file_id | index_file_size | index_type | target_type | target_key | target_json | blob_size | meta_json | node_id |
+----------------------------+---------------------------------------------------------------------------------------------+---------------+----------+---------------+--------------+-----------------+--------------------------------------+-----------------+----------------+-------------+------------+--------------+-----------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+---------+
| data/greptime/public/<TABLE_ID>/ | data/greptime/public/<TABLE_ID>/<REGION_ID>_<REGION_NUMBER>/index/<UUID>.puffin |<NUM>|<NUM>|<NUM>|<NUM>|<NUM>| <UUID> |<NUM>| fulltext_bloom | column |<NUM>| {"column":2} |<NUM>| {"bloom":{"bloom_filter_size":64,"row_count":1,"rows_per_segment":10240,"segment_count":1},"fulltext":{"analyzer":"English","case_sensitive":false}} |<NUM>|
| data/greptime/public/<TABLE_ID>/ | data/greptime/public/<TABLE_ID>/<REGION_ID>_<REGION_NUMBER>/index/<UUID>.puffin |<NUM>|<NUM>|<NUM>|<NUM>|<NUM>| <UUID> |<NUM>| fulltext_bloom | column |<NUM>| {"column":2} |<NUM>| {"bloom":{"bloom_filter_size":64,"row_count":1,"rows_per_segment":10240,"segment_count":1},"fulltext":{"analyzer":"English","case_sensitive":false}} |<NUM>|
| data/greptime/public/<TABLE_ID>/ | data/greptime/public/<TABLE_ID>/<REGION_ID>_<REGION_NUMBER>/index/<UUID>.puffin |<NUM>|<NUM>|<NUM>|<NUM>|<NUM>| <UUID> |<NUM>| fulltext_bloom | column |<NUM>| {"column":2} |<NUM>| {"bloom":{"bloom_filter_size":64,"row_count":1,"rows_per_segment":10240,"segment_count":1},"fulltext":{"analyzer":"English","case_sensitive":false}} |<NUM>|
| data/greptime/public/<TABLE_ID>/ | data/greptime/public/<TABLE_ID>/<REGION_ID>_<REGION_NUMBER>/index/<UUID>.puffin |<NUM>|<NUM>|<NUM>|<NUM>|<NUM>| <UUID> |<NUM>| fulltext_bloom | column |<NUM>| {"column":2} |<NUM>| {"bloom":{"bloom_filter_size":64,"row_count":1,"rows_per_segment":10240,"segment_count":1},"fulltext":{"analyzer":"English","bloom_version":2,"case_sensitive":false}} |<NUM>|
| data/greptime/public/<TABLE_ID>/ | data/greptime/public/<TABLE_ID>/<REGION_ID>_<REGION_NUMBER>/index/<UUID>.puffin |<NUM>|<NUM>|<NUM>|<NUM>|<NUM>| <UUID> |<NUM>| fulltext_bloom | column |<NUM>| {"column":2} |<NUM>| {"bloom":{"bloom_filter_size":64,"row_count":1,"rows_per_segment":10240,"segment_count":1},"fulltext":{"analyzer":"English","bloom_version":2,"case_sensitive":false}} |<NUM>|
| data/greptime/public/<TABLE_ID>/ | data/greptime/public/<TABLE_ID>/<REGION_ID>_<REGION_NUMBER>/index/<UUID>.puffin |<NUM>|<NUM>|<NUM>|<NUM>|<NUM>| <UUID> |<NUM>| fulltext_bloom | column |<NUM>| {"column":2} |<NUM>| {"bloom":{"bloom_filter_size":64,"row_count":1,"rows_per_segment":10240,"segment_count":1},"fulltext":{"analyzer":"English","bloom_version":2,"case_sensitive":false}} |<NUM>|
| data/greptime/public/<TABLE_ID>/ | data/greptime/public/<TABLE_ID>/<REGION_ID>_<REGION_NUMBER>/index/<UUID>.puffin |<NUM>|<NUM>|<NUM>|<NUM>|<NUM>| <UUID> |<NUM>| bloom_filter | column |<NUM>| {"column":1} |<NUM>| {"bloom":{"bloom_filter_size":64,"row_count":1,"rows_per_segment":10240,"segment_count":1}} |<NUM>|
| data/greptime/public/<TABLE_ID>/ | data/greptime/public/<TABLE_ID>/<REGION_ID>_<REGION_NUMBER>/index/<UUID>.puffin |<NUM>|<NUM>|<NUM>|<NUM>|<NUM>| <UUID> |<NUM>| bloom_filter | column |<NUM>| {"column":1} |<NUM>| {"bloom":{"bloom_filter_size":64,"row_count":1,"rows_per_segment":10240,"segment_count":1}} |<NUM>|
| data/greptime/public/<TABLE_ID>/ | data/greptime/public/<TABLE_ID>/<REGION_ID>_<REGION_NUMBER>/index/<UUID>.puffin |<NUM>|<NUM>|<NUM>|<NUM>|<NUM>| <UUID> |<NUM>| bloom_filter | column |<NUM>| {"column":1} |<NUM>| {"bloom":{"bloom_filter_size":64,"row_count":1,"rows_per_segment":10240,"segment_count":1}} |<NUM>|
@@ -191,15 +191,15 @@ SELECT * FROM information_schema.ssts_index_meta ORDER BY meta_json;
+----------------------------+---------------------------------------------------------------------------------------------+---------------+----------+---------------+--------------+-----------------+--------------------------------------+-----------------+----------------+-------------+------------+--------------+-----------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+---------+
| table_dir | index_file_path | region_id | table_id | region_number | region_group | region_sequence | file_id | index_file_size | index_type | target_type | target_key | target_json | blob_size | meta_json | node_id |
+----------------------------+---------------------------------------------------------------------------------------------+---------------+----------+---------------+--------------+-----------------+--------------------------------------+-----------------+----------------+-------------+------------+--------------+-----------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+---------+
| data/greptime/public/<TABLE_ID>/ | data/greptime/public/<TABLE_ID>/<REGION_ID>_<REGION_NUMBER>/index/<UUID>.puffin |<NUM>|<NUM>|<NUM>|<NUM>|<NUM>| <UUID> |<NUM>| fulltext_bloom | column |<NUM>| {"column":2} |<NUM>| {"bloom":{"bloom_filter_size":64,"row_count":1,"rows_per_segment":10240,"segment_count":1},"fulltext":{"analyzer":"English","case_sensitive":false}} |<NUM>|
| data/greptime/public/<TABLE_ID>/ | data/greptime/public/<TABLE_ID>/<REGION_ID>_<REGION_NUMBER>/index/<UUID>.puffin |<NUM>|<NUM>|<NUM>|<NUM>|<NUM>| <UUID> |<NUM>| fulltext_bloom | column |<NUM>| {"column":2} |<NUM>| {"bloom":{"bloom_filter_size":64,"row_count":1,"rows_per_segment":10240,"segment_count":1},"fulltext":{"analyzer":"English","case_sensitive":false}} |<NUM>|
| data/greptime/public/<TABLE_ID>/ | data/greptime/public/<TABLE_ID>/<REGION_ID>_<REGION_NUMBER>/index/<UUID>.puffin |<NUM>|<NUM>|<NUM>|<NUM>|<NUM>| <UUID> |<NUM>| fulltext_bloom | column |<NUM>| {"column":2} |<NUM>| {"bloom":{"bloom_filter_size":64,"row_count":1,"rows_per_segment":10240,"segment_count":1},"fulltext":{"analyzer":"English","case_sensitive":false}} |<NUM>|
| data/greptime/public/<TABLE_ID>/ | data/greptime/public/<TABLE_ID>/<REGION_ID>_<REGION_NUMBER>/index/<UUID>.puffin |<NUM>|<NUM>|<NUM>|<NUM>|<NUM>| <UUID> |<NUM>| fulltext_bloom | column |<NUM>| {"column":2} |<NUM>| {"bloom":{"bloom_filter_size":64,"row_count":1,"rows_per_segment":10240,"segment_count":1},"fulltext":{"analyzer":"English","case_sensitive":false}} |<NUM>|
| data/greptime/public/<TABLE_ID>/ | data/greptime/public/<TABLE_ID>/<REGION_ID>_<REGION_NUMBER>/index/<UUID>.puffin |<NUM>|<NUM>|<NUM>|<NUM>|<NUM>| <UUID> |<NUM>| fulltext_bloom | column |<NUM>| {"column":2} |<NUM>| {"bloom":{"bloom_filter_size":64,"row_count":1,"rows_per_segment":10240,"segment_count":1},"fulltext":{"analyzer":"English","bloom_version":2,"case_sensitive":false}} |<NUM>|
| data/greptime/public/<TABLE_ID>/ | data/greptime/public/<TABLE_ID>/<REGION_ID>_<REGION_NUMBER>/index/<UUID>.puffin |<NUM>|<NUM>|<NUM>|<NUM>|<NUM>| <UUID> |<NUM>| fulltext_bloom | column |<NUM>| {"column":2} |<NUM>| {"bloom":{"bloom_filter_size":64,"row_count":1,"rows_per_segment":10240,"segment_count":1},"fulltext":{"analyzer":"English","bloom_version":2,"case_sensitive":false}} |<NUM>|
| data/greptime/public/<TABLE_ID>/ | data/greptime/public/<TABLE_ID>/<REGION_ID>_<REGION_NUMBER>/index/<UUID>.puffin |<NUM>|<NUM>|<NUM>|<NUM>|<NUM>| <UUID> |<NUM>| fulltext_bloom | column |<NUM>| {"column":2} |<NUM>| {"bloom":{"bloom_filter_size":64,"row_count":1,"rows_per_segment":10240,"segment_count":1},"fulltext":{"analyzer":"English","bloom_version":2,"case_sensitive":false}} |<NUM>|
| data/greptime/public/<TABLE_ID>/ | data/greptime/public/<TABLE_ID>/<REGION_ID>_<REGION_NUMBER>/index/<UUID>.puffin |<NUM>|<NUM>|<NUM>|<NUM>|<NUM>| <UUID> |<NUM>| fulltext_bloom | column |<NUM>| {"column":2} |<NUM>| {"bloom":{"bloom_filter_size":64,"row_count":1,"rows_per_segment":10240,"segment_count":1},"fulltext":{"analyzer":"English","bloom_version":2,"case_sensitive":false}} |<NUM>|
| data/greptime/public/<TABLE_ID>/ | data/greptime/public/<TABLE_ID>/<REGION_ID>_<REGION_NUMBER>/index/<UUID>.puffin |<NUM>|<NUM>|<NUM>|<NUM>|<NUM>| <UUID> |<NUM>| bloom_filter | column |<NUM>| {"column":1} |<NUM>| {"bloom":{"bloom_filter_size":64,"row_count":1,"rows_per_segment":10240,"segment_count":1}} |<NUM>|
| data/greptime/public/<TABLE_ID>/ | data/greptime/public/<TABLE_ID>/<REGION_ID>_<REGION_NUMBER>/index/<UUID>.puffin |<NUM>|<NUM>|<NUM>|<NUM>|<NUM>| <UUID> |<NUM>| bloom_filter | column |<NUM>| {"column":1} |<NUM>| {"bloom":{"bloom_filter_size":64,"row_count":1,"rows_per_segment":10240,"segment_count":1}} |<NUM>|
| data/greptime/public/<TABLE_ID>/ | data/greptime/public/<TABLE_ID>/<REGION_ID>_<REGION_NUMBER>/index/<UUID>.puffin |<NUM>|<NUM>|<NUM>|<NUM>|<NUM>| <UUID> |<NUM>| bloom_filter | column |<NUM>| {"column":1} |<NUM>| {"bloom":{"bloom_filter_size":64,"row_count":1,"rows_per_segment":10240,"segment_count":1}} |<NUM>|
| data/greptime/public/<TABLE_ID>/ | data/greptime/public/<TABLE_ID>/<REGION_ID>_<REGION_NUMBER>/index/<UUID>.puffin |<NUM>|<NUM>|<NUM>|<NUM>|<NUM>| <UUID> |<NUM>| bloom_filter | column |<NUM>| {"column":1} |<NUM>| {"bloom":{"bloom_filter_size":64,"row_count":1,"rows_per_segment":10240,"segment_count":1}} |<NUM>|
| data/greptime/public/<TABLE_ID>/ | data/greptime/public/<TABLE_ID>/<REGION_ID>_<REGION_NUMBER>/index/<UUID>.puffin |<NUM>|<NUM>|<NUM>|<NUM>|<NUM>| <UUID> |<NUM>| fulltext_bloom | column |<NUM>| {"column":2} |<NUM>| {"bloom":{"bloom_filter_size":64,"row_count":2,"rows_per_segment":10240,"segment_count":1},"fulltext":{"analyzer":"English","case_sensitive":false}} |<NUM>|
| data/greptime/public/<TABLE_ID>/ | data/greptime/public/<TABLE_ID>/<REGION_ID>_<REGION_NUMBER>/index/<UUID>.puffin |<NUM>|<NUM>|<NUM>|<NUM>|<NUM>| <UUID> |<NUM>| fulltext_bloom | column |<NUM>| {"column":2} |<NUM>| {"bloom":{"bloom_filter_size":64,"row_count":2,"rows_per_segment":10240,"segment_count":1},"fulltext":{"analyzer":"English","bloom_version":2,"case_sensitive":false}} |<NUM>|
| data/greptime/public/<TABLE_ID>/ | data/greptime/public/<TABLE_ID>/<REGION_ID>_<REGION_NUMBER>/index/<UUID>.puffin |<NUM>|<NUM>|<NUM>|<NUM>|<NUM>| <UUID> |<NUM>| bloom_filter | column |<NUM>| {"column":1} |<NUM>| {"bloom":{"bloom_filter_size":64,"row_count":2,"rows_per_segment":10240,"segment_count":1}} |<NUM>|
| data/greptime/public/<TABLE_ID>/ | data/greptime/public/<TABLE_ID>/<REGION_ID>_<REGION_NUMBER>/index/<UUID>.puffin |<NUM>|<NUM>|<NUM>|<NUM>|<NUM>| <UUID> |<NUM>| inverted | column |<NUM>| {"column":0} |<NUM>| {"inverted":{"base_offset":0,"bitmap_type":"Roaring","fst_size":55,"inverted_index_size":81,"null_bitmap_size":8,"relative_fst_offset":26,"relative_null_bitmap_offset":0,"segment_row_count":1024,"total_row_count":1}} |<NUM>|
| data/greptime/public/<TABLE_ID>/ | data/greptime/public/<TABLE_ID>/<REGION_ID>_<REGION_NUMBER>/index/<UUID>.puffin |<NUM>|<NUM>|<NUM>|<NUM>|<NUM>| <UUID> |<NUM>| inverted | column |<NUM>| {"column":0} |<NUM>| {"inverted":{"base_offset":0,"bitmap_type":"Roaring","fst_size":55,"inverted_index_size":81,"null_bitmap_size":8,"relative_fst_offset":26,"relative_null_bitmap_offset":0,"segment_row_count":1024,"total_row_count":1}} |<NUM>|
@@ -47,3 +47,14 @@ INSERT INTO t_sst_float_field_encoding_downgrade VALUES
('2024-02-10 00:02:00+0000', 'host_c', NULL, NULL);
ADMIN FLUSH_TABLE('t_sst_float_field_encoding_downgrade');
CREATE TABLE t_fulltext_bloom_v2_downgrade (
ts TIMESTAMP TIME INDEX,
msg STRING FULLTEXT INDEX WITH(analyzer = 'English', backend = 'bloom', case_sensitive = 'false')
);
INSERT INTO t_fulltext_bloom_v2_downgrade VALUES
(1, 'hello_world'),
(2, '错误error日志');
ADMIN FLUSH_TABLE('t_fulltext_bloom_v2_downgrade');
@@ -29,3 +29,21 @@ ORDER BY ts, host;
| 2024-02-10T00:01:00 | host_b | -2.5 | 20.25 |
| 2024-02-10T00:02:00 | host_c | | |
+---------------------+--------+------+-------+
-- Older binaries don't recognize the bloom blob written by the current one and must
-- scan without pruning instead of probing it with their own tokens.
SELECT ts, msg FROM t_fulltext_bloom_v2_downgrade WHERE msg @@ 'hello_world' ORDER BY ts;
+-------------------------+-------------+
| ts | msg |
+-------------------------+-------------+
| 1970-01-01T00:00:00.001 | hello_world |
+-------------------------+-------------+
SELECT ts, msg FROM t_fulltext_bloom_v2_downgrade WHERE msg @@ '错误error日志' ORDER BY ts;
+-------------------------+---------------+
| ts | msg |
+-------------------------+---------------+
| 1970-01-01T00:00:00.002 | 错误error日志 |
+-------------------------+---------------+
@@ -5,3 +5,9 @@ SELECT ts, host, val FROM t_preserve_sequence_downgrade ORDER BY ts, host;
SELECT ts, host, f, d
FROM t_sst_float_field_encoding_downgrade
ORDER BY ts, host;
-- Older binaries don't recognize the bloom blob written by the current one and must
-- scan without pruning instead of probing it with their own tokens.
SELECT ts, msg FROM t_fulltext_bloom_v2_downgrade WHERE msg @@ 'hello_world' ORDER BY ts;
SELECT ts, msg FROM t_fulltext_bloom_v2_downgrade WHERE msg @@ '错误error日志' ORDER BY ts;
@@ -0,0 +1,8 @@
name = "fulltext_bloom_tokens"
reason = "Verify matches_term and LIKE return every matching row from fulltext bloom SSTs written by older binaries, whose tokens don't follow matches_term boundaries, alone and mixed with SSTs written by the current binary."
introduced_by = "fix: fulltext bloom index drops rows matched by matches_term"
topologies = ["distributed", "standalone"]
from_range = [">=v1.0.0"]
to_range = ["*"]
features = ["table", "sst", "fulltext"]
owner = "storage"
@@ -0,0 +1,28 @@
CREATE TABLE t_fulltext_bloom_tokens (
ts TIMESTAMP TIME INDEX,
msg STRING FULLTEXT INDEX WITH(analyzer = 'English', backend = 'bloom', case_sensitive = 'false')
);
INSERT INTO t_fulltext_bloom_tokens VALUES
(1, 'hello_world'),
(2, '错误error日志'),
(3, '中国农业银行');
ADMIN FLUSH_TABLE('t_fulltext_bloom_tokens');
-- Two SSTs with old bloom blobs, left for compaction to rewrite.
CREATE TABLE t_fulltext_bloom_compact (
ts TIMESTAMP TIME INDEX,
msg STRING FULLTEXT INDEX WITH(analyzer = 'English', backend = 'bloom', case_sensitive = 'false')
);
INSERT INTO t_fulltext_bloom_compact VALUES
(1, 'hello_world'),
(2, '错误error日志');
ADMIN FLUSH_TABLE('t_fulltext_bloom_compact');
INSERT INTO t_fulltext_bloom_compact VALUES
(3, '中国农业银行');
ADMIN FLUSH_TABLE('t_fulltext_bloom_compact');
@@ -0,0 +1,197 @@
-- Only the SST written by the old binary.
SELECT ts, msg FROM t_fulltext_bloom_tokens WHERE msg @@ 'world' ORDER BY ts;
+-------------------------+-------------+
| ts | msg |
+-------------------------+-------------+
| 1970-01-01T00:00:00.001 | hello_world |
+-------------------------+-------------+
SELECT ts, msg FROM t_fulltext_bloom_tokens WHERE msg @@ 'error' ORDER BY ts;
+-------------------------+---------------+
| ts | msg |
+-------------------------+---------------+
| 1970-01-01T00:00:00.002 | 错误error日志 |
+-------------------------+---------------+
SELECT ts, msg FROM t_fulltext_bloom_tokens WHERE msg @@ '农业' ORDER BY ts;
+-------------------------+--------------+
| ts | msg |
+-------------------------+--------------+
| 1970-01-01T00:00:00.003 | 中国农业银行 |
+-------------------------+--------------+
SELECT ts, msg FROM t_fulltext_bloom_tokens WHERE msg LIKE 'hello\_%' ORDER BY ts;
+-------------------------+-------------+
| ts | msg |
+-------------------------+-------------+
| 1970-01-01T00:00:00.001 | hello_world |
+-------------------------+-------------+
INSERT INTO t_fulltext_bloom_tokens VALUES
(4, 'hello_world again'),
(5, '农业 error');
Affected Rows: 2
ADMIN FLUSH_TABLE('t_fulltext_bloom_tokens');
+----------------------------------------------+
| ADMIN FLUSH_TABLE('t_fulltext_bloom_tokens') |
+----------------------------------------------+
| 0 |
+----------------------------------------------+
-- Old and new SSTs: one bloom blob of each version.
SELECT meta_json LIKE '%"bloom_version":2%' AS is_v2, count(*) AS ssts
FROM information_schema.ssts_index_meta
WHERE table_id = (SELECT table_id FROM information_schema.tables WHERE table_name = 't_fulltext_bloom_tokens')
AND index_type = 'fulltext_bloom'
GROUP BY is_v2
ORDER BY is_v2;
+-------+------+
| is_v2 | ssts |
+-------+------+
| false | 1 |
| true | 1 |
+-------+------+
SELECT ts, msg FROM t_fulltext_bloom_tokens WHERE msg @@ 'world' ORDER BY ts;
+-------------------------+-------------------+
| ts | msg |
+-------------------------+-------------------+
| 1970-01-01T00:00:00.001 | hello_world |
| 1970-01-01T00:00:00.004 | hello_world again |
+-------------------------+-------------------+
SELECT ts, msg FROM t_fulltext_bloom_tokens WHERE msg @@ 'error' ORDER BY ts;
+-------------------------+---------------+
| ts | msg |
+-------------------------+---------------+
| 1970-01-01T00:00:00.002 | 错误error日志 |
| 1970-01-01T00:00:00.005 | 农业 error |
+-------------------------+---------------+
SELECT ts, msg FROM t_fulltext_bloom_tokens WHERE msg @@ '农业' ORDER BY ts;
+-------------------------+--------------+
| ts | msg |
+-------------------------+--------------+
| 1970-01-01T00:00:00.003 | 中国农业银行 |
| 1970-01-01T00:00:00.005 | 农业 error |
+-------------------------+--------------+
SELECT ts, msg FROM t_fulltext_bloom_tokens WHERE msg LIKE 'hello\_%' ORDER BY ts;
+-------------------------+-------------------+
| ts | msg |
+-------------------------+-------------------+
| 1970-01-01T00:00:00.001 | hello_world |
| 1970-01-01T00:00:00.004 | hello_world again |
+-------------------------+-------------------+
-- Rebuilds the old SST's index with the current bloom version.
ADMIN BUILD_INDEX('t_fulltext_bloom_tokens');
+----------------------------------------------+
| ADMIN BUILD_INDEX('t_fulltext_bloom_tokens') |
+----------------------------------------------+
| 0 |
+----------------------------------------------+
-- SQLNESS SLEEP 2s
SELECT meta_json LIKE '%"bloom_version":2%' AS is_v2, count(*) AS ssts
FROM information_schema.ssts_index_meta
WHERE table_id = (SELECT table_id FROM information_schema.tables WHERE table_name = 't_fulltext_bloom_tokens')
AND index_type = 'fulltext_bloom'
GROUP BY is_v2
ORDER BY is_v2;
+-------+------+
| is_v2 | ssts |
+-------+------+
| true | 2 |
+-------+------+
SELECT ts, msg FROM t_fulltext_bloom_tokens WHERE msg @@ 'world' ORDER BY ts;
+-------------------------+-------------------+
| ts | msg |
+-------------------------+-------------------+
| 1970-01-01T00:00:00.001 | hello_world |
| 1970-01-01T00:00:00.004 | hello_world again |
+-------------------------+-------------------+
DROP TABLE t_fulltext_bloom_tokens;
Affected Rows: 0
-- Two SSTs written by the old binary.
SELECT meta_json LIKE '%"bloom_version":2%' AS is_v2, count(*) AS ssts
FROM information_schema.ssts_index_meta
WHERE table_id = (SELECT table_id FROM information_schema.tables WHERE table_name = 't_fulltext_bloom_compact')
AND index_type = 'fulltext_bloom'
GROUP BY is_v2
ORDER BY is_v2;
+-------+------+
| is_v2 | ssts |
+-------+------+
| false | 2 |
+-------+------+
SELECT ts, msg FROM t_fulltext_bloom_compact WHERE msg @@ 'world' ORDER BY ts;
+-------------------------+-------------+
| ts | msg |
+-------------------------+-------------+
| 1970-01-01T00:00:00.001 | hello_world |
+-------------------------+-------------+
-- Merges both SSTs into one with a current bloom blob.
ADMIN COMPACT_TABLE('t_fulltext_bloom_compact', 'strict_window', 'window=3600');
+---------------------------------------------------------------------------------+
| ADMIN COMPACT_TABLE('t_fulltext_bloom_compact', 'strict_window', 'window=3600') |
+---------------------------------------------------------------------------------+
| 0 |
+---------------------------------------------------------------------------------+
SELECT meta_json LIKE '%"bloom_version":2%' AS is_v2, count(*) AS ssts
FROM information_schema.ssts_index_meta
WHERE table_id = (SELECT table_id FROM information_schema.tables WHERE table_name = 't_fulltext_bloom_compact')
AND index_type = 'fulltext_bloom'
GROUP BY is_v2
ORDER BY is_v2;
+-------+------+
| is_v2 | ssts |
+-------+------+
| true | 1 |
+-------+------+
SELECT ts, msg FROM t_fulltext_bloom_compact WHERE msg @@ 'world' ORDER BY ts;
+-------------------------+-------------+
| ts | msg |
+-------------------------+-------------+
| 1970-01-01T00:00:00.001 | hello_world |
+-------------------------+-------------+
SELECT ts, msg FROM t_fulltext_bloom_compact WHERE msg @@ '农业' ORDER BY ts;
+-------------------------+--------------+
| ts | msg |
+-------------------------+--------------+
| 1970-01-01T00:00:00.003 | 中国农业银行 |
+-------------------------+--------------+
DROP TABLE t_fulltext_bloom_compact;
Affected Rows: 0
@@ -0,0 +1,71 @@
-- Only the SST written by the old binary.
SELECT ts, msg FROM t_fulltext_bloom_tokens WHERE msg @@ 'world' ORDER BY ts;
SELECT ts, msg FROM t_fulltext_bloom_tokens WHERE msg @@ 'error' ORDER BY ts;
SELECT ts, msg FROM t_fulltext_bloom_tokens WHERE msg @@ '农业' ORDER BY ts;
SELECT ts, msg FROM t_fulltext_bloom_tokens WHERE msg LIKE 'hello\_%' ORDER BY ts;
INSERT INTO t_fulltext_bloom_tokens VALUES
(4, 'hello_world again'),
(5, '农业 error');
ADMIN FLUSH_TABLE('t_fulltext_bloom_tokens');
-- Old and new SSTs: one bloom blob of each version.
SELECT meta_json LIKE '%"bloom_version":2%' AS is_v2, count(*) AS ssts
FROM information_schema.ssts_index_meta
WHERE table_id = (SELECT table_id FROM information_schema.tables WHERE table_name = 't_fulltext_bloom_tokens')
AND index_type = 'fulltext_bloom'
GROUP BY is_v2
ORDER BY is_v2;
SELECT ts, msg FROM t_fulltext_bloom_tokens WHERE msg @@ 'world' ORDER BY ts;
SELECT ts, msg FROM t_fulltext_bloom_tokens WHERE msg @@ 'error' ORDER BY ts;
SELECT ts, msg FROM t_fulltext_bloom_tokens WHERE msg @@ '农业' ORDER BY ts;
SELECT ts, msg FROM t_fulltext_bloom_tokens WHERE msg LIKE 'hello\_%' ORDER BY ts;
-- Rebuilds the old SST's index with the current bloom version.
ADMIN BUILD_INDEX('t_fulltext_bloom_tokens');
-- SQLNESS SLEEP 2s
SELECT meta_json LIKE '%"bloom_version":2%' AS is_v2, count(*) AS ssts
FROM information_schema.ssts_index_meta
WHERE table_id = (SELECT table_id FROM information_schema.tables WHERE table_name = 't_fulltext_bloom_tokens')
AND index_type = 'fulltext_bloom'
GROUP BY is_v2
ORDER BY is_v2;
SELECT ts, msg FROM t_fulltext_bloom_tokens WHERE msg @@ 'world' ORDER BY ts;
DROP TABLE t_fulltext_bloom_tokens;
-- Two SSTs written by the old binary.
SELECT meta_json LIKE '%"bloom_version":2%' AS is_v2, count(*) AS ssts
FROM information_schema.ssts_index_meta
WHERE table_id = (SELECT table_id FROM information_schema.tables WHERE table_name = 't_fulltext_bloom_compact')
AND index_type = 'fulltext_bloom'
GROUP BY is_v2
ORDER BY is_v2;
SELECT ts, msg FROM t_fulltext_bloom_compact WHERE msg @@ 'world' ORDER BY ts;
-- Merges both SSTs into one with a current bloom blob.
ADMIN COMPACT_TABLE('t_fulltext_bloom_compact', 'strict_window', 'window=3600');
SELECT meta_json LIKE '%"bloom_version":2%' AS is_v2, count(*) AS ssts
FROM information_schema.ssts_index_meta
WHERE table_id = (SELECT table_id FROM information_schema.tables WHERE table_name = 't_fulltext_bloom_compact')
AND index_type = 'fulltext_bloom'
GROUP BY is_v2
ORDER BY is_v2;
SELECT ts, msg FROM t_fulltext_bloom_compact WHERE msg @@ 'world' ORDER BY ts;
SELECT ts, msg FROM t_fulltext_bloom_compact WHERE msg @@ '农业' ORDER BY ts;
DROP TABLE t_fulltext_bloom_compact;