Skip to content

[improvement](be) Rewrite the tokens of a batch in place in the one-to-one filters - #70269

Draft
airborne12 wants to merge 13 commits into
apache:masterfrom
airborne12:snii-analysis-t10-batch-filters
Draft

airborne12 wants to merge 13 commits into
apache:masterfrom
airborne12:snii-analysis-t10-batch-filters

Conversation

@airborne12

@airborne12 airborne12 commented Nov 5, 2026 •

Copy link
Copy Markdown
Member

What problem does this PR solve?

Issue Number: None

Related PR: #70268 (depends on)

Problem Summary: Let lowercase, icu_normalizer and asciifolding rewrite a token batch in place instead of copying every term: custom analyzers with these filters get 1.7x to 2.2x faster, with the same tokens.

Release note

None

Check List (For Author)

  • Test

    • Regression test
    • Unit Test
    • Manual test (add detailed scripts or steps below)
    • No need to test or manual test. Explain why:
      • This is a refactor/code format and no logic has been changed.
      • Previous test can cover this change.
      • No code files have been changed.
      • Other reason
  • Behavior changed:

    • No.
    • Yes.
  • Does this need documentation?

    • No.
    • Yes.

Check List (For Reviewer who merge this PR)

  • Confirm the release note
  • Confirm test cases
  • Confirm document
  • Add branch pick label

🤖 Generated with Claude Code

airborne12 and others added 13 commits November 3, 2026 12:27
…kens

### What problem does this PR solve?

Issue Number: None

Related PR: None

Problem Summary: The SNII text analysis is moving off CLucene one analyzer at a
time, and every step has to show two things against the code before it: that it
is faster, and that every row still gives the same tokens. There was no tool for
either.

benchmark_analyzer.hpp measures every builtin parser and a few custom chains on
the path the SNII writer takes for a row (a new stream per row) and with one
stream across rows, over corpora given as `.rows` files. With
ANALYZER_BENCH_DIGEST set it registers only the benchmark "digest", which writes
one line per analyzer, corpus and row: the number of terms and a digest of their
bytes and positions. Diffing the files of two binaries names every row whose
tokens changed, on real text and with the real kuromoji dictionary, which the
unit tests cannot hold.

analyzer_ab.py runs two binaries in alternating rounds and reports the median of
the per-round throughput ratios, because the host load moves a single
measurement by 10 to 20 percent. analyzer_corpus.py builds the corpora; the
Chinese and Japanese ones are cut from the Wikipedia CirrusSearch dumps of
2025-12-29, so the same date gives the same corpus. analyzer_benchmark.md lists
the steps.

The analysis rewrite is measured against this commit, branch
snii-analysis-bench-baseline.

### Release note

None

### Check List (For Author)

- Test: Manual test
    - BUILD_TYPE=RELEASE DORIS_BENCHMARK_HEADER=benchmark_analyzer.hpp
      ./build.sh --benchmark builds benchmark_test.
    - The digest mode over six corpora of 8 MB (four mixed ones, zh_wiki and
      ja_wiki) writes 5,240,060 lines in 67 s. A build with the separate
      dynamic_cast fix of the tokenizer base class, which keeps every token,
      gives a byte-identical file.
    - analyzer_ab.py, five rounds: that fix measures 1.84 on basic and 1.64 on
      custom standard + lowercase. The A/A run stayed within 0.94 to 1.08 per
      analyzer on a host at load 140 to 280, more than the 5% the method
      allows, so finer comparisons need a quieter host.
- Behavior changed: No
- Does this need documentation: No

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…being frozen

### What problem does this PR solve?

Issue Number: None

Related PR: None

Problem Summary: Freezing the output of every text analyzer, in the next commit,
ran into three bugs that no frozen digest can hold.

1. The word_delimiter filter threw std::out_of_range for a token holding a NUL
   byte when catenate_words, catenate_numbers or catenate_all was set.
   WordDelimiterConcatenation::append called std::string::append(const char*,
   pos, n), which resolves to the string_view overload and measures the text up
   to its first NUL, so for "foo\0bar" the second part started past the end.
   The SNII and V1/V2 writers catch only CLuceneError and doris::Exception, so
   the error left the writer. The part is now appended from its offset.
2. IKAnalyzer::tokenStream created its tokenizer with new and then called
   reset(), which throws when a value starts with a character cut short, such as
   "\xE4\xB8". The tokenizer and its buffers, about 100 KB, leaked once per such
   value, on the query path and on the SNII write path, which build a stream per
   value. A unique_ptr now owns it until reset() returns.
3. The IK dictionary was loaded under std::call_once, which is used up even when
   loading fails. After one failure, for example a dictionary path that is fixed
   later, every IK analyzer kept failing until the BE restarted, and in the unit
   tests the result of the IK tests depended on whether a test with a bad path
   ran first. Dictionary::initial now publishes the dictionary only after it has
   loaded, and the next call tries again. reload() and getSingleton(cfg), which
   only existed to recover from that state, are removed.

### Release note

Fix the word_delimiter filter failing on values with a NUL byte when a catenate
option is set, and a memory leak of the IK analyzer on values it cannot decode.

### Check List (For Author)

- Test: Unit Test
    - New: WordDelimiterFilterTest.CatenatePartsAcrossNul, and
      IKTokenizerTest.TestUndecodableValueThrowsWithoutLeakingTheStream, whose
      leak LeakSanitizer reports when the binary exits. Changed:
      IKTokenizerTest.TestDictionaryExceptionHandling checks that a failed load
      is retried and that the first loaded configuration stays.
    - ./run-be-ut.sh --run --filter='WordDelimiterFilterTest*:IKTokenizerTest*'
      passes on ASAN, also three times with GTEST_SHUFFLE=1 together with the
      golden tests of the next commit.
- Behavior changed: Yes. A value with a NUL byte under word_delimiter with a
  catenate option is indexed instead of failing; a failed IK dictionary load is
  retried by the next IK analyzer instead of failing until a restart.
- Does this need documentation: No

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
### What problem does this PR solve?

Issue Number: None

Related PR: None

Problem Summary: The SNII text analysis is about to be rewritten without
CLucene. A change in what an analyzer emits raises no error anywhere: queries
just stop finding the rows indexed with the old tokens. So the output is frozen
before any implementation changes, and every later step has to keep it.

AnalysisGolden runs 155 analyzer configurations over 13 corpus families. The
configurations are written the way users write them, as index properties and
analyzer, normalizer, tokenizer and filter policies, so the matrix does not move
when the implementation behind it does: every builtin parser with its lower_case
and stopwords values, every custom tokenizer, token filter and char filter with
its settings (pairwise covering arrays for the 16 pinyin and 9 word_delimiter
switches), the index's own char filter, normalizers, and the normalize() of
prefix and wildcard queries. The families aim at known risks: token windows at
255 bytes and at 255 UTF-16 code units, batch sizes of 255 to 513 tokens,
characters across 4096-byte chunks, invalid UTF-8, sampled jieba and IK
dictionary words, unknown Han strings, every pair of word break classes, sampled
BMP code points, NUL bytes and long lines. Each configuration runs with a new
stream per line, as queries and the SNII writer read values, and with one stream
across lines, as the V1/V2 writers do. Both must give the same XXH3 digest, the
one frozen in analysis_golden.txt; on the current code no configuration
disagrees. ANALYSIS_GOLDEN_FULL=1 scans every code point against
analysis_golden_full.txt. A test checks that every component AnalysisFactoryMgr
registers is in some configuration, and that the ICU analyzers give the same
output with Turkish as the default locale. The kuromoji configurations use a
small dictionary the test builds, because the real one is not in the
repository. The 4030 jobs run on up to 16 threads.

AnalysisDifferential compares the production english, standard, unicode,
custom standard and chinese analyzers with their old implementations, which stay
linked into the unit tests, on the same families and on one second of random
input, and shows that an injected difference is caught.

AnalysisWriteGolden freezes what the writers make of the tokens: the bytes of
the SNII file, and, because V1/V2 files carry timestamps, the terms with their
documents and positions read back, for chinese, ik, icu and a custom chain.
AnnWriteGolden freezes every sub-file of HNSW, IVF and IVF_ON_DISK indexes in V2
and in SNII, built on one OpenMP thread from vectors whose distances are exact
in float, and written twice to show the bytes repeat.

Lines of the digest files are only added: ANALYSIS_GOLDEN_REGEN=<commit> appends
the missing ones and never rewrites one; ANALYSIS_GOLDEN_DUMP writes the token
streams to diff two binaries.

check-header-deps.py gets two rules for the code moving off CLucene (snii/,
analysis/, query/, common/ and ann/ under storage/index): no file there, sources
included, and no project header it reaches may include a CLucene header, and
the code there may not name a CLucene symbol once comments and strings are
stripped, because the PCH makes CLucene visible without an include. What still
breaks the rules is listed by the file that breaks it, 31 and 51 entries; a
missing or stale entry fails the gate, so the lists only shrink.

### Release note

None

### Check List (For Author)

- Test: Unit Test
    - ./run-be-ut.sh --run --filter='*AnalysisGolden*:*AnalysisDifferential*:*AnalysisWriteGolden*:*AnnWriteGolden*:SniiWriterGoldenBytes*:SniiGoldenCorpus*'
      passes on ASAN. The default set takes 33 s of wall time on 16 threads
      for 515 s of work; ANALYSIS_GOLDEN_FULL=1 takes 920 s.
    - GTEST_SHUFFLE=1 with '*AnalysisGolden*:*IK*' passes three times, seeds
      56420, 13481 and 56363.
    - The same tests pass on a RELEASE build, and both builds write
      byte-identical digests (ANALYSIS_GOLDEN_WRITE, 2065 lines). The ANN
      vectors have small integer coordinates for that: with fractional ones
      the two builds wrote different HNSW graphs, because -O0 and -O3 round
      faiss's distance sums differently.
    - build-support/run-clang-tidy.sh --base origin/master reports no warning
      on the changed lines.
    - Mutations, each built, run and reverted: SimpleTokenizer<char> cutting
      english tokens at 254 bytes instead of 255 fails the 4 english
      configurations; basic splitting tokens over 255 bytes instead of
      truncating them fails the 6 basic ones; IK lexemes ending one character
      later fail the 10 IK ones; jieba without its lowercasing fails the 2
      chinese ones with lower_case=true. Removing only the first-byte condition
      of that lowercasing changes no digest: no token of the shipped
      dictionaries starts with a non-ASCII byte and holds an ASCII letter.
    - build-support/tests/test-build-hygiene-header-deps.sh passes, with new
      cases for an angle include two hops from a root, a root source file,
      stale entries in both tables, and CLucene words in comments and strings.
- Behavior changed: No
- Does this need documentation: No

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…wo CLucene adapters

### What problem does this PR solve?

Issue Number: None

Related PR: None

Problem Summary: The SNII text analysis is moving off CLucene. Today every
consumer (the SNII writer, the query side, the V1/V2 writers) reaches an
analyzer through lucene::analysis::Analyzer, TokenStream and Token, one virtual
call per token, so no analyzer can leave CLucene before every consumer does,
and the other way round. This adds the interface both sides move to, with no
production caller yet, and the two adapters that let consumers and analyzers
move one at a time.

The interface is in storage/index/inverted/analysis/ and includes no CLucene
header; the gate adds no exception for it.
- TokenBatch: the tokens of one next_batch() call, each a term view and a
  position increment. A term points into the value, into memory the stream
  keeps, or into the batch's arena, which never moves a term it handed out.
  clear() keeps the token array and the arena's largest block;
  release_oversized() frees everything once the batch keeps more than 64 KiB,
  so one huge value does not pin its memory until the segment ends.
- TextTokenStream, with TextTokenizer and TextTokenFilter: reset(text) binds a
  value, next_batch() hands out up to 256 tokens and returns false, with an
  empty batch, only at the end, and reserved_bytes() reports what survives a
  value.
- PerTokenTokenizer and PerTokenFilter: bases for components that make one
  token at a time; the base copies each term into the batch. A filter reads its
  input through a private batch, so it may hold an input token across batches,
  as word_delimiter holds a token while it hands out its parts.
- TextAnalyzer: immutable after it is built, shared by threads, makes streams.
The 64 KiB high water mark and release_oversized_scratch() move from
token_stream.h to a new scratch.h, so the batch uses them without CLucene.

The adapters are in storage/index/inverted/clucene/:
- LuceneBackedTextAnalyzer puts a CLucene analyzer behind TextAnalyzer, for the
  consumers that switch before their analyzers do. Each value gets a new reader
  through the index's char filter and a new CLucene stream, as a query reads a
  value today (the SNII writer re-initializes one reader instead); terms are
  copied into the batch, and a CLuceneError comes out as a doris::Exception. It
  goes once every analyzer is native.
- LuceneAnalyzerBridge puts a TextAnalyzer behind lucene::analysis::Analyzer,
  for the V1/V2 writers. reusableTokenStream() rebinds one stream per scalar
  value, tokenStream() makes an owned stream per array element, reset() starts
  the value as SDocumentsWriter::invertField() expects, and terms go out with
  setTextNoCopy(), empty ones included, because those writers give an empty
  term a position.

One difference turned up and is pinned by a test: on the V1/V2 array path,
ICUAnalyzer::tokenStream() resets the stream and invertField() resets it
again, and ICUTokenizer, which reads its reader in reset(), finds it consumed:
array elements of an icu index are indexed as nothing. The bridge starts each
element once and indexes them. No table can have such an index, because the FE
accepts no parser and no analyzer on an array column
(InvertedIndexUtil.checkInvertedIndexParser).

### Release note

None

### Check List (For Author)

- Test: Unit Test
    - ./run-be-ut.sh --run --filter='TokenBatchTest.*:PerTokenStreamTest.*:LuceneBackedTextAnalyzerTest.*:LuceneAnalyzerBridgeTest.*:LuceneBridgeWriterTest.*:AnalysisGolden*:AnalysisDifferential*:AnalysisWriteGolden*:AnnWriteGolden*:SniiWriterGoldenBytes*:SniiGoldenCorpus*:InvertedIndexWriterTest.*'
      passes on ASAN, with no leak report.
    - TokenBatchTest: 10000 copied terms over many arena blocks keep their
      bytes; clear() keeps the token array and the largest block, and a batch
      that fits allocates nothing; release_oversized() leaves at most 64 KiB
      after 10000 tokens or a 1 MiB term, and keeps an ordinary batch.
    - PerTokenStreamTest: batches of at most 256 tokens; a filter making two
      tokens out of one; a filter dropping every token returns false, never an
      empty batch; a filter holding a 300-character token across two batches;
      next_token() is not called after it returned false; reset() in the middle
      of a value does not replay it; reserved_bytes() adds up the chain and
      reset() releases an oversized input batch.
    - expect_text_stream_contract(), a checker later components reuse: false
      only with an empty batch and again after, no token for an empty value with
      a null or a non-null pointer, a reused stream equal to a new one, and a
      reset after the first batch. It passes on the test streams, on a scripted
      CLucene analyzer and on the standard analyzer through
      LuceneBackedTextAnalyzer.
    - AnalysisGolden.LuceneBackedTextAnalyzerMatchesTheFrozenDigests: all 122
      analyzer configurations through LuceneBackedTextAnalyzer, with a new
      stream per line and with one stream for all lines, match
      analysis_golden.txt (3172 jobs).
    - AnalysisGolden.EightThreadsShareOneTextAnalyzer: eight threads share each
      configuration's analyzer on mixed_zh_en and invalid_utf8, half with a new
      stream per line, and all match the frozen digests.
    - AnalysisWriteGolden.LuceneAnalyzerBridgeWritesTheSameV2Index: the
      production V2 writer with its analyzer behind the bridge writes the
      frozen string-column digests of chinese, ik, icu and the custom chain,
      and the same array-column index as the analyzer itself, except the icu
      case above.
    - LuceneBridgeWriterTest: a doris::Exception from reset() or next_batch()
      reaches add_values() and add_array_values() as
      INVERTED_INDEX_ANALYZER_ERROR through the CLucene frames.
    - analysis_golden.txt and ann_write_golden.txt unchanged.
      build-support/check-build-hygiene.sh passes with the same 31 + 51
      exceptions. build-support/run-clang-tidy.sh reports no warning on the
      changed lines.
- Behavior changed: No
- Does this need documentation: No

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…yzer interface

### What problem does this PR solve?

Issue Number: None

Related PR: None

Problem Summary: The SNII text analysis is moving off CLucene. The previous
change added the CLucene-free TextAnalyzer interface and two adapters with no
production caller. This one moves every consumer onto the interface, so the
analyzers can follow one at a time. The analyzers themselves still run on
CLucene behind LuceneBackedTextAnalyzer, except the gram family, which moves now
because the SNII writer depends on its stream.

- AnalyzerProvider::get_analyzer(), InvertedIndexAnalyzerCtx::analyzer,
  InvertedIndexAnalyzer::create_analyzer() and the IndexPolicyMgr getters hand
  out a TextAnalyzerPtr. index_policy_mgr.h and inverted_index_parser.h no longer
  name a CLucene type, and no AnalyzerPtr is left outside the analysis layer.
- The index-level char filter (char_filter_type and friends) moves from the
  readers each consumer built (create_reader) into the analyzer:
  create_analyzer() and create_analyzer_provider() put it in front of whatever
  they build, builtin, custom or normalizer, so every consumer reads every value
  through it exactly as before. IndexPolicyMgr's unused outer_char_filter_map
  parameter goes.
- get_analyse_result() has one loop: a reusable form over a stream and a batch,
  and a one-shot form over an analyzer. analyze() and normalize() use it.
- The SNII writer keeps one stream and one batch for all its rows, made in
  init() and destroyed by finish() and close_on_error(). The two lanes
  (_plain_lane_token_stream, _finish_plain_lane_row) and _char_string_reader go;
  the memory report covers the stream and the batch of every analyzer
  (_report_analysis_capacity), not only the gram tokenizer's buffers. The solved
  gram density is set directly on the stream, which replaces
  _pending_density_permille.
- GramTokenizer is a native TextTokenizer that hands out its grams as views, and
  GramTextAnalyzer is the analyzer of a gram-family policy, which the FE allows
  no filter. The CLucene version stays as LuceneGramTokenizer for a CLucene
  chain, which only a policy created before the FE check (a gram tokenizer plus
  a filter) can still build. GramExtractor and GramTokenizer release a buffer
  above 64 KiB at the next row instead of keeping it until the segment ends.
- MATCH without an index and tokenize() tokenize a block with one stream and
  one batch.
- The V1/V2 writer reaches its analyzer through LuceneAnalyzerBridge and hands
  it plain readers.
- create_builtin_analyzer() and normalize() turn a CLuceneError, such as a
  missing dictionary file, into an INVERTED_INDEX_ANALYZER_ERROR exception, so
  the CLuceneError catches of analyze(), the SNII writer, the scoring collector,
  the search functions and tokenize() go. function_search.cpp keeps the one
  around the whole evaluation, which covers index reads too.
- The analyzer benchmark drives TextAnalyzers built through the production
  entry points: "row/" with a stream per row, "reuse/" with one stream.

### Release note

None

### Check List (For Author)

- Test: Unit Test, Regression test, Manual test
    - ./run-be-ut.sh --run with the analysis, golden, gram, SNII, MATCH,
      tokenize, search, inverted index reader/writer, policy and collector
      suites passes on ASAN (3945 tests).
    - analysis_golden.txt only gains lines: four frozen write/v2-array digests
      of an ARRAY<STRING> column on V2, added at 5271950, whose bridge
      path wrote them first; there the icu one already read 282 postings,
      digest 118e86a496cf6071, as here. The other digests are unchanged.
    - The benchmark's digest mode over all six corpora gives the same tokens
      as the T1 baseline binary in all 5,240,060 rows; row/ and reuse/ run.
    - New tests: the index char filter is part of create_analyzer() and
      create_analyzer_provider(); a missing chinese dictionary fails SNII
      init(), analyze() and tokenize() with INVERTED_INDEX_ANALYZER_ERROR;
      after a 2 MiB row the SNII batch is under 64 KiB and the writer charges
      exactly the stream's and the batch's capacity; finish() and
      close_on_error() destroy the stream; the solved density is on the
      writer's GramTokenizer; the native GramTokenizer gives the CLucene
      tokenizer's grams and releases a long row's scratch at the next row.
    - Fixed while widening the run: the golden write tests and the adapter
      tests now set up ExecEnv's tmp file directory themselves
      (test::ensure_tmp_file_dirs()), since some earlier test may have
      cleared it.
    - ./run-regression-test.sh --run -d inverted_index_p0 on an ASAN cluster
      of this build: 232 of 252 suites pass on the first run. The other 20
      need debug points (18) or room under the FE's limit of 100 index
      policies, which the earlier suites fill (2); all 20 pass on a fresh
      cluster with enable_debug_points = true. The BE logged no sanitizer
      error.
    - build-support/run-clang-tidy.sh reports no warning on the changed
      lines. build-support/check-build-hygiene.sh passes, with the CLucene
      exception tables down from 31 + 51 to 29 + 46 entries: the SNII writer,
      the compaction eligibility check, analyzer_provider.h and
      inverted_index_parser.h no longer reach or name CLucene.
- Behavior changed: No. Two differences are only reachable outside SQL or on
  an error path: a V1/V2 ARRAY index with the icu parser now indexes its
  elements (before, ICUAnalyzer::tokenStream() and invertField() both reset the
  stream and the second reset found the reader consumed), but the FE accepts no
  parser on an array column; and a CLuceneError raised while tokenize() or MATCH
  without an index analyzes a value now reports INVERTED_INDEX_ANALYZER_ERROR.
- Does this need documentation: No

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ream interface

### What problem does this PR solve?

Issue Number: None

Related PR: None

Problem Summary: The SNII text analysis is moving off CLucene. The two previous
changes added the TextAnalyzer interface and moved every consumer onto it, with
the analyzers still CLucene ones behind LuceneBackedTextAnalyzer. This change
moves the components themselves, all in one step because the two sets of base
classes cannot be mixed in one chain: the 11 registered tokenizers (keyword,
empty, standard, ngram, edge_ngram, char_group, basic, icu, pinyin, ik_smart,
ik_max_word) and kuromoji, the 6 token filters, the 3 char filters, their
factories and the analyzers built from them. english, standard, unicode and
chinese are CLucene analyzers, not Doris components, and stay behind
LuceneBackedTextAnalyzer until their own tasks.

- TokenizerFactory::create() const returns a TextTokenizer,
  TokenFilterFactory::create() const takes and returns a TextTokenStream, and
  CharFilterFactory::create() const returns a TextCharFilter, a text-to-text
  function run in reset() instead of a CLucene Reader.
- ChainAnalyzer is char filters (CharFilteredStream), a tokenizer and token
  filters. A custom analyzer and a custom normalizer are ChainAnalyzers, and
  create_builtin_analyzer() builds the basic, icu, ik and kuromoji parsers as
  preset chains with the same lowercasing and dictionaries as before. The index
  char filter is a CharFilteredAnalyzer around whatever the index analyzes with;
  create_reader() is gone and filter_characters() runs the native char_replace.
- Components that make one token at a time derive from PerTokenTokenizer and
  PerTokenFilter and keep their state machines. Terms that stay valid until the
  next value, views of it or per-value storage, go into the batch without a copy.
- The source byte offsets added with apache#67918 are removed: nothing outside the
  analysis layer read them. Their only effect on terms, strict handling of
  invalid UTF-8 below a pinyin filter with ignore_pinyin_offset=false, stays as
  set_strict_utf8(): icu, ngram and edge_ngram throw, ik folds rune by rune.
  PositionCapability had no reader either and goes.
- Every component reports what it keeps from one value to the next
  (reserved_bytes()) and releases buffers above 64 KiB at the next value. basic,
  ik, pinyin and kuromoji kept a long value's buffers until the stream died,
  which was harmless with a stream per value and is not with the reused stream
  of the SNII writer.
- The IK core reads the value through a TextCursor (the same 4096-byte window and
  read semantics), throws doris::Exception instead of CLuceneError, and decodes
  runes with analysis/common/rune.h, jieba's rules byte for byte, instead of the
  CLucene submodule's Unicode.hpp. StandardTokenizerImpl reads through the same
  cursor. The ik tokenizer factory builds its Configuration once instead of for
  every stream.
- Deleted: analysis/reader.h (ReaderPtr moves to clucene/lucene_reader.h for the
  V1/V2 writer), token_stream.h, DorisTokenizer, DorisTokenFilter,
  DorisCharFilter, LuceneGramTokenizer, and the BasicAnalyzer, ICUAnalyzer,
  IKAnalyzer and KuromojiAnalyzer classes. match.cpp had CLucene's _CLFINALLY
  only through the old headers; it uses Defer.

### Release note

None

### Check List (For Author)

- Test: Unit Test, Regression test, Manual test
    - ./run-be-ut.sh --run with T3's suites plus the component, query engine,
      kuromoji, ik and icu suites passes on ASAN: 4121 tests, 6 skipped.
    - The new component_stream_test runs every tokenizer, filter and char
      filter through the stream contract (kuromoji in kuromoji_viterbi_test,
      on the dictionary that test builds); checks that after a 2 MiB value and
      a short one each keeps at most 1 MiB, exactly what its members hold (read
      with -fno-access-control); checks strict UTF-8 (icu, ngram and edge_ngram
      throw, ik folds rune by rune, a pinyin filter with ignore_pinyin_offset
      false turns it on upstream, the other tokenizers ignore it); and checks
      that a value NFKC_CF folds to nothing makes no token.
    - analysis_golden.txt and ann_write_golden.txt are unchanged, and
      ANALYSIS_GOLDEN_FULL=1 (every code point through every configuration)
      passes. The benchmark's digest mode gives the same tokens as the T1
      baseline binary in all 5,240,060 rows of the six corpora and fourteen
      analyzers, kuromoji on the real dictionary included.
    - Throughput with be/benchmark/analyzer_ab.py: five alternating rounds,
      the median of the per-round ratios on each of six corpora and their
      geometric mean; an A/A of the T3 binary stays within 0.98 to 1.03.
      Against T3, reuse/ (one stream for all values) and row/ (a stream per
      value): basic 2.49 and 2.01, custom basic+lowercase 2.48 and 2.00,
      keyword+lowercase 1.88 and 1.54, standard+asciifolding+lowercase 1.57
      and 1.24, standard+lowercase 1.76 and 1.51,
      standard+word_delimiter+lowercase 2.10 and 1.70, icu 1.35 and 1.18,
      ik_max_word 1.17 and 1.02, ik_smart 1.17 and 1.01, kuromoji 1.01 and
      1.01. Against the T1 baseline binary: basic 1.82 and 1.73, custom basic
      1.83 and 1.72, keyword 1.15 and 1.40, standard+asciifolding 1.23 and
      1.09, standard 1.41 and 1.34, standard+word_delimiter 1.79 and 1.57,
      icu 1.14 and 1.12, ik_max_word and ik_smart 0.99 and 0.99, kuromoji 0.99
      and 0.99; the lowest single corpus is 0.96 (ik on agent_input).
    - ./run-regression-test.sh --run -d inverted_index_p0 on an ASAN cluster
      of this build with debug points: 249 of 252 suites pass. Two hit the
      FE's limit of 100 index policies, which the earlier suites fill, and
      test_drop_index_with_format_v2 lists the index file as soon as the FE
      alter job finishes, while the BE received the drop-index task 23 and 28
      seconds later in two runs. All three pass on a fresh cluster, and the BE
      logged no sanitizer error.
    - build-support/check-build-hygiene.sh passes with the CLucene exception
      tables down from 29 + 46 to 13 + 12 entries; analyzer.cpp is the only
      file of analysis/ left in them. build-support/run-clang-tidy.sh reports
      no warning on the changed lines.
    - The rewritten tests drop only assertions on deleted features: token
      offsets and source byte offsets (keyword 6, ngram 2, icu tokenizer 8,
      pinyin tokenizer 50, ik 2, lowercase 4, icu normalizer filter 7, icu
      normalizer char filter 7 macros, pinyin filter offsets in 27 tests and
      its provenance accessors in 10), the token type of lowercase (2,
      TokenRef has none), and char_filter_test.cpp, whose 3 assertions tested
      the reader methods of the deleted DorisCharFilter.
- Behavior changed: No. The terms and positions of every analyzer are unchanged
  (above). An analyzer error now comes from the component as a doris::Exception
  instead of through the CLuceneError conversion, with the same
  INVERTED_INDEX_ANALYZER_ERROR code and a slightly different message.
- Does this need documentation: No

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…lassifier

### What problem does this PR solve?

Issue Number: None

Related PR: None

Problem Summary: The english parser still ran CLucene's SimpleTokenizer<char>
behind LuceneBackedTextAnalyzer, and the basic tokenizer walked the value byte
by byte into a token list. Both now classify the value 64 bytes at a time and
cut it at the edges of the letter-and-digit bits.

- tokenizer/byte_classifier.h: classify_block() turns 64 bytes into a bitmap
  of ASCII letters and digits, a bitmap of the bytes that can start a
  multi-byte character and, when lowercasing, a copy with A-Z folded. It is a
  plain loop the compiler vectorizes on every target, the byte masks turned
  into bits by simd::bytes32_mask_to_bits32_mask as the Doris filters turn
  theirs. classify_tail() copies the last bytes through a stack buffer, so
  nothing past the value is read.
- simd::bytes32_mask_to_bits32_mask gets a NEON branch; aarch64 took its
  scalar loop. It compiles for aarch64 but has not run: no aarch64 host or
  emulator was at hand, so BitsTest still has to pass on an ARM machine.
- EnglishTokenizer replaces SimpleAnalyzer<char> for the english parser and an
  unknown one: the runs of ASCII letters and digits, cut every 255 bytes,
  lowercased into a copy that keeps the offsets of the value, or views of the
  value without lower_case. The chain has no stop words: SimpleAnalyzer<char>
  never read the ones set_stopwords() gave it.
- BasicTokenizer runs the same block loop and decodes a character only at a
  byte that can start one, the only bytes the scalar loop made a token of
  besides the runs; a run is cut to 255 bytes and the rest dropped, as before.
  It lowercases its own tokens, so the builtin basic parser drops its
  lowercase filter, which changed nothing else in them: the CJK characters it
  keeps have no case. With extra_chars it keeps the scalar loop.
- normalize() lowercases the text of a builtin parser with the same ASCII
  function the tokenizers use. The std::tolower it replaces changed only A-Z
  as well: the BE never calls setlocale.
- rowid_read_ahead.cpp includes storage/iterators.h for StorageReadOptions; it
  compiled only through another file of its unity batch, and the new source
  moved it to a batch without that file.

### Release note

None

### Check List (For Author)

- Test: Unit Test, Manual test
    - BE UT under ASAN: the analysis suites with the new ones, 3396 of 3402
      tests pass; the 6 skipped are the opt-in code point scan, four gram
      report tests and the vector width check, which runs in the build
      without AVX2 below. The wider set of analysis, index and search suites:
      4152 of 4159 pass, 7 skipped. The code point scan passes with
      ANALYSIS_GOLDEN_FULL=1. New tests:
      ByteClassifierTest (every byte value at every position, random blocks,
      every tail length, a guard page after the value), EnglishTokenizerTest
      (CLucene's SimpleAnalyzer<char> run beside it on random bytes, block
      edges, the 255-byte cuts, batches around 256 tokens, views without
      lowercase, a guard page), BasicTokenizerTest (the same against the
      scalar loop), BitsTest for the mask conversion, and ComponentStreamTest
      for the buffers both release after a long value. After the last change,
      push_chinese_char forced inline, the tokenizer, component and golden
      suites ran again: 60 pass, 2 skipped.
    - The analysis golden files are unchanged, and the benchmark's digest mode
      gives the same tokens as the T1 baseline binary in all 5,240,060 rows of
      the six corpora and fourteen analyzers.
    - -Rpass=loop-vectorize with the RELEASE flags: the classification loop
      is vectorized at width 32 with AVX2 and at width 16 with SSE4.2 alone,
      in all four instantiations (a block or a tail, with or without the
      lowered copy).
    - A second ASAN UT build with USE_AVX2=0 in its own CMAKE_BUILD_DIR
      (USE_AVX2=0 in its CMakeCache.txt) runs the same 3402 tests: 3397 pass,
      ByteClassifierTest.BuiltWithTheExpectedVectorWidth among them, which
      asserts that __AVX2__ is not defined there.
    - Throughput of the benchmark binary of this commit, with
      be/benchmark/analyzer_ab.py: five alternating rounds, the median of the
      per-round ratios on each of six corpora and their geometric mean.
      Against the T1 baseline binary, reuse/ (one stream for all values) and
      row/ (a stream per value): english 3.78 and 3.12, no corpus below 1.80
      (row/http_request); basic 7.42 and 7.74, no corpus below 4.27
      (reuse/ja_wiki); custom basic+lowercase 2.40 and 2.37. Against T4:
      english 4.81 and 3.91, basic 4.05 and 4.45, custom basic+lowercase 1.31
      and 1.38, and standard, which this change does not touch, 1.00 and
      0.99. push_chinese_char is forced inline: out of line, its call for
      every lead byte made basic 5-8% slower on ja_wiki and zh_wiki. A binary
      with the same machine code for both tokenizers at other alignments
      differs by up to 8% on one corpus of row/english (geomean 0.96), so a
      difference of that size is code layout.
    - check-build-hygiene.sh passes with the CLucene exception tables
      unchanged; clang-format 16 (run_clang_format.py) leaves the changed
      files as they are; run-clang-tidy.sh reports nothing on the changed
      lines.
- Behavior changed: No. Every token of english and basic is the same as
  before (above).
- Does this need documentation: No

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…able

### What problem does this PR solve?

Issue Number: None

Related PR: None

Problem Summary: The builtin standard and unicode parsers still ran CLucene's
standard95 StandardTokenizer behind LuceneBackedTextAnalyzer, and the custom
standard tokenizer ran the JFlex scanner StandardTokenizerImpl one code point
at a time through a buffer it refills and shifts. Both now run one flat table
of the same DFA over the value.

- tokenizer/standard/uax29_table.{h,cpp}: Uax29Table lays the scanner's ZZ_*
  tables out at first use: 59 states by 29 classes with one byte per
  transition, and a compact copy of the two-level character map, so that a
  step is one load and no table is copied by hand. CLucene's standard95
  scanner has the same tables entry for entry. longest_match() is the JFlex
  longest match, over a window its caller defines.
- tokenizer/utf8_validator.h: the UTF-8 rule of the builtin parsers, ported
  from CLucene's StringUtil::validate_utf8, which accepts overlong forms, and
  the decoder of CLucene's scanner that goes with it.
- Uax29Tokenizer, the builtin standard and unicode parsers: a value strict
  UTF-8 accepts (simdutf) passes, the lenient rule decides the others, and a
  value it refuses has no token, as before. The window (a token is scanned
  while it is shorter than 255 bytes), the drop of a longer token without a
  gap, the ASCII lowercase of WORD tokens and the 33 stop words, also only
  for WORD tokens, follow the old tokenizer. Lowercasing folds the whole value
  into a copy and scans the copy: A-Z and a-z are one class of the DFA and
  every accepting state reached through a letter is WORD, so the tokens are
  the same; Uax29TableTest checks both on the table. Stop words are looked up
  as packed integers after a length test, not in an unordered_set.
- StandardTokenizer, the custom standard tokenizer: a valid UTF-8 value runs
  through the same table with the scanner's window, max_token_length bytes
  from each token start: a character that does not fit ends the token, and
  one that does not fit an empty window ends the value, as the scanner's
  refill did. Its terms are views of the value. Any other value still runs
  through StandardTokenizerImpl, whose reaction to invalid bytes depends on
  where its buffer refills; those terms are copied, since a refill moves the
  buffer.
- analyzer.cpp builds the standard and unicode parsers on Uax29Tokenizer;
  lucene_builtin_analyzer stays for chinese.
- storage/utils.cpp no longer opens ErrorCode for whatever follows it in a
  unity batch: the new sources moved it into a batch with version_graph.cpp,
  and gen_cpp/types.pb.h, first included there, then saw two LIMIT_REACH.

### Release note

None

### Check List (For Author)

- Test: Unit Test, Manual test
    - BE UT under ASAN: the analysis, index and search suites with the T5 and
      T6 ones, 4177 of 4184 tests pass; the 7 skipped are opt-in reports and
      generators, the opt-in code point scan and the vector width check of the
      build without AVX2. The code point scan passes with
      ANALYSIS_GOLDEN_FULL=1. AnalysisDifferential, which runs both standard
      parsers beside standard95 and the custom tokenizer beside
      StandardTokenizerImpl for max_token_length 1, 2, 3, 4, 5, 16, 255, 256,
      1000 and 16383, on the golden families and on random lines, passes.
      New tests: Uax29TableTest (the table against ZZ_* entry by entry, the
      class of every code point, every first character accepted, the proof
      that lowercasing the whole value changes only WORD tokens),
      Utf8ValidatorTest (CLucene's validate_utf8 on every value of up to three
      bytes and on random bytes, the decoder against CLucene's
      string_to_wstring on every accepted sequence), Uax29TokenizerTest
      (standard95 beside it on random values for every lower_case and stop
      words setting, the 255-byte window and drop, overlong and invalid
      values, the 33 stop words with their prefixes, suffixes and case
      variants, batches around 256 tokens, a guard page) and
      CustomStandardTokenizerTest (the scanner beside it on random values for
      the same ten max_token_length values, the cut at the window, the end of
      the value at a character longer than the window, the copied terms of an
      invalid value with max_token_length=8, the views of a valid one).
      LuceneBackedTextAnalyzerTest now wraps CLucene's standard analyzer
      itself instead of the standard parser.
    - The analysis golden files are unchanged, and the benchmark's digest mode
      gives the same tokens as the T1 baseline binary in all 5,240,060 rows of
      the six corpora and fourteen analyzers, the builtin standard parser and
      three custom standard chains among them.
    - Throughput of the benchmark binary of this change, with
      be/benchmark/analyzer_ab.py: five alternating rounds, the median of the
      per-round ratios on each of six corpora and their geometric mean.
      Against the T1 baseline binary, reuse/ (one stream for all values) and
      row/ (a stream per value): the builtin standard parser 1.82 and 1.79, no
      corpus below 1.52 (prose_en); unicode is the same analyzer. The custom
      standard chains, whose lowercase filter is still per token: standard +
      lowercase 1.84 and 1.78, + asciifolding + lowercase 1.51 and 1.33,
      + word_delimiter + lowercase 2.06 and 1.78. Against T5: standard 2.13
      and 2.06 (it no longer goes through the CLucene adapter), the custom
      chains 1.13 to 1.32.
    - check-build-hygiene.sh passes with the CLucene exception tables
      unchanged (analyzer.cpp keeps CLucene for chinese); clang-format 16
      (run_clang_format.py) leaves the changed files as they are;
      run-clang-tidy.sh reports nothing on the changed lines.
- Behavior changed: No. Every token of standard, unicode and the custom
  standard tokenizer is the same as before (above).
- Does this need documentation: No

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
### What problem does this PR solve?

Issue Number: None

Related PR: None

Problem Summary: The chinese parser was the last builtin parser still on a
CLucene analyzer: LanguageBasedAnalyzer behind LuceneBackedTextAnalyzer, over
the copy of cppjieba in CLucene's contribs-lib. jieba's segmenters now live in
the BE as analysis/tokenizer/jieba/, ported without a change in how they cut,
and the parser runs them through a tokenizer of the new stream interface.
This commit only moves the code; a later one changes its data layout.

- analysis/tokenizer/jieba/: trie.h, dict_trie.h, mp_segment.h, hmm_model.h,
  hmm_segment.h, mix_segment.h, full_segment.h, pre_filter.h, segment_base.h,
  unicode.h and jieba.h from cppjieba, string_util.h from limonp, with
  cppjieba's and limonp's namespaces replaced by
  doris::segment_v2::inverted_index::jieba, so that doris_be_test links both
  copies without merging them. No namespace std, XLOG or XCHECK: a dictionary,
  user dictionary or model file that cannot be read, or has an illegal line,
  throws INVERTED_INDEX_ANALYZER_ERROR instead of aborting the process.
  Decoding comes from analysis/common/rune.h, which ik already uses. The
  keyword extractor, POS tagger, query segment, logging and std extensions are
  not brought over, so idf.utf8 is no longer read; it stays in the package.
  The asserts on dictionary data are DCHECKs, active in the same builds.
- JiebaTokenizer: copies the value, cuts it with MixSegment and the HMM in the
  coarse mode or with FullSegment in the fine mode, and hands the words out in
  batches, cut to 255 bytes. With lower_case, a word whose first byte is ASCII
  is lowercased where it lies when it is handed out, as the old tokenizer did
  to its buffer, so a word sees the lowercasing of the words before it. The
  fine mode's words overlap: it hands out a run of ASCII letters and digits
  whole and then the runs of letters and of digits in it, and jieba reads a
  stray continuation byte and the byte after it as one character, so a run
  can start with a byte that is not ASCII, keep its capitals, and be lowered
  by the next word. A word that still has a capital after its turn is
  therefore copied into the batch; the other terms are views of the copy of
  the value. The dictionaries are loaded once per directory, and a failed
  load is tried again by the next analyzer.
- analyzer.cpp builds the chinese parser on JiebaTokenizer, and with
  lucene_builtin_analyzer and the CLuceneError catch gone it includes no
  CLucene: it leaves the CLucene exception tables of check-header-deps.py.
- LICENSE.txt, dist/LICENSE-dist.txt, dist/licenses/LICENSE-cppjieba.txt,
  dist/licenses/LICENSE-limonp.txt and .licenserc.yaml carry the MIT terms of
  the derived files.

### Release note

None

### Check List (For Author)

- Test: Unit Test, Manual test
    - BE UT under ASAN: the analysis, index and search suites with the T5 to
      T7 ones, 4191 of 4198 tests pass; the 7 skipped are opt-in reports and
      generators, the opt-in code point scan and the vector width check of the
      build without AVX2. The code point scan passes with
      ANALYSIS_GOLDEN_FULL=1. JiebaTokenizerTest runs the parser beside
      LanguageBasedAnalyzer in both modes, with and without lowercase, on
      random values with invalid bytes, on every word of jieba.dict.utf8 and
      user.dict.utf8 (64 to a value, half with raised letters) and on
      mixed-case, unknown and undecodable words. A death test does the same on
      a dictionary of its own, with words such as 卡拉OK, Q版本 and words of a
      supplementary plane, in a process of its own because CLucene's jieba
      keeps the first dictionary it loads. Other new cases: 你好\xE4\xB8 makes
      no token, the first byte decides the lowercase, supplementary-plane
      characters and user words, loading without idf.utf8, a failed load tried
      again, eight threads on one analyzer, batches around 256 tokens, the
      stream contract, and the buffers released after a long value.
    - Mutation: lowercasing the words of a batch in reverse order fails
      AnalysisDifferential and JiebaTokenizerTest's random values, both on
      the shipped dictionaries, where a stray continuation byte starts a run
      of letters that keeps its capitals, and the batching test. The source
      was restored before the final runs.
    - The analysis golden files are unchanged, and the benchmark's digest mode
      gives the same tokens as the T1 baseline binary in all 5,240,060 rows,
      chinese_coarse and chinese_fine among them.
    - The port was also compared with the original headers directly, outside
      the BE build: 3,408,272 calls of cut and cut_all with 0 differences, on
      the shipped dictionaries (every byte and pairs and triples of chosen
      bytes, 300k random sentences with invalid bytes, every dictionary word
      alone and joined to the next, about 70 MB of corpora) and on edited ones
      (an undecodable word, user dictionaries of 1 to 4 columns, CRLF); 15
      broken files fail on both sides.
    - check-build-hygiene.sh passes, with analyzer.cpp out of both CLucene
      exception tables (12+11 now); clang-format 16 leaves the changed files
      as they are; run-clang-tidy.sh reports nothing on the changed lines.
- Behavior changed: No. Every token of the chinese parser is the same as
  before (above). A missing dictionary file still fails the analyzer, now
  with INVERTED_INDEX_ANALYZER_ERROR from the BE.
- Does this need documentation: No

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
### What problem does this PR solve?

Issue Number: None

Related PR: None

Problem Summary: jieba, moved into the BE unchanged in the previous commit,
still walked a trie of hash maps behind pointers, kept a 20-byte RuneStr and a
DAG entry with an inline vector per character, and looked separators, stop
words and HMM emissions up in hash sets and maps. The chinese parser now cuts
over flat arrays built once from the same dictionaries, with every decision
the old segmenters made kept, so the tokens do not change.

- flat_dict.h: FlatDict, built from the loaded DictTrie, HMMModel and stop
  word file, which are dropped afterwards. The trie is breadth-first arrays
  (child_begin, key, weight, has_value), so the children of a node are
  consecutive and a node's count is the difference of two child_begin
  entries; the first rune has a table of 0x10000 entries, supplementary first
  runes are searched for among the root's children, up to 8 children are
  scanned and more are bisected. User single-rune words are a bitmap with a
  set for supplementary runes, the emission probabilities a rune-major table
  of 0x10000 x 4 with a map for supplementary runes, and stop words of up to
  8 bytes an open-addressing table keyed by their bytes, longer ones a sorted
  vector. The pointer trie is never built: a stable sort keeps the last unit
  of each word, which is what inserting the units in order kept.
- segmenter.h: the mix and full cuts of MPSegment, HMMSegment, MixSegment,
  FullSegment and PreFilter over a JiebaScratch (jieba_scratch.h): runes as
  code point and byte offset arrays, the DAG walked straight into the DP or
  the full cut in its old order instead of being stored, one column of
  Viterbi weights. Kept as they decide tokens: the lenient decoding of
  rune.h, no token for a value that does not decode, the DP adding weights in
  the old order and keeping the first maximum (strict >), MAX_WORD_LENGTH
  512, FullSegment's word_len carried from one rune to the next, the whole
  alphanumeric run before its runs of letters and digits, user single-rune
  words, stop words dropped before lowercasing. The separators are compared
  directly: space, \t, \n, U+FF0C and U+3002, all SPECIAL_SEPARATORS held.
- jieba.h loads the files and keeps only the FlatDict; JiebaTokenizer owns a
  JiebaScratch, releases it above the high water mark at reset() like its
  other buffers and counts it in reserved_bytes(). next_batch() is unchanged.
- trie.h, mp_segment.h, hmm_segment.h, mix_segment.h, full_segment.h,
  pre_filter.h and segment_base.h are gone; LICENSE.txt and .licenserc.yaml
  list the derived files that remain.

### Release note

The chinese parser tokenizes about 3.2 times as fast in the coarse mode and
2.3 times as fast in the fine mode, and its dictionaries take less memory.

### Check List (For Author)

- Test: Unit Test, Manual test
    - BE UT under ASAN: the analysis, index and search suites with the T5 to
      T7 ones, 4191 of 4198 tests pass and the 7 skipped are the opt-in ones;
      the code point scan passes with ANALYSIS_GOLDEN_FULL=1. JiebaTokenizerTest
      passes unchanged: the live differential with LanguageBasedAnalyzer on
      random values, every dictionary word, mixed-case, unknown and
      undecodable words, and on a dictionary of its own in a death test.
      ComponentStreamTest counts the scratch among the bytes a long value
      leaves. IndexFileWriterTest.CacheEvictionTest failed once in a run of the
      wider suite list and passes on its own: it depends on what earlier tests
      leave in a shared searcher cache, not on this change.
    - The analysis golden files are unchanged, and the benchmark's digest mode
      gives the same tokens as the T1 baseline binary in all 5,240,060 rows,
      chinese_coarse and chinese_fine among them.
    - The new code was also compared with the original CLucene headers
      directly, outside the BE build and with the real jieba_tokenizer.cpp:
      4,208,058 cuts and 7,364,072 tokenizer runs (both modes, lowercase on
      and off, the scratch reused and fresh) on every byte and pairs and
      triples of chosen bytes, 300k random sentences with invalid bytes, every
      dictionary word alone and joined to the next, long values, all corpora,
      and edited dictionaries (an undecodable main word, duplicate main words,
      supplementary-plane user words including words of 512 and 513 runes,
      supplementary-plane emissions, CRLF files, 12k stop words): 0
      mismatches, in a release and in an ASAN+UBSan build. 9 of 10 deliberate
      mutations of the new code were caught; the tenth swaps E and S on a tie
      at the last rune, which ends a word either way.
    - Throughput of the benchmark binary of this change with
      be/benchmark/analyzer_ab.py: five alternating rounds, the median of the
      per-round ratios on each corpus and their geometric mean, reuse/ and
      row/. Against the T1 baseline: coarse 3.62 and 3.23 (zh_wiki 2.85 and
      2.70, ja_wiki 3.18 and 2.93, the four mixed corpora 3.97 and 3.47), fine
      2.44 and 2.28 (zh_wiki 2.48 and 2.41, ja_wiki 2.66 and 2.54, mixed 2.37
      and 2.18). Against the previous commit: coarse 3.54 and 3.10, fine 2.43
      and 2.19, so the move itself kept the old speed.
    - Memory of loading the shipped dictionaries, each in a process of its own
      with glibc malloc: the old cppjieba::Jieba with its five files +132.3 MB
      of RSS after loading (+145 MB at the peak); the new holder +40.5 MB
      right after loading and +11.2 MB after malloc_trim (+51 MB at the peak),
      the difference being freed loader memory that glibc keeps and the BE's
      jemalloc returns on decay. Loading takes 0.19 s instead of 0.51 s.
    - check-build-hygiene.sh passes; clang-format 16 leaves the changed files
      as they are; run-clang-tidy.sh reports nothing on the changed lines.
- Behavior changed: No. Every token of the chinese parser is the same as
  before (above).
- Does this need documentation: No

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… tokenizer

### What problem does this PR solve?

Issue Number: None

Related PR: None

Problem Summary: The icu tokenizer asked ICU for the script of every character
and whether it is a combining mark or an emoji, allocated a UText per script
run, converted every value through UnicodeString::fromUTF8 and every term back
through UnicodeString and std::string, and the builtin icu parser lowercased
every token again in LowerCaseFilter. RuleBasedBreakIterator, which holds the
rules, does not change; the cost around it does.

- icu_char_table.{h,cpp}: tables built once per process for the BMP: an int16
  script per code point and combining-mark, emoji and emoji-RK bitmaps, about
  152 KB; supplementary code points still ask ICU.
- script_iterator: reads the table, runs its loop on locals with a test of
  whether a character joins the run that does not branch on each condition,
  and skips ASCII eight units at a time inside a Latin run, which is exact
  because every ASCII character is Latin or Common (a test checks it).
- break_iterator_wrapper and composite_break_iterator: the emoji test from the
  table, the previous boundary tracked instead of asked of ICU, and one UText
  owned by the composite iterator and reopened for each run.
- icu_tokenizer: a native batch tokenizer. An ASCII value is widened to UTF-16
  directly, a valid non-ASCII value converted by simdutf's validating
  converter, an invalid one by u_strFromUTF8WithSub with U+FFFD, as
  UnicodeString::fromUTF8 did; strict mode throws where it threw. The terms of
  an ASCII value are views of it, the others are encoded straight into the
  batch, still cut at 255 UTF-16 units without splitting a surrogate pair.
  The builtin parser lowercases in the tokenizer: an ASCII term byte by byte,
  a term of U+3000 to U+9FFF only (no case there) as it is, any other with
  ICU's root-locale case mapping, which is what LowerCaseFilter did; the
  custom icu_tokenizer does not lowercase. The combine_cj option, always on,
  is gone: the table builds the Han, Hiragana and Katakana grouping in.
- analyzer.cpp: the icu preset uses the lowercasing tokenizer and no
  LowerCaseFilter, as the basic preset does; tokenizer_chain() has no user
  left and is removed.

ICU's break iterator keeps some state from one value to the next: after a
Common-script kana mark with no dictionary, such as U+309B, a later U+30FC
U+0301 in the same stream comes out as one token instead of two. The old code
does the same, and this change keeps it, so a reused stream still matches the
old reused stream token for token.

### Release note

None

### Check List (For Author)

- Test: Unit Test, Manual test
    - BE UT under ASAN: the analysis, index and search suites with the T5 to
      T8 ones, 4203 of 4210 tests pass and the 7 skipped are the opt-in ones;
      the code point scan passes with ANALYSIS_GOLDEN_FULL=1. The golden files
      freeze the icu outputs, supplementary characters around the 255th UTF-16
      unit and invalid UTF-8 included. New: ICUCharTableTest (every BMP entry
      and every code point's lookup against the old ICU calls, and that every
      ASCII character is Latin or Common) and ICUTokenizerStreamTest (the
      builtin parser against the custom tokenizer followed by LowerCaseFilter
      on 6,000 random values, lenient and strict; each lowercase path; no case
      mapping in U+3000 to U+9FFF; a Turkish default locale gives the root
      output; one stream reused across scripts gives what new streams give;
      strict mode throws; 255-unit cuts with surrogate pairs; the buffers
      released after a long value).
    - The analysis golden files are unchanged, and the benchmark's digest mode
      gives the same tokens as the T1 baseline binary in all 5,240,060 rows,
      icu among them.
    - The new code was also compared with the old tokenizer and LowerCaseFilter
      directly, outside the BE build: 7,285,055 values per mode (builtin and
      custom, lenient and strict): every code point alone and in context,
      30,132 values around the 255th unit, 300k random values with invalid
      bytes and every row of the six corpora, through kept streams and with
      242,287 new-stream checks per mode. 0 differences, the same exceptions
      in strict mode, and none under a Turkish default locale, in a release
      and in an ASAN/UBSan build.
    - Throughput with be/benchmark/analyzer_ab.py, on a host loaded far above
      its usual level (load 118 to 246), so the ranges are wide. Against the
      T1 baseline, reuse/ and row/: a first run of 5 rounds gave icu 2.06 and
      2.01, the four mixed corpora 2.07 to 2.77, zh_wiki 1.48 and 1.51 and
      ja_wiki 1.68 and 1.48; a second run of 9 rounds at load 220 gave zh_wiki
      1.41 and 1.32 and ja_wiki 1.36 and 1.46. Against T7: zh_wiki 1.32 and
      1.34, ja_wiki 1.30 and 1.31, which the standalone harness confirms at a
      lower load (zh_wiki 1.34, ja_wiki 1.30, the four mixed corpora 2.11 in
      geometric mean). With T4's 1.14 and 1.12 over the T1 baseline, that puts
      zh_wiki at about 1.5 and ja_wiki at about 1.47 against it. ICU's break
      iterator and its CJK dictionary now take about 88% of the time on
      Chinese and Japanese text, so 1.5 on each is out of reach without
      changing ICU's dictionary breaking, which this change keeps.
    - check-build-hygiene.sh passes; clang-format 16 leaves the changed files
      as they are; run-clang-tidy.sh reports nothing on the changed lines.
- Behavior changed: No. Every token of the icu parser and the custom
  icu_tokenizer is the same as before (above).
- Does this need documentation: No

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
### What problem does this PR solve?

Issue Number: None

Related PR: None

Problem Summary: The ik tokenizer walked dictionaries of DictSegment nodes,
each a map or array of child pointers, reached them through
Dictionary::getSingleton() for every character, asked a chain of range tests
for each character's type, regularized every word, kept its candidate hits in
a std::list and its results in a std::queue rebuilt for each value, and built
a std::string for every word it handed out. The tokens do not change.

- dic/FlatDict.h: the dictionaries as breadth-first flat tries of 12-byte
  nodes, children sorted (scanned up to 8, bisected above), with a table of
  0x10000 entries for the root's children. match() and isWord() keep the hit
  semantics of the old walk. DictSegment is now a load-time builder: each
  loader fills one, turns it into a FlatDict and frees it, and a load is still
  published only after it succeeds, so a failed load can be tried again. The
  FlatDict constructor lives in DictSegment.cpp, since a new source file would
  move the Storage unity batches. dic/Hit.h holds (const FlatDict*, node).
- core/CharacterUtil: identifyCharType reads a table of 0x10000 entries built
  once for the BMP, the block rules above it; a character's type is taken
  once, on the regularized character; regularizeInPlace returns a word with no
  0xE3 or 0xEF byte and no ASCII capital untouched, and regularizeString wraps
  it with its old signature.
- core/IKSegmenter, AnalyzeContext, CJKSegmenter, CN_QuantifierSegmenter: the
  Dictionary is taken once by reference instead of through getSingleton() on
  every character; the four segmenters are members called in the old order;
  an idle quantifier segmenter only releases its lock on a character that is
  not Chinese, and the surrogate pair segmenter only runs on surrogate pairs;
  CJKSegmenter's hits are a vector compacted in place in the same order; the
  results are one vector reused across windows and values; words are handed
  out as views, and Lexeme keeps no string.
- IKTokenizer: the words of a value lie back to back in one buffer reused for
  every value and are lowercased there, in lenient and strict mode alike; a
  value that does not decode still throws at reset().

Kept as they decide tokens: charEnd is the current index after matchWithHit
and the index plus one after matchInMainDict, which the quantifier path reads
as its next index; the 4096-byte windows with BUFF_EXHAUST_CRITICAL; the
255-byte cut at a character boundary; lowercase always on for dictionary
matching, so "Hello ABC" with lower_case=false still gives hello and ABC.

An IKTokenizer or IKSegmenter now needs a loaded dictionary when it is made,
not at its first Chinese character; the factory and the builtin preset load
it first.

### Release note

The ik parser tokenizes about 1.8 times as fast, and its dictionaries take
about a tenth of the memory.

### Check List (For Author)

- Test: Unit Test, Manual test
    - BE UT under ASAN: the analysis, index and search suites with the T5 to
      T9 ones, 4213 of 4220 tests pass and the 7 skipped are the opt-in ones;
      the code point scan passes with ANALYSIS_GOLDEN_FULL=1. New:
      IKFlatDictTest (FlatDict against DictSegment for every word, prefix and
      suffix of main.dic, quantifier.dic, stopword.dic and extra_stopword.dic,
      every step and length compared; 20k random strings against a trie with
      supplementary-plane words and nodes of more than 8 children; no
      DictSegment left after Dictionary::initial; a tokenizer keeps working
      with the singleton gone, so the per-character path does not reach it)
      and IKCharacterUtilTest (the type table against the block rules on every
      rune up to 0x1FFFFF; regularizeString against the old implementation on
      300k random byte strings). ik_anayzer_test gains "Hello ABC" (hello,
      ABC without lowercase; hello, abc with it), a character on the 4096-byte
      boundary at offsets 4088 to 4100 for é, 中 and 😀, and 你好\xE4\xB8
      throwing. IndexFileWriterTest.CacheEvictionTest failed once in a run of
      the wider suite list and passes on its own and in the later runs: it
      depends on what earlier tests leave in a shared searcher cache.
    - The analysis golden files are unchanged, and the benchmark's digest mode
      gives the same tokens as the T1 baseline binary in all 5,240,060 rows,
      ik_smart and ik_max_word among them.
    - The new code was also compared with the old ik code directly, outside
      the BE build: 28,659,978 values in 8 modes (ik_smart and ik_max_word,
      lower_case on and off, strict and lenient), through kept and new
      streams: every dictionary line alone and joined, random strings with
      invalid bytes, every offset around the 4096-byte boundary and all six
      corpora in full. 523M tokens and 211,200 identical exceptions, 0
      differences; an ASAN/UBSan run of 7.5M values found none, and no
      sanitizer report. In that harness, measured as thread CPU time with old
      and new taking turns on each 128 KiB chunk (the host load reached about
      410, which made wall time useless; old against old gave 0.97 to 1.00),
      the new code is 1.71 to 1.92 times as fast on every corpus in both
      modes, and the loaded dictionaries take 6.0 MB instead of 64.2 MB.
    - Throughput of the benchmark binary of this change with
      be/benchmark/analyzer_ab.py: five alternating rounds, the median of the
      per-round ratios on each corpus and their geometric mean, reuse/ and
      row/, at a host load of 50 to 135. Against the T1 baseline: ik_max_word
      1.84 and 1.74 (zh_wiki 1.58 and 1.54), ik_smart 1.80 and 1.70 (zh_wiki
      1.50 and 1.42), no corpus below 1.42. Against T8: ik_max_word 1.93 and
      1.76, ik_smart 1.89 and 1.75.
    - check-build-hygiene.sh passes; clang-format 16 leaves the changed files
      as they are; run-clang-tidy.sh reports nothing on the changed lines.
- Behavior changed: No. Every token of ik_smart and ik_max_word is the same
  as before (above).
- Does this need documentation: No

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…o-one filters

### What problem does this PR solve?

Issue Number: None

Related PR: None

Problem Summary: lowercase, icu_normalizer and asciifolding were per-token
filters: each pulled the tokens of its input one at a time into a batch of its
own and copied every term it handed on, changed or not. They now rewrite the
tokens of their input's batch where they stand, writing only a changed term
into the batch's memory. word_delimiter, pinyin and asciifolding with
preserve_original, which can make two tokens out of one, stay per token. The
tokens and the exceptions do not change.

- lower_case_filter.h: LowerCaseFilter is a TextTokenFilter with a native
  next_batch(). Which terms it rewrites decides where it throws, so a term is
  left as it is only when it is certainly valid: ASCII without a capital, or
  exactly one 3-byte character with a lead byte of 0xE3 to 0xE9 and two
  continuation bytes, which lies in U+3000 to U+9FFF and has no case. An ASCII
  term with a capital is lowercased byte by byte into the batch; any other term
  takes the old path, validation (the same exception) and ICU's root-locale
  case mapping into the batch, retried once at the length ICU asks for when the
  term grows (as İ does).
- icu_normalizer_filter: native and one to one; a term the quick check passes
  keeps its view, any other is normalized as before and copied into the batch;
  a failed normalization still logs and leaves the token as it is.
- ascii_folding_filter: ASCIIFoldingFilter is the native one-to-one filter,
  keeping the view of a term with nothing to fold; the per-token code stays as
  ASCIIFoldingPreserveOriginalFilter, which the factory makes for
  preserve_original=true; both share may_fold() and fold().

The filters hand out the batches their input hands out instead of refilling a
batch of 256 tokens, so an exception now comes out of the call that reaches the
bad token, after the batches before it, where a filter used to throw before
handing out the tokens ahead of it. The same values throw, with the same code
and message.

### Release note

None

### Check List (For Author)

- Test: Unit Test, Manual test
    - BE UT under ASAN: the analysis, index and search suites with the T5 to
      T10 ones, 4222 of 4229 tests pass and the 7 skipped are the opt-in ones;
      the code point scan passes with ANALYSIS_GOLDEN_FULL=1. New:
      OneToOneFilterTest (each batch filter against a per-token oracle of the
      replaced code on random streams of 1 to 4 batches of 1 to 300 tokens,
      with all four classes, empty terms, invalid UTF-8 and terms held as views
      or as batch copies: the same terms, increments, codes and messages; for
      icu_normalizer in four settings; terms left as they are keep their
      views), LowerCaseFilterTest.HandlesEachClassOfTerm (the four classes,
      which terms keep their views, and that only five go through ICU),
      LowerCaseFilterTest.RejectsALeadWithoutContinuationBytes (keyword +
      lowercase on E4 41 41 and four other malformed terms throws
      INVERTED_INDEX_ANALYZER_ERROR) and, in ASCIIFoldingFilterFactoryTest,
      standard + asciifolding on "café" with preserve_original ("cafe", then
      "café" with an increment of 0) and without it.
      RejectsInvalidUtf8WithAnalyzerError now reads the valid batch before the
      call that throws.
    - The analysis golden files are unchanged, and the benchmark's digest mode
      gives the same tokens as the T1 baseline binary in all 5,240,060 rows,
      the custom chains with lowercase and asciifolding among them.
    - The new filters were also compared with the old ones directly, outside
      the BE build, behind the real standard, basic, keyword, icu and empty
      tokenizers in 32 chains (the benchmark's custom chains, each tokenizer
      before each filter, every icu_normalizer setting, both asciifolding
      settings, filters in a row): all corpora, every BMP code point in 15
      contexts, supplementary code points, every 3-byte sequence led by 0xE0
      to 0xEF and 60k random values with invalid bytes per chain. 137.1M
      values, 773M tokens and 923K values that threw: 0 differences in tokens
      and exceptions; 57.1M values under ASAN/UBSan, 0 differences and no
      sanitizer report. 10 mutants of the new code were all killed, and no
      character in U+3000 to U+9FFF has a lowercase mapping (checked for each
      one).
    - Throughput of the benchmark binary of this change with
      be/benchmark/analyzer_ab.py: five alternating rounds, the median of the
      per-round ratios on each corpus and their geometric mean, reuse/ and
      row/, at a host load of 37 to 206, so the ranges are wide. Against the T1
      baseline: custom standard+lowercase 2.97 and 3.11, basic+lowercase 4.68
      and 4.94, standard+asciifolding+lowercase 2.49 and 2.62 (the three
      together 3.26 and 3.43); keyword+lowercase 2.31 and 2.57 (ja_wiki 1.09
      and 1.23, zh_wiki 1.12 and 1.27, where ICU's case mapping dominates long
      values), standard+word_delimiter+lowercase 2.62 and 2.45. Against T9: the
      three 1.67 to 2.04 and 1.80 to 2.17, keyword+lowercase 2.03 and 1.88,
      standard+word_delimiter+lowercase 1.33 and 1.41. pinyin is not in the
      benchmark and is not touched.
    - check-build-hygiene.sh passes; clang-format 16 leaves the changed files
      as they are; run-clang-tidy.sh reports nothing on the changed lines.
- Behavior changed: No. Every token and every exception is the same as before;
  only the call an exception comes out of can be a later one (above).
- Does this need documentation: No

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant