This is an automated email from the ASF dual-hosted git repository.

airborne12 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/doris.git


The following commit(s) were added to refs/heads/master by this push:
     new cab48ddc68a [refactor](be) Add the CLucene-free text analysis 
interface and its two CLucene adapters (#70261)
cab48ddc68a is described below

commit cab48ddc68abd4d75d3d8729ea9b6e9c66699c9a
Author: Jack <[email protected]>
AuthorDate: Fri Nov 6 22:55:29 2026 +0800

    [refactor](be) Add the CLucene-free text analysis interface and its two 
CLucene adapters (#70261)
    
    ### What problem does this PR solve?
    
    Issue Number: None
    
    Related PR: #70260 (depends on)
    
    Problem Summary: Add a CLucene-free text analysis interface
    (`TextAnalyzer`, `TextTokenStream`, `TokenBatch`) that hands out tokens
    in batches, and adapters in both directions so that consumers and
    analyzers can move to it separately. Nothing in production uses it yet.
---
 .../index/inverted/analysis/per_token_stream.cpp   |  84 ++++
 .../index/inverted/analysis/per_token_stream.h     |  77 ++++
 be/src/storage/index/inverted/analysis/scratch.h   |  39 ++
 .../index/inverted/analysis/text_analyzer.h        |  39 ++
 .../index/inverted/analysis/text_token_stream.cpp  |  42 ++
 .../index/inverted/analysis/text_token_stream.h    |  89 ++++
 .../storage/index/inverted/analysis/token_batch.h  | 102 +++++
 .../storage/index/inverted/analysis/token_stream.h |  13 +-
 .../inverted/clucene/lucene_analyzer_bridge.cpp    | 111 +++++
 .../inverted/clucene/lucene_analyzer_bridge.h      |  88 ++++
 .../clucene/lucene_backed_text_analyzer.cpp        | 117 +++++
 .../inverted/clucene/lucene_backed_text_analyzer.h |  53 +++
 .../analysis/golden/analysis_golden_test.cpp       | 125 +++++-
 .../analysis/golden/analysis_write_golden_test.cpp | 150 ++++++-
 .../inverted/analysis/golden/golden_driver.cpp     | 104 ++++-
 .../index/inverted/analysis/golden/golden_driver.h |  12 +
 .../inverted/analysis/text_stream_contract.cpp     |  80 ++++
 .../index/inverted/analysis/text_stream_contract.h |  56 +++
 .../inverted/analysis/text_token_stream_test.cpp   | 471 +++++++++++++++++++++
 .../index/inverted/clucene/lucene_adapter_test.cpp | 455 ++++++++++++++++++++
 20 files changed, 2262 insertions(+), 45 deletions(-)

diff --git a/be/src/storage/index/inverted/analysis/per_token_stream.cpp 
b/be/src/storage/index/inverted/analysis/per_token_stream.cpp
new file mode 100644
index 00000000000..8ce5fe17a98
--- /dev/null
+++ b/be/src/storage/index/inverted/analysis/per_token_stream.cpp
@@ -0,0 +1,84 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+#include "storage/index/inverted/analysis/per_token_stream.h"
+
+namespace doris::segment_v2::inverted_index {
+
+namespace {
+
+// Copies tokens into `batch` until it is full or `next_token` returns false, 
which latches
+// `exhausted`.
+template <typename NextToken>
+bool fill_batch(TokenBatch& batch, bool& exhausted, NextToken&& next_token) {
+    batch.clear();
+    TokenRef token;
+    while (!exhausted && batch.size() < TextTokenStream::kBatchTokens) {
+        if (next_token(token)) {
+            batch.push_copy(token.term(), token.position_increment);
+        } else {
+            exhausted = true;
+        }
+    }
+    return batch.size() != 0;
+}
+
+} // namespace
+
+void PerTokenTokenizer::reset(std::string_view text) {
+    // Exhausted until on_reset() returns, so a stream whose on_reset() threw 
makes no token.
+    _exhausted = true;
+    on_reset(text);
+    _exhausted = false;
+}
+
+bool PerTokenTokenizer::next_batch(TokenBatch& batch) {
+    return fill_batch(batch, _exhausted, [this](TokenRef& token) { return 
next_token(token); });
+}
+
+void PerTokenFilter::reset(std::string_view text) {
+    _exhausted = true;
+    TextTokenFilter::reset(text);
+    // A half-read value leaves tokens in the input batch; none of them may 
come out again.
+    _input.clear();
+    _input.release_oversized();
+    _cursor = 0;
+    on_reset();
+    _exhausted = false;
+}
+
+bool PerTokenFilter::next_batch(TokenBatch& batch) {
+    return fill_batch(batch, _exhausted, [this](TokenRef& token) { return 
next_token(token); });
+}
+
+size_t PerTokenFilter::reserved_bytes() const {
+    return _input.capacity_bytes() + TextTokenFilter::reserved_bytes();
+}
+
+bool PerTokenFilter::input_token(TokenRef& token) {
+    if (_cursor == _input.size()) {
+        _cursor = 0;
+        // An exhausted input keeps returning false with an empty batch.
+        if (!_in->next_batch(_input)) {
+            return false;
+        }
+    }
+    token = _input.tokens()[_cursor++];
+    return true;
+}
+
+} // namespace doris::segment_v2::inverted_index
diff --git a/be/src/storage/index/inverted/analysis/per_token_stream.h 
b/be/src/storage/index/inverted/analysis/per_token_stream.h
new file mode 100644
index 00000000000..e46357b082b
--- /dev/null
+++ b/be/src/storage/index/inverted/analysis/per_token_stream.h
@@ -0,0 +1,77 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+#pragma once
+
+#include <cstddef>
+#include <string_view>
+
+#include "storage/index/inverted/analysis/text_token_stream.h"
+
+namespace doris::segment_v2::inverted_index {
+
+// The base of a tokenizer that makes one token at a time. It keeps its state 
machine and may build
+// every term in one reused scratch buffer: the base copies each term into the 
batch, because views
+// of a reused scratch would overwrite each other within one batch.
+class PerTokenTokenizer : public TextTokenizer {
+public:
+    void reset(std::string_view text) final;
+    bool next_batch(TokenBatch& batch) final;
+
+protected:
+    // Binds `text`, dropping the state of the previous value.
+    virtual void on_reset(std::string_view text) = 0;
+    // The next token; its term stays valid until the next call. Once it 
returned false it is not
+    // called again before the next reset().
+    virtual bool next_token(TokenRef& token) = 0;
+
+private:
+    // No value is bound, or next_token() returned false.
+    bool _exhausted = true;
+};
+
+// The base of a filter that makes one token at a time out of the tokens of 
its input, which it
+// reads with input_token(). The input batch is private and only input_token() 
refills it, so the
+// filter may hold an input token across next_batch() calls, as word_delimiter 
does while it hands
+// out the parts of one token.
+class PerTokenFilter : public TextTokenFilter {
+public:
+    using TextTokenFilter::TextTokenFilter;
+
+    void reset(std::string_view text) final;
+    bool next_batch(TokenBatch& batch) final;
+    // Adds the input batch. A filter with buffers of its own overrides it and 
adds them.
+    size_t reserved_bytes() const override;
+
+protected:
+    // Drops the filter's state of the previous value; the input is reset 
already.
+    virtual void on_reset() {}
+    // The next token; its term stays valid until the next call. Once it 
returned false it is not
+    // called again before the next reset().
+    virtual bool next_token(TokenRef& token) = 0;
+    // The next input token, or false once the input is exhausted. Its term 
stays valid until the
+    // next call.
+    bool input_token(TokenRef& token);
+
+private:
+    TokenBatch _input;
+    size_t _cursor = 0; // the next token of _input
+    // No value is bound, or next_token() returned false.
+    bool _exhausted = true;
+};
+
+} // namespace doris::segment_v2::inverted_index
diff --git a/be/src/storage/index/inverted/analysis/scratch.h 
b/be/src/storage/index/inverted/analysis/scratch.h
new file mode 100644
index 00000000000..68fffdfdbd8
--- /dev/null
+++ b/be/src/storage/index/inverted/analysis/scratch.h
@@ -0,0 +1,39 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+#pragma once
+
+#include <cstddef>
+
+namespace doris::segment_v2::inverted_index {
+
+// The capacity an analysis buffer may keep from one value to the next. Values 
have no length
+// limit, so a buffer that grew for a long value is released once the value is 
done; otherwise a
+// stream reused across values would hold, say, 800 MB after one 50 MB value 
until the segment
+// ends. 64 KiB is far above what ordinary values need, so steady values 
allocate nothing.
+constexpr size_t ANALYZER_SCRATCH_HIGH_WATER_BYTES = 64 * 1024;
+
+// Releases `container` when its capacity is above the high water mark.
+template <typename Container>
+void release_oversized_scratch(Container& container) {
+    if (container.capacity() * sizeof(typename Container::value_type) >
+        ANALYZER_SCRATCH_HIGH_WATER_BYTES) {
+        Container().swap(container);
+    }
+}
+
+} // namespace doris::segment_v2::inverted_index
diff --git a/be/src/storage/index/inverted/analysis/text_analyzer.h 
b/be/src/storage/index/inverted/analysis/text_analyzer.h
new file mode 100644
index 00000000000..08d20061844
--- /dev/null
+++ b/be/src/storage/index/inverted/analysis/text_analyzer.h
@@ -0,0 +1,39 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+#pragma once
+
+#include <memory>
+
+#include "storage/index/inverted/analysis/text_token_stream.h"
+
+namespace doris::segment_v2::inverted_index {
+
+// Makes the token streams of one analysis configuration. An analyzer does not 
change once it is
+// built, so any number of threads may share one, each with streams of its own.
+class TextAnalyzer {
+public:
+    virtual ~TextAnalyzer() = default;
+
+    // A new stream. It must not outlive the analyzer: a stream may refer to 
what the analyzer
+    // holds, as an ICU normalizer refers to the character set its factory 
keeps.
+    virtual std::unique_ptr<TextTokenStream> create_stream() const = 0;
+};
+
+using TextAnalyzerPtr = std::shared_ptr<const TextAnalyzer>;
+
+} // namespace doris::segment_v2::inverted_index
diff --git a/be/src/storage/index/inverted/analysis/text_token_stream.cpp 
b/be/src/storage/index/inverted/analysis/text_token_stream.cpp
new file mode 100644
index 00000000000..154ee63cd10
--- /dev/null
+++ b/be/src/storage/index/inverted/analysis/text_token_stream.cpp
@@ -0,0 +1,42 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+#include "storage/index/inverted/analysis/text_token_stream.h"
+
+#include <utility>
+
+#include "common/check.h"
+
+namespace doris::segment_v2::inverted_index {
+
+TextTokenFilter::TextTokenFilter(std::unique_ptr<TextTokenStream> in) : 
_in(std::move(in)) {
+    DORIS_CHECK(_in != nullptr);
+}
+
+void TextTokenFilter::reset(std::string_view text) {
+    _in->reset(text);
+}
+
+size_t TextTokenFilter::reserved_bytes() const {
+    return _in->reserved_bytes();
+}
+
+void TextTokenFilter::set_strict_utf8(bool strict) {
+    _in->set_strict_utf8(strict);
+}
+
+} // namespace doris::segment_v2::inverted_index
diff --git a/be/src/storage/index/inverted/analysis/text_token_stream.h 
b/be/src/storage/index/inverted/analysis/text_token_stream.h
new file mode 100644
index 00000000000..1198209cdca
--- /dev/null
+++ b/be/src/storage/index/inverted/analysis/text_token_stream.h
@@ -0,0 +1,89 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+#pragma once
+
+#include <cstddef>
+#include <memory>
+#include <string_view>
+
+#include "storage/index/inverted/analysis/token_batch.h"
+
+namespace doris::segment_v2::inverted_index {
+
+// The tokens of one value at a time, handed over in batches. A consumer keeps 
one stream and one
+// batch for many values:
+//
+//     stream->reset(value);
+//     while (stream->next_batch(batch)) {
+//         for (const TokenRef& token : batch.tokens()) { ... }
+//     }
+//     batch.release_oversized();
+//
+// A stream belongs to one thread; the analyzer that made it may be shared. It 
throws only
+// doris::Exception, from reset() and from next_batch(), and is not used again 
after it threw.
+class TextTokenStream {
+public:
+    // A streaming source hands over a batch once it holds this many tokens. A 
core loop may
+    // overshoot by what one step makes, and a filter that makes several 
tokens out of one may
+    // exceed it; a consumer relies only on the return value of next_batch().
+    static constexpr size_t kBatchTokens = 256;
+
+    virtual ~TextTokenStream() = default;
+
+    // Binds `text`, in any state, and drops whatever is left of the previous 
value. `text` must
+    // stay valid while the stream iterates over it. An empty `text` makes no 
token, whatever its
+    // data pointer: NFKC_CF can fold a value to an empty view that does not 
point to null.
+    virtual void reset(std::string_view text) = 0;
+
+    // Clears `batch`, then fills it. Returns true with at least one token, or 
false with an empty
+    // batch once the value is exhausted, and only then; it keeps returning 
false until the next
+    // reset(). A filter that drops every token of a batch pulls the next one 
instead of returning
+    // an empty batch. A term stays valid until the next next_batch() or 
reset() on this stream.
+    // An empty term may be in the batch: the consumer decides whether it 
counts.
+    virtual bool next_batch(TokenBatch& batch) = 0;
+
+    // The bytes this stream and the streams it wraps keep from one value to 
the next. Every buffer
+    // that survives a value counts, and reset() releases those above the high 
water mark
+    // (scratch.h), so the number stays bounded however long a value was.
+    virtual size_t reserved_bytes() const = 0;
+
+    // Strict handling of invalid UTF-8, which the pinyin filter needs when it 
reports source
+    // offsets: icu, ngram and edge_ngram then throw on it, and ik normalizes 
strictly. A filter
+    // forwards it to its input; a tokenizer without a strict form ignores it.
+    virtual void set_strict_utf8(bool /*strict*/) {}
+};
+
+// The start of a chain: makes tokens out of the text.
+class TextTokenizer : public TextTokenStream {};
+
+// A link of a chain: makes tokens out of the tokens of its input, which it 
owns.
+class TextTokenFilter : public TextTokenStream {
+public:
+    explicit TextTokenFilter(std::unique_ptr<TextTokenStream> in);
+
+    // Resets the input. A filter with state of its own overrides it and calls 
this first.
+    void reset(std::string_view text) override;
+    // The bytes of the input. A filter with buffers of its own overrides it 
and adds them.
+    size_t reserved_bytes() const override;
+    void set_strict_utf8(bool strict) override;
+
+protected:
+    std::unique_ptr<TextTokenStream> _in;
+};
+
+} // namespace doris::segment_v2::inverted_index
diff --git a/be/src/storage/index/inverted/analysis/token_batch.h 
b/be/src/storage/index/inverted/analysis/token_batch.h
new file mode 100644
index 00000000000..ebe7fa848b6
--- /dev/null
+++ b/be/src/storage/index/inverted/analysis/token_batch.h
@@ -0,0 +1,102 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+#pragma once
+
+#include <cstddef>
+#include <cstdint>
+#include <limits>
+#include <span>
+#include <string_view>
+#include <vector>
+
+#include "common/logging.h"
+#include "core/arena.h"
+#include "storage/index/inverted/analysis/scratch.h"
+
+namespace doris::segment_v2::inverted_index {
+
+// One token: a view of its term, and how many positions it is after the token 
before it.
+struct TokenRef {
+    const char* data = nullptr;
+    uint32_t size = 0;
+    int32_t position_increment = 0; // >= 0
+
+    std::string_view term() const { return {data, size}; }
+};
+
+// The tokens a stream hands over in one TextTokenStream::next_batch() call. A 
term is a view: of
+// the value being analyzed, of memory the stream keeps, or of the batch's own 
term storage, which
+// push_copy() and allocate_term() fill. Term storage is an arena, so growing 
it never moves a term
+// handed out before, and clear() drops all of them at once.
+//
+// The consumer owns the batch and keeps it from value to value. Between two 
values it calls
+// release_oversized(): the stream cannot, and one huge value must not pin its 
memory until the
+// segment ends.
+class TokenBatch {
+public:
+    // Drops the tokens and the terms in term storage. The token array and the 
arena's largest
+    // block stay, so the next batch of a steady stream allocates nothing.
+    void clear() {
+        _tokens.clear();
+        _terms.clear();
+    }
+
+    size_t size() const { return _tokens.size(); }
+    std::span<TokenRef> tokens() { return _tokens; }
+    std::span<const TokenRef> tokens() const { return _tokens; }
+
+    // Appends a token whose term stays where it is; whoever owns the term 
keeps it valid while the
+    // batch holds the token.
+    void push(std::string_view term, int32_t position_increment) {
+        DCHECK_GE(position_increment, 0);
+        DCHECK_LE(term.size(), std::numeric_limits<uint32_t>::max());
+        _tokens.push_back({.data = term.data(),
+                           .size = static_cast<uint32_t>(term.size()),
+                           .position_increment = position_increment});
+    }
+
+    // Appends a token with a copy of `term` in term storage.
+    void push_copy(std::string_view term, int32_t position_increment) {
+        char* copy = allocate_term(term.size());
+        term.copy(copy, term.size());
+        push({copy, term.size()}, position_increment);
+    }
+
+    // `size` bytes of term storage, for a term built in place. Valid until 
the next clear().
+    char* allocate_term(size_t size) { return _terms.alloc(size); }
+
+    // The bytes the batch keeps across clear(), for the writer's memory 
reporter.
+    size_t capacity_bytes() const { return _tokens.capacity() * 
sizeof(TokenRef) + _terms.size(); }
+
+    // Between two values, on an empty batch: when the batch keeps more than 
the high water mark,
+    // which only a value with a very large batch or very long terms leaves 
behind, it releases all
+    // of it. An ordinary batch keeps a 4 KiB token array and a 4 KiB arena 
block, and keeps them.
+    void release_oversized() {
+        DCHECK_EQ(_tokens.size(), 0);
+        if (capacity_bytes() > ANALYZER_SCRATCH_HIGH_WATER_BYTES) {
+            std::vector<TokenRef>().swap(_tokens);
+            _terms.clear(/*delete_head=*/true);
+        }
+    }
+
+private:
+    std::vector<TokenRef> _tokens;
+    Arena _terms;
+};
+
+} // namespace doris::segment_v2::inverted_index
diff --git a/be/src/storage/index/inverted/analysis/token_stream.h 
b/be/src/storage/index/inverted/analysis/token_stream.h
index bdc13c3e9ea..a357523f6e7 100644
--- a/be/src/storage/index/inverted/analysis/token_stream.h
+++ b/be/src/storage/index/inverted/analysis/token_stream.h
@@ -31,6 +31,7 @@
 #include "CLucene/analysis/AnalysisHeader.h"
 #include "common/cast_set.h"
 #include "storage/index/inverted/analysis/reader.h"
+#include "storage/index/inverted/analysis/scratch.h"
 
 using namespace lucene::analysis;
 
@@ -41,10 +42,6 @@ using TokenizerPtr = std::shared_ptr<DorisTokenizer>;
 
 using TokenStreamPtr = std::shared_ptr<TokenStream>;
 
-// Reused analyzers keep ordinary scratch across values; one oversized value 
must not pin its
-// capacity for the rest of the writer lifetime.
-constexpr size_t ANALYZER_SCRATCH_HIGH_WATER_BYTES = 64 * 1024;
-
 // Longest prefix of text that fits in max_bytes without splitting a rune, and 
its rune count.
 inline std::pair<size_t, size_t> utf8_prefix_at_most(std::string_view text, 
size_t max_bytes) {
     const auto length = static_cast<int32_t>(text.size());
@@ -63,14 +60,6 @@ inline std::pair<size_t, size_t> 
utf8_prefix_at_most(std::string_view text, size
     return {static_cast<size_t>(offset), rune_count};
 }
 
-template <typename Container>
-void release_oversized_scratch(Container& container) {
-    if (container.capacity() * sizeof(typename Container::value_type) >
-        ANALYZER_SCRATCH_HIGH_WATER_BYTES) {
-        Container().swap(container);
-    }
-}
-
 /**
  * All custom tokenizers and token_filters must use the following functions 
  * to set token information. Using these unified set methods helps avoid 
diff --git a/be/src/storage/index/inverted/clucene/lucene_analyzer_bridge.cpp 
b/be/src/storage/index/inverted/clucene/lucene_analyzer_bridge.cpp
new file mode 100644
index 00000000000..cab1d251e89
--- /dev/null
+++ b/be/src/storage/index/inverted/clucene/lucene_analyzer_bridge.cpp
@@ -0,0 +1,111 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+#include "storage/index/inverted/clucene/lucene_analyzer_bridge.h"
+
+#include <utility>
+
+#include "common/cast_set.h"
+#include "common/check.h"
+#include "common/exception.h"
+
+namespace doris::segment_v2::inverted_index {
+
+namespace {
+
+// The whole value of `reader`, without a copy. SStringReader::read() hands 
out a view of its bytes
+// and returns -1, not 0, for an empty value.
+std::string_view read_value(const ReaderPtr& reader) {
+    const char* start = nullptr;
+    const int32_t length = reader->read(reinterpret_cast<const 
void**>(&start), 0,
+                                        cast_set<int32_t>(reader->size()));
+    return length > 0 ? std::string_view(start, length) : std::string_view();
+}
+
+} // namespace
+
+LuceneTokenStreamBridge::LuceneTokenStreamBridge(std::unique_ptr<TextTokenStream>
 stream)
+        : _stream(std::move(stream)) {
+    DORIS_CHECK(_stream != nullptr);
+}
+
+LuceneTokenStreamBridge::~LuceneTokenStreamBridge() = default;
+
+void LuceneTokenStreamBridge::bind(std::string_view text) {
+    _pending = text;
+}
+
+void LuceneTokenStreamBridge::reset() {
+    // Exhausted until the stream accepted the value, so a value whose reset() 
threw makes no token.
+    _exhausted = true;
+    _batch.clear();
+    _cursor = 0;
+    _stream->reset(_pending);
+    _exhausted = false;
+}
+
+lucene::analysis::Token* 
LuceneTokenStreamBridge::next(lucene::analysis::Token* token) {
+    if (_cursor == _batch.size()) {
+        _cursor = 0;
+        if (_exhausted || !_stream->next_batch(_batch)) {
+            _exhausted = true;
+            _batch.release_oversized();
+            return nullptr;
+        }
+    }
+    const TokenRef& ref = _batch.tokens()[_cursor++];
+    token->setTextNoCopy(ref.data, static_cast<int32_t>(ref.size));
+    token->setPositionIncrement(ref.position_increment);
+    return token;
+}
+
+LuceneAnalyzerBridge::LuceneAnalyzerBridge(TextAnalyzerPtr analyzer)
+        : _analyzer(std::move(analyzer)) {
+    DORIS_CHECK(_analyzer != nullptr);
+}
+
+LuceneAnalyzerBridge::~LuceneAnalyzerBridge() = default;
+
+lucene::analysis::TokenStream* LuceneAnalyzerBridge::tokenStream(const TCHAR* 
/*field_name*/,
+                                                                 const 
ReaderPtr& reader) {
+    auto stream = 
std::make_unique<LuceneTokenStreamBridge>(_analyzer->create_stream());
+    stream->bind(read_value(reader));
+    return stream.release();
+}
+
+lucene::analysis::TokenStream* LuceneAnalyzerBridge::reusableTokenStream(
+        const TCHAR* /*field_name*/, const ReaderPtr& reader) {
+    if (_reusable == nullptr) {
+        _reusable = 
std::make_unique<LuceneTokenStreamBridge>(_analyzer->create_stream());
+    }
+    _reusable->bind(read_value(reader));
+    return _reusable.get();
+}
+
+lucene::analysis::TokenStream* LuceneAnalyzerBridge::tokenStream(const TCHAR* 
/*field_name*/,
+                                                                 
lucene::util::Reader* /*reader*/) {
+    throw Exception(ErrorCode::INVERTED_INDEX_NOT_SUPPORTED,
+                    "LuceneAnalyzerBridge::tokenStream needs a ReaderPtr");
+}
+
+lucene::analysis::TokenStream* LuceneAnalyzerBridge::reusableTokenStream(
+        const TCHAR* /*field_name*/, lucene::util::Reader* /*reader*/) {
+    throw Exception(ErrorCode::INVERTED_INDEX_NOT_SUPPORTED,
+                    "LuceneAnalyzerBridge::reusableTokenStream needs a 
ReaderPtr");
+}
+
+} // namespace doris::segment_v2::inverted_index
diff --git a/be/src/storage/index/inverted/clucene/lucene_analyzer_bridge.h 
b/be/src/storage/index/inverted/clucene/lucene_analyzer_bridge.h
new file mode 100644
index 00000000000..589f52df51a
--- /dev/null
+++ b/be/src/storage/index/inverted/clucene/lucene_analyzer_bridge.h
@@ -0,0 +1,88 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+#pragma once
+
+#include <CLucene.h> // IWYU pragma: keep
+
+#include <cstddef>
+#include <memory>
+#include <string_view>
+
+#include "CLucene/analysis/AnalysisHeader.h"
+#include "storage/index/inverted/analysis/reader.h"
+#include "storage/index/inverted/analysis/text_analyzer.h"
+#include "storage/index/inverted/analysis/token_batch.h"
+
+namespace doris::segment_v2::inverted_index {
+
+// A TextTokenStream behind the CLucene TokenStream interface, for the V1 and 
V2 writers. Terms are
+// handed over without a copy: a term stays valid until the next batch, and 
CLucene is done with a
+// token before it asks for the next one. An empty term is handed over too, 
because the V1 and V2
+// writers give it a position. A doris::Exception passes through the CLucene 
frames to the caller
+// of IndexWriter::addDocument(), whose _CLFINALLY blocks clean up on the way.
+class LuceneTokenStreamBridge final : public lucene::analysis::TokenStream {
+public:
+    explicit LuceneTokenStreamBridge(std::unique_ptr<TextTokenStream> stream);
+    ~LuceneTokenStreamBridge() override;
+
+    // Parks the value; reset() starts it.
+    void bind(std::string_view text);
+    // SDocumentsWriter::invertField() calls reset() once per field, and that 
is what starts the
+    // parked value, exactly as DorisTokenizer::reset() promotes its pending 
reader today.
+    void reset() override;
+    lucene::analysis::Token* next(lucene::analysis::Token* token) override;
+    // invertField() closes the stream after every field, and the scalar path 
reuses it.
+    void close() override {}
+
+private:
+    std::unique_ptr<TextTokenStream> _stream;
+    TokenBatch _batch;
+    size_t _cursor = 0; // the next token of _batch
+    std::string_view _pending;
+    // No value is started, or the stream is done with it.
+    bool _exhausted = true;
+};
+
+// A TextAnalyzer behind the CLucene Analyzer interface, for the V1 and V2 
writers. The writer
+// passes each value as an SStringReader on the column's bytes; the char 
filters are in the
+// TextAnalyzer.
+class LuceneAnalyzerBridge final : public lucene::analysis::Analyzer {
+public:
+    explicit LuceneAnalyzerBridge(TextAnalyzerPtr analyzer);
+    ~LuceneAnalyzerBridge() override;
+
+    bool isSDocOpt() override { return true; }
+
+    // A new stream the caller owns. Array elements take this path, one stream 
per element.
+    lucene::analysis::TokenStream* tokenStream(const TCHAR* field_name,
+                                               const ReaderPtr& reader) 
override;
+    // The bridge's own stream, rebound to the value. A scalar column takes 
this path.
+    lucene::analysis::TokenStream* reusableTokenStream(const TCHAR* field_name,
+                                                       const ReaderPtr& 
reader) override;
+    // Pure virtual in the base and never reached, as in CustomAnalyzer: these 
throw.
+    lucene::analysis::TokenStream* tokenStream(const TCHAR* field_name,
+                                               lucene::util::Reader* reader) 
override;
+    lucene::analysis::TokenStream* reusableTokenStream(const TCHAR* field_name,
+                                                       lucene::util::Reader* 
reader) override;
+
+private:
+    const TextAnalyzerPtr _analyzer;
+    std::unique_ptr<LuceneTokenStreamBridge> _reusable;
+};
+
+} // namespace doris::segment_v2::inverted_index
diff --git 
a/be/src/storage/index/inverted/clucene/lucene_backed_text_analyzer.cpp 
b/be/src/storage/index/inverted/clucene/lucene_backed_text_analyzer.cpp
new file mode 100644
index 00000000000..61965848ef6
--- /dev/null
+++ b/be/src/storage/index/inverted/clucene/lucene_backed_text_analyzer.cpp
@@ -0,0 +1,117 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+#include "storage/index/inverted/clucene/lucene_backed_text_analyzer.h"
+
+#include <CLucene.h>
+
+#include <string_view>
+#include <utility>
+
+#include "common/cast_set.h"
+#include "common/check.h"
+#include "common/exception.h"
+#include "storage/index/inverted/analysis/analyzer/analyzer.h"
+#include "storage/index/inverted/analysis/reader.h"
+
+namespace doris::segment_v2::inverted_index {
+
+namespace {
+
+// One value at a time through a CLucene analyzer. The reader, the token and 
the CLucene stream are
+// made by reset() and released once the value is exhausted, so nothing 
outlives a value.
+class LuceneBackedTokenStream final : public TextTokenizer {
+public:
+    // Both outlive the stream, because a stream does not outlive its analyzer.
+    LuceneBackedTokenStream(lucene::analysis::Analyzer* analyzer, const 
CharFilterMap* char_filters)
+            : _analyzer(analyzer), _char_filters(char_filters) {}
+
+    void reset(std::string_view text) override {
+        release();
+        try {
+            _reader = InvertedIndexAnalyzer::create_reader(*_char_filters);
+            _reader->init(text.data(), cast_set<int32_t>(text.size()), false);
+            _token = std::make_unique<lucene::analysis::Token>();
+            _stream.reset(_analyzer->tokenStream(L"", _reader));
+        } catch (const CLuceneError& e) {
+            release();
+            throw Exception(ErrorCode::INVERTED_INDEX_ANALYZER_ERROR,
+                            "the CLucene analyzer failed to start a value: 
{}", e.what());
+        }
+    }
+
+    bool next_batch(TokenBatch& batch) override {
+        batch.clear();
+        while (_stream != nullptr && batch.size() < kBatchTokens) {
+            lucene::analysis::Token* token = next_lucene_token();
+            if (token == nullptr) {
+                release();
+                break;
+            }
+            batch.push_copy({token->termBuffer<char>(), 
token->termLength<char>()},
+                            token->getPositionIncrement());
+        }
+        return batch.size() != 0;
+    }
+
+    // Nothing survives a value.
+    size_t reserved_bytes() const override { return 0; }
+
+private:
+    // The next CLucene token, or nullptr once the value is exhausted.
+    lucene::analysis::Token* next_lucene_token() {
+        try {
+            lucene::analysis::Token* token = _stream->next(_token.get());
+            if (token == nullptr) {
+                _stream->close();
+            }
+            return token;
+        } catch (const CLuceneError& e) {
+            release();
+            throw Exception(ErrorCode::INVERTED_INDEX_ANALYZER_ERROR,
+                            "the CLucene analyzer failed on a value: {}", 
e.what());
+        }
+    }
+
+    void release() {
+        _stream.reset();
+        _token.reset();
+        _reader.reset();
+    }
+
+    lucene::analysis::Analyzer* const _analyzer;
+    const CharFilterMap* const _char_filters;
+    ReaderPtr _reader;
+    std::unique_ptr<lucene::analysis::Token> _token;
+    std::unique_ptr<lucene::analysis::TokenStream> _stream;
+};
+
+} // namespace
+
+LuceneBackedTextAnalyzer::LuceneBackedTextAnalyzer(
+        std::shared_ptr<lucene::analysis::Analyzer> analyzer, CharFilterMap 
char_filters)
+        : _analyzer(std::move(analyzer)), 
_char_filters(std::move(char_filters)) {
+    DORIS_CHECK(_analyzer != nullptr);
+}
+
+LuceneBackedTextAnalyzer::~LuceneBackedTextAnalyzer() = default;
+
+std::unique_ptr<TextTokenStream> LuceneBackedTextAnalyzer::create_stream() 
const {
+    return std::make_unique<LuceneBackedTokenStream>(_analyzer.get(), 
&_char_filters);
+}
+
+} // namespace doris::segment_v2::inverted_index
diff --git 
a/be/src/storage/index/inverted/clucene/lucene_backed_text_analyzer.h 
b/be/src/storage/index/inverted/clucene/lucene_backed_text_analyzer.h
new file mode 100644
index 00000000000..16610072867
--- /dev/null
+++ b/be/src/storage/index/inverted/clucene/lucene_backed_text_analyzer.h
@@ -0,0 +1,53 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+#pragma once
+
+#include <memory>
+
+#include "storage/index/inverted/analysis/inverted_index_parser.h"
+#include "storage/index/inverted/analysis/text_analyzer.h"
+
+namespace lucene::analysis {
+class Analyzer;
+} // namespace lucene::analysis
+
+namespace doris::segment_v2::inverted_index {
+
+// A CLucene analyzer behind the TextAnalyzer interface, for the consumers 
that move to the
+// interface before the analyzers do. It goes once every analyzer is native.
+//
+// Every value gets a new reader through the index's char filter 
(InvertedIndexAnalyzer::
+// create_reader) and a new CLucene stream from Analyzer::tokenStream(), as a 
query reads a value
+// today; the SNII writer does the same with one reader it re-initializes. 
Sharing the analyzer
+// between threads is as safe as sharing the CLucene analyzer for 
tokenStream() calls. Terms are
+// copied into the batch, because a CLucene token is overwritten by the next 
one; a CLuceneError
+// comes out as a doris::Exception.
+class LuceneBackedTextAnalyzer final : public TextAnalyzer {
+public:
+    LuceneBackedTextAnalyzer(std::shared_ptr<lucene::analysis::Analyzer> 
analyzer,
+                             CharFilterMap char_filters);
+    ~LuceneBackedTextAnalyzer() override;
+
+    std::unique_ptr<TextTokenStream> create_stream() const override;
+
+private:
+    const std::shared_ptr<lucene::analysis::Analyzer> _analyzer;
+    const CharFilterMap _char_filters;
+};
+
+} // namespace doris::segment_v2::inverted_index
diff --git 
a/be/test/storage/index/inverted/analysis/golden/analysis_golden_test.cpp 
b/be/test/storage/index/inverted/analysis/golden/analysis_golden_test.cpp
index 6221a4c7ff6..9521f4204ef 100644
--- a/be/test/storage/index/inverted/analysis/golden/analysis_golden_test.cpp
+++ b/be/test/storage/index/inverted/analysis/golden/analysis_golden_test.cpp
@@ -48,6 +48,7 @@
 #include <set>
 #include <sstream>
 #include <string>
+#include <thread>
 #include <utility>
 #include <vector>
 
@@ -99,6 +100,9 @@ void write_dump(const std::string& dir, const std::string& 
key, DriveMode mode,
     out << dump;
 }
 
+// Makes the driver of a configuration: make_golden_driver(), or 
make_golden_text_driver().
+using DriverFactory = std::function<Status(const GoldenConfig&, 
std::unique_ptr<GoldenDriver>*)>;
+
 // One configuration over one family in one mode.
 struct GoldenJob {
     const GoldenConfig* config = nullptr;
@@ -111,11 +115,11 @@ struct GoldenJob {
     double seconds = 0;
 };
 
-void run_job(GoldenJob* job, bool dump) {
+void run_job(GoldenJob* job, bool dump, const DriverFactory& make_driver) {
     const auto started = std::chrono::steady_clock::now();
     // A driver per job, so no family sees the state another one left.
     std::unique_ptr<GoldenDriver> driver;
-    if (const Status status = make_golden_driver(*job->config, &driver); 
!status.ok()) {
+    if (const Status status = make_driver(*job->config, &driver); 
!status.ok()) {
         job->error = status.to_string();
     } else {
         try {
@@ -222,31 +226,74 @@ void print_timing(const std::vector<GoldenJob>& jobs, 
double wall) {
     }
 }
 
-// Runs `configs` over `families` in `requested_modes`, and in the mode whose 
output is frozen, and
-// checks every digest against `file`. The jobs run on several threads; the 
checks run here, in
-// the order of the matrix.
-void check_golden(const std::vector<GoldenConfig>& configs,
-                  const std::vector<CorpusFamily>& families,
-                  const std::vector<DriveMode>& requested_modes, 
test::GoldenFile* file) {
+// The configurations ANALYSIS_GOLDEN_ONLY selects: those whose name contains 
it, or all.
+std::vector<const GoldenConfig*> selected_configs(const 
std::vector<GoldenConfig>& configs) {
     const std::string only = env("ANALYSIS_GOLDEN_ONLY");
-    const std::string dump_dir = env("ANALYSIS_GOLDEN_DUMP");
     std::vector<const GoldenConfig*> selected;
     for (const GoldenConfig& config : configs) {
         if (only.empty() || config.name.find(only) != std::string::npos) {
             selected.push_back(&config);
         }
     }
+    return selected;
+}
+
+// The families of `families` that `names` names, in their order.
+std::vector<CorpusFamily> families_named(std::vector<CorpusFamily> families,
+                                         const std::set<std::string>& names) {
+    std::erase_if(families,
+                  [&](const CorpusFamily& family) { return 
!names.contains(family.name); });
+    return families;
+}
+
+struct ThreadDigest {
+    FamilyDigest digest;
+    std::string error;
+};
+
+// The digests of `family` from `threads` threads that share `analyzer`, the 
even ones with a new
+// stream per line and the odd ones with one stream. A thread that failed has 
its error instead.
+std::vector<ThreadDigest> digests_on_threads(const TextAnalyzerPtr& analyzer,
+                                             const CorpusFamily& family, 
size_t threads) {
+    std::vector<ThreadDigest> results(threads);
+    std::vector<std::thread> workers;
+    for (size_t t = 0; t < threads; ++t) {
+        workers.emplace_back([&, t] {
+            try {
+                const auto driver = make_text_driver(analyzer);
+                results[t].digest = digest_family(
+                        *driver, t % 2 == 0 ? DriveMode::FRESH : 
DriveMode::REUSED, family);
+            } catch (const std::exception& e) {
+                results[t].error = e.what();
+            }
+        });
+    }
+    for (std::thread& worker : workers) {
+        worker.join();
+    }
+    return results;
+}
+
+// Runs `configs` over `families` in `requested_modes`, and in the mode whose 
output is frozen, and
+// checks every digest against `file`. The jobs run on several threads; the 
checks run here, in
+// the order of the matrix.
+void check_golden(const std::vector<GoldenConfig>& configs,
+                  const std::vector<CorpusFamily>& families,
+                  const std::vector<DriveMode>& requested_modes, 
test::GoldenFile* file,
+                  const DriverFactory& make_driver = make_golden_driver) {
+    const std::string dump_dir = env("ANALYSIS_GOLDEN_DUMP");
+    const std::vector<const GoldenConfig*> selected = 
selected_configs(configs);
     // Build each configuration and analyze one line before going parallel, so 
a process-wide
     // dictionary is loaded by one thread, as in a BE that has served a first 
query.
     for (const GoldenConfig* config : selected) {
         std::unique_ptr<GoldenDriver> driver;
-        const Status status = make_golden_driver(*config, &driver);
+        const Status status = make_driver(*config, &driver);
         ASSERT_TRUE(status.ok()) << status.to_string() << "\n" << 
describe(*config);
         driver->fresh("warm up 预热");
     }
     std::vector<GoldenJob> jobs = make_jobs(selected, families, 
requested_modes);
     const auto started = std::chrono::steady_clock::now();
-    parallel_for(jobs.size(), [&](size_t i) { run_job(&jobs[i], 
!dump_dir.empty()); });
+    parallel_for(jobs.size(), [&](size_t i) { run_job(&jobs[i], 
!dump_dir.empty(), make_driver); });
     const double wall =
             std::chrono::duration<double>(std::chrono::steady_clock::now() - 
started).count();
     for (size_t i = 0; i < jobs.size();) {
@@ -266,6 +313,15 @@ protected:
         ASSERT_TRUE(status.ok()) << status.to_string();
     }
 
+    // The configurations that build an analyzer.
+    std::vector<GoldenConfig> analyzer_configs() const {
+        std::vector<GoldenConfig> configs;
+        std::ranges::copy_if(_configs, std::back_inserter(configs), [](const 
GoldenConfig& config) {
+            return config.kind == GoldenKind::ANALYZE;
+        });
+        return configs;
+    }
+
     const std::vector<GoldenConfig> _configs = golden_configs();
     GoldenEnvironment _environment;
 };
@@ -277,6 +333,45 @@ TEST_F(AnalysisGolden, MatchesTheFrozenDigests) {
     file.finish(owns_analysis_line, env("ANALYSIS_GOLDEN_ONLY").empty());
 }
 
+// The consumers move to the TextAnalyzer interface before the analyzers do, 
through
+// LuceneBackedTextAnalyzer. Through it every analyzer gives the frozen 
output, with a new stream
+// per line and with one stream for all lines.
+TEST_F(AnalysisGolden, LuceneBackedTextAnalyzerMatchesTheFrozenDigests) {
+    test::GoldenFile file(kGoldenFile);
+    check_golden(analyzer_configs(), 
default_families(_environment.corpus_sources()),
+                 {DriveMode::FRESH, DriveMode::REUSED}, &file, 
make_golden_text_driver);
+    file.finish(owns_analysis_line, false);
+}
+
+// An analyzer serves many threads at once. Eight threads share the 
LuceneBackedTextAnalyzer of
+// each configuration, half of them with a new stream per line and half with 
one stream, and each
+// thread gives the frozen output.
+TEST_F(AnalysisGolden, EightThreadsShareOneTextAnalyzer) {
+    const std::vector<CorpusFamily> families = families_named(
+            default_families(_environment.corpus_sources()), {"mixed_zh_en", 
"invalid_utf8"});
+    ASSERT_EQ(families.size(), 2);
+    const std::vector<GoldenConfig> configs = analyzer_configs();
+    test::GoldenFile file(kGoldenFile);
+    for (const GoldenConfig* config : selected_configs(configs)) {
+        TextAnalyzerPtr analyzer;
+        const Status status = make_golden_text_analyzer(*config, &analyzer);
+        ASSERT_TRUE(status.ok()) << status.to_string() << "\n" << 
describe(*config);
+        for (const CorpusFamily& family : families) {
+            const std::vector<ThreadDigest> results = 
digests_on_threads(analyzer, family, 8);
+            for (size_t t = 0; t < results.size(); ++t) {
+                ASSERT_TRUE(results[t].error.empty())
+                        << config->name << " thread " << t << ": " << 
results[t].error;
+                EXPECT_EQ(results[t].digest, results[0].digest)
+                        << config->name << " " << family.name << ": thread " 
<< t
+                        << " disagrees with thread 0";
+            }
+            file.check(config->name, family.name, results[0].digest.tokens,
+                       results[0].digest.digest, describe(*config));
+        }
+    }
+    file.finish(owns_analysis_line, false);
+}
+
 // Every component the analysis factory registers is in some configuration, so 
a new component
 // cannot ship without frozen output.
 TEST_F(AnalysisGolden, EveryRegisteredComponentHasAConfiguration) {
@@ -332,12 +427,8 @@ TEST_F(AnalysisGolden, IcuIgnoresTheDefaultLocale) {
         return config.name.find("icu") != std::string::npos;
     });
     ASSERT_FALSE(icu_configs.empty());
-    std::vector<CorpusFamily> families;
-    for (CorpusFamily& family : 
default_families(_environment.corpus_sources())) {
-        if (family.name == "latin_words" || family.name == "bmp_code_points") {
-            families.push_back(std::move(family));
-        }
-    }
+    const std::vector<CorpusFamily> families = families_named(
+            default_families(_environment.corpus_sources()), {"latin_words", 
"bmp_code_points"});
     ASSERT_EQ(families.size(), 2);
     test::GoldenFile file(kGoldenFile);
     check_golden(icu_configs, families, {DriveMode::FRESH}, &file);
diff --git 
a/be/test/storage/index/inverted/analysis/golden/analysis_write_golden_test.cpp 
b/be/test/storage/index/inverted/analysis/golden/analysis_write_golden_test.cpp
index 9ab4309b19e..38d37d16bc2 100644
--- 
a/be/test/storage/index/inverted/analysis/golden/analysis_write_golden_test.cpp
+++ 
b/be/test/storage/index/inverted/analysis/golden/analysis_write_golden_test.cpp
@@ -27,8 +27,11 @@
 #include <gtest/gtest.h>
 
 #include <algorithm>
+#include <climits>
 #include <fstream>
+#include <functional>
 #include <memory>
+#include <set>
 #include <sstream>
 #include <string>
 #include <vector>
@@ -42,6 +45,10 @@
 #include "storage/index/inverted/analysis/golden/golden_configs.h"
 #include "storage/index/inverted/analysis/golden/golden_corpus.h"
 #include "storage/index/inverted/analysis/golden/golden_environment.h"
+#include "storage/index/inverted/analysis/inverted_index_parser.h"
+#include "storage/index/inverted/clucene/inverted_index_writer.h"
+#include "storage/index/inverted/clucene/lucene_analyzer_bridge.h"
+#include "storage/index/inverted/clucene/lucene_backed_text_analyzer.h"
 #include "storage/index/inverted/common/inverted_index_desc.h"
 #include "storage/tablet/tablet_schema.h"
 #include "testutil/golden_file.h"
@@ -66,6 +73,17 @@ constexpr const char* kConfigs[] = {
         "custom/char_replace+standard+word_delimiter+asciifolding+lowercase",
 };
 
+// The configurations of kConfigs whose array elements the V1 and V2 writers 
index nothing of. The
+// writer asks Analyzer::tokenStream() for a stream per element, ICUAnalyzer 
resets that stream
+// before it returns it, and SDocumentsWriter::invertField() resets it again. 
ICUTokenizer reads its
+// reader in reset(), so the second time it finds the reader consumed and 
makes no token: an
+// element "hello world" is indexed as nothing. LuceneAnalyzerBridge starts 
each element once, so
+// through it the elements are indexed. No table has such an index: the FE 
accepts no parser and no
+// analyzer on an array column (InvertedIndexUtil.checkInvertedIndexParser).
+const std::set<std::string> kOldArrayPathLosesElements = {
+        "builtin/icu/lower_case=unset/stopwords=unset",
+};
+
 struct Format {
     const char* name;
     InvertedIndexStorageFormatPB format;
@@ -115,6 +133,24 @@ TabletColumn text_column() {
     return column;
 }
 
+TabletColumn array_column() {
+    TabletColumn element;
+    element.set_name("text");
+    element.set_type(FieldType::OLAP_FIELD_TYPE_STRING);
+    element.set_length(INT_MAX);
+    element.set_is_nullable(true);
+    TabletColumn column;
+    column.set_unique_id(0);
+    column.set_name("texts");
+    column.set_type(FieldType::OLAP_FIELD_TYPE_ARRAY);
+    column.set_is_nullable(true);
+    column.add_sub_column(element);
+    return column;
+}
+
+// Called on the column writer before the rows go in.
+using WriterHook = std::function<void(IndexColumnWriter*)>;
+
 // What every index gets: the first lines of four families, one batch each, 
nulls after each.
 std::vector<std::vector<std::string>> row_batches(const 
std::vector<CorpusFamily>& families) {
     std::vector<std::vector<std::string>> batches;
@@ -131,7 +167,8 @@ std::vector<std::vector<std::string>> row_batches(const 
std::vector<CorpusFamily
 // Writes the batches through the production writer factory; returns the index 
path prefix.
 std::string write_index(const std::string& name, InvertedIndexStorageFormatPB 
format,
                         const TabletIndex& meta,
-                        const std::vector<std::vector<std::string>>& batches) {
+                        const std::vector<std::vector<std::string>>& batches,
+                        const WriterHook& before_rows = {}) {
     const std::string prefix = std::string(kTestDir) + "/" + name;
     const auto fs = io::global_local_filesystem();
     io::FileWriterPtr file_writer;
@@ -150,6 +187,9 @@ std::string write_index(const std::string& name, 
InvertedIndexStorageFormatPB fo
     if (!created.ok()) {
         return prefix;
     }
+    if (before_rows) {
+        before_rows(writer.get());
+    }
     for (const std::vector<std::string>& batch : batches) {
         std::vector<Slice> slices;
         for (const std::string& row : batch) {
@@ -166,6 +206,74 @@ std::string write_index(const std::string& name, 
InvertedIndexStorageFormatPB fo
     return prefix;
 }
 
+// The lines of the batches as array rows of 0 to 3 elements, with a null 
element now and then.
+struct ArrayRows {
+    std::vector<Slice> elements;
+    std::vector<uint8_t> null_map;
+    std::vector<uint64_t> offsets; // one more than the rows
+};
+
+ArrayRows array_rows(const std::vector<std::vector<std::string>>& batches) {
+    ArrayRows rows;
+    rows.offsets.push_back(0);
+    for (const std::vector<std::string>& batch : batches) {
+        for (size_t i = 0; i < batch.size();) {
+            const size_t size = std::min((rows.offsets.size() - 1) % 4, 
batch.size() - i);
+            for (size_t end = i + size; i < end; ++i) {
+                rows.null_map.push_back(rows.elements.size() % 7 == 3 ? 1 : 0);
+                rows.elements.emplace_back(batch[i]);
+            }
+            rows.offsets.push_back(rows.elements.size());
+        }
+    }
+    return rows;
+}
+
+// Writes the rows into the V2 index of an array column; returns the index 
path prefix.
+std::string write_array_index(const std::string& name, const TabletIndex& meta,
+                              const ArrayRows& rows, const WriterHook& 
before_rows = {}) {
+    const std::string prefix = std::string(kTestDir) + "/" + name;
+    const auto fs = io::global_local_filesystem();
+    io::FileWriterPtr file_writer;
+    EXPECT_TRUE(
+            
fs->create_file(InvertedIndexDescriptor::get_index_file_path_v2(prefix), 
&file_writer)
+                    .ok());
+    IndexFileWriter index_file_writer(fs, prefix, "write_golden_rowset", 
/*seg_id=*/0,
+                                      InvertedIndexStorageFormatPB::V2, 
std::move(file_writer),
+                                      /*can_use_ram_dir=*/true, 
/*tablet_id=*/7700);
+    const TabletColumn column = array_column();
+    std::unique_ptr<IndexColumnWriter> writer;
+    const Status created = IndexColumnWriter::create(&column, &writer, 
&index_file_writer, &meta);
+    EXPECT_TRUE(created.ok()) << created.to_string();
+    if (!created.ok()) {
+        return prefix;
+    }
+    if (before_rows) {
+        before_rows(writer.get());
+    }
+    const Status added = writer->add_array_values(
+            sizeof(Slice), reinterpret_cast<const 
uint8_t*>(rows.elements.data()),
+            rows.null_map.data(), rows.offsets.data(), rows.offsets.size() - 
1);
+    EXPECT_TRUE(added.ok()) << added.to_string();
+    EXPECT_TRUE(writer->finish().ok());
+    EXPECT_TRUE(index_file_writer.begin_close().ok());
+    EXPECT_TRUE(index_file_writer.finish_close().ok());
+    return prefix;
+}
+
+// Puts the V2 writer's analyzer behind LuceneBackedTextAnalyzer and 
LuceneAnalyzerBridge, as the
+// writer sees it once the consumers use the TextAnalyzer interface; the 
bridge reads each value
+// from a plain reader. The CLucene IndexWriter keeps a raw pointer to the 
analyzer it was made
+// with, which the LuceneBackedTextAnalyzer keeps alive. The index has no char 
filter of its own:
+// the writer gives array elements the index's char filter itself, which would 
apply it twice.
+void use_bridge(IndexColumnWriter* writer) {
+    auto* legacy =
+            
static_cast<InvertedIndexColumnWriter<FieldType::OLAP_FIELD_TYPE_STRING>*>(writer);
+    auto analyzer = 
std::make_shared<LuceneBackedTextAnalyzer>(legacy->_analyzer, CharFilterMap {});
+    
legacy->set_analysis_for_test(std::make_shared<lucene::util::SStringReader<char>>(),
+                                  
std::make_shared<LuceneAnalyzerBridge>(std::move(analyzer)));
+}
+
 struct WriteDigest {
     uint64_t count = 0;
     uint64_t digest = 0;
@@ -283,4 +391,44 @@ TEST_F(AnalysisWriteGolden, MatchesTheFrozenDigests) {
     file.finish([](const std::string& name) { return 
name.starts_with(kWritePrefix); }, true);
 }
 
+// The V1 and V2 writers reach the analyzers through LuceneAnalyzerBridge once 
the consumers use
+// the TextAnalyzer interface. Through the bridge, over the analyzer in a 
LuceneBackedTextAnalyzer,
+// the V2 writer writes the terms, documents and positions it writes with the 
analyzer itself: the
+// frozen ones for a string column, and for an array column, whose elements 
take the other way
+// into the analyzer, the ones the analyzer itself gives, except where it 
gives none
+// (kOldArrayPathLosesElements).
+TEST_F(AnalysisWriteGolden, LuceneAnalyzerBridgeWritesTheSameV2Index) {
+    const std::vector<std::vector<std::string>> batches =
+            row_batches(default_families(_environment.corpus_sources()));
+    const ArrayRows arrays = array_rows(batches);
+    test::GoldenFile file(kGoldenFile);
+    for (const char* name : kConfigs) {
+        const auto config = std::ranges::find_if(
+                _configs, [&](const GoldenConfig& c) { return c.name == name; 
});
+        ASSERT_NE(config, _configs.end()) << name;
+        
ASSERT_TRUE(get_parser_char_filter_map_from_properties(config->properties).empty())
 << name;
+        const TabletIndex meta = index_meta(*config);
+        const std::string id = std::to_string(config - _configs.begin());
+        constexpr auto kV2 = InvertedIndexStorageFormatPB::V2;
+
+        const WriteDigest scalar = clucene_digest(
+                write_index("bridge_" + id, kV2, meta, batches, use_bridge), 
kV2, meta);
+        file.check(std::string(kWritePrefix) + "v2/" + config->name, "rows", 
scalar.count,
+                   scalar.digest, describe(*config));
+
+        const WriteDigest array =
+                clucene_digest(write_array_index("array_" + id, meta, arrays), 
kV2, meta);
+        const WriteDigest bridged_array = clucene_digest(
+                write_array_index("array_bridge_" + id, meta, arrays, 
use_bridge), kV2, meta);
+        EXPECT_GT(bridged_array.count, 0) << name;
+        if (kOldArrayPathLosesElements.contains(name)) {
+            EXPECT_EQ(array.count, 0) << name;
+        } else {
+            EXPECT_EQ(bridged_array.count, array.count) << name;
+            EXPECT_EQ(bridged_array.digest, array.digest) << name;
+        }
+    }
+    file.finish([](const std::string& name) { return 
name.starts_with(kWritePrefix); }, false);
+}
+
 } // namespace doris::segment_v2::inverted_index::golden
diff --git a/be/test/storage/index/inverted/analysis/golden/golden_driver.cpp 
b/be/test/storage/index/inverted/analysis/golden/golden_driver.cpp
index b3720240d01..f58a7facade 100644
--- a/be/test/storage/index/inverted/analysis/golden/golden_driver.cpp
+++ b/be/test/storage/index/inverted/analysis/golden/golden_driver.cpp
@@ -37,8 +37,10 @@
 #include "storage/index/inverted/analysis/char_filter/char_filter_factory.h"
 #include "storage/index/inverted/analysis/inverted_index_parser.h"
 #include "storage/index/inverted/analysis/reader.h"
+#include "storage/index/inverted/analysis/token_batch.h"
 #include "storage/index/inverted/analysis/token_filter/token_filter_factory.h"
 #include "storage/index/inverted/analysis/tokenizer/tokenizer_factory.h"
+#include "storage/index/inverted/clucene/lucene_backed_text_analyzer.h"
 #include "util/hash_util.hpp"
 
 namespace doris::segment_v2::inverted_index::golden {
@@ -182,6 +184,66 @@ private:
     const std::map<std::string, std::string> _properties;
 };
 
+class TextDriver final : public GoldenDriver {
+public:
+    explicit TextDriver(TextAnalyzerPtr analyzer) : 
_analyzer(std::move(analyzer)) {}
+
+    GoldenLine fresh(std::string_view line) override {
+        std::unique_ptr<TextTokenStream> stream;
+        return read(&stream, line);
+    }
+
+    GoldenLine reused(std::string_view line) override { return read(&_stream, 
line); }
+
+private:
+    // Reads `line` with `*stream`, made first if there is none. A stream is 
not used again after
+    // it threw, so it is dropped then.
+    GoldenLine read(std::unique_ptr<TextTokenStream>* stream, std::string_view 
line) {
+        GoldenLine out;
+        try {
+            if (*stream == nullptr) {
+                *stream = _analyzer->create_stream();
+            }
+            (*stream)->reset(line);
+            while ((*stream)->next_batch(_batch)) {
+                for (const TokenRef& token : _batch.tokens()) {
+                    out.tokens.push_back({.bytes = std::string(token.term()),
+                                          .position_increment = 
token.position_increment});
+                }
+            }
+        } catch (const Exception&) {
+            out = threw_line();
+            stream->reset();
+            _batch.clear();
+        }
+        _batch.release_oversized();
+        return out;
+    }
+
+    const TextAnalyzerPtr _analyzer;
+    std::unique_ptr<TextTokenStream> _stream;
+    TokenBatch _batch;
+};
+
+// The analyzer configuration the writers read from `properties`, except that 
an unset lower_case
+// stays unset, as on the query path; lower_case=true covers what the writers 
pass.
+InvertedIndexAnalyzerConfig analyzer_config_of(
+        const std::map<std::string, std::string>& properties) {
+    InvertedIndexAnalyzerConfig analyzer_config;
+    analyzer_config.analyzer_name = 
get_analyzer_name_from_properties(properties);
+    analyzer_config.parser_type = get_inverted_index_parser_type_from_string(
+            get_parser_string_from_properties(properties));
+    analyzer_config.parser_mode = 
get_parser_mode_string_from_properties(properties);
+    analyzer_config.lower_case = 
get_parser_lowercase_from_properties(properties);
+    analyzer_config.stop_words = 
get_parser_stopwords_from_properties(properties);
+    analyzer_config.char_filter_map = 
get_parser_char_filter_map_from_properties(properties);
+    return analyzer_config;
+}
+
+AnalyzerPtr build_analyzer(const InvertedIndexAnalyzerConfig& analyzer_config) 
{
+    return 
InvertedIndexAnalyzer::create_analyzer_provider(&analyzer_config)->get_analyzer();
+}
+
 } // namespace
 
 FamilyDigest digest_family(GoldenDriver& driver, DriveMode mode, const 
CorpusFamily& family,
@@ -260,23 +322,10 @@ Status make_golden_driver(const GoldenConfig& config, 
std::unique_ptr<GoldenDriv
         *driver = std::make_unique<NormalizeDriver>(config.properties);
         return Status::OK();
     }
-    // The analyzer configuration the writers read from the properties, except 
that an unset
-    // lower_case stays unset, as on the query path; lower_case=true covers 
what the writers pass.
-    const auto& properties = config.properties;
-    InvertedIndexAnalyzerConfig analyzer_config;
-    analyzer_config.analyzer_name = 
get_analyzer_name_from_properties(properties);
-    analyzer_config.parser_type = get_inverted_index_parser_type_from_string(
-            get_parser_string_from_properties(properties));
-    analyzer_config.parser_mode = 
get_parser_mode_string_from_properties(properties);
-    analyzer_config.lower_case = 
get_parser_lowercase_from_properties(properties);
-    analyzer_config.stop_words = 
get_parser_stopwords_from_properties(properties);
-    analyzer_config.char_filter_map = 
get_parser_char_filter_map_from_properties(properties);
+    const InvertedIndexAnalyzerConfig analyzer_config = 
analyzer_config_of(config.properties);
     try {
         *driver = std::make_unique<LuceneDriver>(
-                [analyzer_config]() {
-                    return 
InvertedIndexAnalyzer::create_analyzer_provider(&analyzer_config)
-                            ->get_analyzer();
-                },
+                [analyzer_config]() { return build_analyzer(analyzer_config); 
},
                 analyzer_config.char_filter_map);
     } catch (const CLuceneError& e) {
         return Status::InternalError("building {} failed: {}", config.name, 
e.what());
@@ -286,6 +335,31 @@ Status make_golden_driver(const GoldenConfig& config, 
std::unique_ptr<GoldenDriv
     return Status::OK();
 }
 
+Status make_golden_text_analyzer(const GoldenConfig& config, TextAnalyzerPtr* 
analyzer) {
+    DORIS_CHECK(config.kind == GoldenKind::ANALYZE) << config.name;
+    const InvertedIndexAnalyzerConfig analyzer_config = 
analyzer_config_of(config.properties);
+    try {
+        *analyzer = 
std::make_shared<LuceneBackedTextAnalyzer>(build_analyzer(analyzer_config),
+                                                               
analyzer_config.char_filter_map);
+    } catch (const CLuceneError& e) {
+        return Status::InternalError("building {} failed: {}", config.name, 
e.what());
+    } catch (const Exception& e) {
+        return Status::InternalError("building {} failed: {}", config.name, 
e.what());
+    }
+    return Status::OK();
+}
+
+std::unique_ptr<GoldenDriver> make_text_driver(TextAnalyzerPtr analyzer) {
+    return std::make_unique<TextDriver>(std::move(analyzer));
+}
+
+Status make_golden_text_driver(const GoldenConfig& config, 
std::unique_ptr<GoldenDriver>* driver) {
+    TextAnalyzerPtr analyzer;
+    RETURN_IF_ERROR(make_golden_text_analyzer(config, &analyzer));
+    *driver = make_text_driver(std::move(analyzer));
+    return Status::OK();
+}
+
 std::vector<std::pair<std::string, std::string>> registered_components() {
     auto& factories = AnalysisFactoryMgr::instance();
     factories.initialise();
diff --git a/be/test/storage/index/inverted/analysis/golden/golden_driver.h 
b/be/test/storage/index/inverted/analysis/golden/golden_driver.h
index e2a445382c7..80f81aba8d0 100644
--- a/be/test/storage/index/inverted/analysis/golden/golden_driver.h
+++ b/be/test/storage/index/inverted/analysis/golden/golden_driver.h
@@ -28,6 +28,7 @@
 #include "common/status.h"
 #include "storage/index/inverted/analysis/golden/golden_configs.h"
 #include "storage/index/inverted/analysis/golden/golden_corpus.h"
+#include "storage/index/inverted/analysis/text_analyzer.h"
 
 namespace lucene::analysis {
 class Analyzer;
@@ -100,6 +101,17 @@ std::string format_line(const GoldenLine& line);
 // analyzer cannot be built.
 Status make_golden_driver(const GoldenConfig& config, 
std::unique_ptr<GoldenDriver>* driver);
 
+// The analyzer of a GoldenKind::ANALYZE `config` behind the TextAnalyzer 
interface: the one
+// make_golden_driver() drives, in a LuceneBackedTextAnalyzer.
+Status make_golden_text_analyzer(const GoldenConfig& config, TextAnalyzerPtr* 
analyzer);
+
+// Drives `analyzer` as the consumers of the TextAnalyzer interface do: FRESH 
makes a stream for
+// every line, as a query does, and REUSED keeps one stream for all lines, as 
a writer does.
+std::unique_ptr<GoldenDriver> make_text_driver(TextAnalyzerPtr analyzer);
+
+// make_golden_text_analyzer() in make_text_driver(), with the signature of 
make_golden_driver().
+Status make_golden_text_driver(const GoldenConfig& config, 
std::unique_ptr<GoldenDriver>* driver);
+
 // The kind ("tokenizer", "token_filter" or "char_filter") and name of every 
component the
 // analysis factory registers.
 std::vector<std::pair<std::string, std::string>> registered_components();
diff --git a/be/test/storage/index/inverted/analysis/text_stream_contract.cpp 
b/be/test/storage/index/inverted/analysis/text_stream_contract.cpp
new file mode 100644
index 00000000000..3ceba2ba591
--- /dev/null
+++ b/be/test/storage/index/inverted/analysis/text_stream_contract.cpp
@@ -0,0 +1,80 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+#include "storage/index/inverted/analysis/text_stream_contract.h"
+
+#include <gtest/gtest.h>
+
+namespace doris::segment_v2::inverted_index {
+
+std::ostream& operator<<(std::ostream& out, const CollectedToken& token) {
+    return out << "+" << token.position_increment << "\"" << token.term << 
"\"";
+}
+
+std::vector<CollectedToken> collect_checked(TextTokenStream& stream, 
std::string_view text) {
+    constexpr std::string_view kStale = "stale token";
+    std::vector<CollectedToken> tokens;
+    TokenBatch batch;
+    stream.reset(text);
+    for (;;) {
+        batch.push(kStale, 1);
+        if (!stream.next_batch(batch)) {
+            break;
+        }
+        EXPECT_GT(batch.size(), 0) << "next_batch() returned true with no 
token";
+        for (const TokenRef& token : batch.tokens()) {
+            EXPECT_GE(token.position_increment, 0);
+            tokens.push_back({.term = std::string(token.term()),
+                              .position_increment = token.position_increment});
+        }
+    }
+    EXPECT_EQ(batch.size(), 0) << "next_batch() returned false with tokens in 
the batch";
+    batch.push(kStale, 1);
+    EXPECT_FALSE(stream.next_batch(batch)) << "next_batch() returned true 
after it returned false";
+    EXPECT_EQ(batch.size(), 0);
+    return tokens;
+}
+
+void expect_text_stream_contract(const TextStreamFactory& make_stream,
+                                 const std::vector<std::string>& texts) {
+    const std::unique_ptr<TextTokenStream> reused = make_stream();
+    std::vector<std::vector<CollectedToken>> expected;
+    for (const std::string& text : texts) {
+        const std::unique_ptr<TextTokenStream> fresh = make_stream();
+        expected.push_back(collect_checked(*fresh, text));
+        EXPECT_EQ(collect_checked(*reused, text), expected.back())
+                << "a reused stream disagrees with a new one on \"" << text << 
"\"";
+    }
+
+    // NFKC_CF can fold a value to an empty view that does not point to null.
+    const std::string not_null = "x";
+    EXPECT_TRUE(collect_checked(*reused, std::string_view()).empty());
+    EXPECT_TRUE(collect_checked(*reused, std::string_view(not_null.data(), 
0)).empty());
+
+    // Each text cut after its first batch, then the next text, which comes 
out whole.
+    for (size_t i = 0; i < texts.size(); ++i) {
+        TokenBatch batch;
+        reused->reset(texts[i]);
+        reused->next_batch(batch);
+        const size_t next = (i + 1) % texts.size();
+        EXPECT_EQ(collect_checked(*reused, texts[next]), expected[next])
+                << "\"" << texts[next] << "\" after a reset in the middle of 
\"" << texts[i]
+                << "\"";
+    }
+}
+
+} // namespace doris::segment_v2::inverted_index
diff --git a/be/test/storage/index/inverted/analysis/text_stream_contract.h 
b/be/test/storage/index/inverted/analysis/text_stream_contract.h
new file mode 100644
index 00000000000..870bef1fa17
--- /dev/null
+++ b/be/test/storage/index/inverted/analysis/text_stream_contract.h
@@ -0,0 +1,56 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+#pragma once
+
+#include <cstdint>
+#include <functional>
+#include <memory>
+#include <ostream>
+#include <string>
+#include <string_view>
+#include <vector>
+
+#include "storage/index/inverted/analysis/text_token_stream.h"
+
+namespace doris::segment_v2::inverted_index {
+
+struct CollectedToken {
+    std::string term;
+    int32_t position_increment = 0;
+
+    bool operator==(const CollectedToken&) const = default;
+};
+
+std::ostream& operator<<(std::ostream& out, const CollectedToken& token);
+
+// Reads `text` with `stream`, checking every next_batch() call against the 
TextTokenStream
+// contract: true comes with at least one token and no negative increment, 
false with an empty
+// batch, and false again on the call after. The batch holds a stale token 
before every call, so a
+// stream that does not clear it fails.
+std::vector<CollectedToken> collect_checked(TextTokenStream& stream, 
std::string_view text);
+
+using TextStreamFactory = std::function<std::unique_ptr<TextTokenStream>()>;
+
+// The whole contract, for streams from `make_stream`: one stream reused over 
`texts` gives each
+// text the tokens a stream of its own gives it; an empty value gives no 
token, whatever its data
+// pointer; and a reset() after the first batch of a value drops the rest of 
it, which `texts`
+// exercises when one of them makes more than one batch.
+void expect_text_stream_contract(const TextStreamFactory& make_stream,
+                                 const std::vector<std::string>& texts);
+
+} // namespace doris::segment_v2::inverted_index
diff --git a/be/test/storage/index/inverted/analysis/text_token_stream_test.cpp 
b/be/test/storage/index/inverted/analysis/text_token_stream_test.cpp
new file mode 100644
index 00000000000..be83112c12c
--- /dev/null
+++ b/be/test/storage/index/inverted/analysis/text_token_stream_test.cpp
@@ -0,0 +1,471 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+#include <fmt/format.h>
+#include <gtest/gtest.h>
+
+#include <algorithm>
+#include <cctype>
+#include <memory>
+#include <string>
+#include <string_view>
+#include <utility>
+#include <vector>
+
+#include "storage/index/inverted/analysis/per_token_stream.h"
+#include "storage/index/inverted/analysis/scratch.h"
+#include "storage/index/inverted/analysis/text_stream_contract.h"
+#include "storage/index/inverted/analysis/token_batch.h"
+
+namespace doris::segment_v2::inverted_index {
+
+namespace {
+
+TokenRef ref(std::string_view term, int32_t position_increment) {
+    return {.data = term.data(),
+            .size = static_cast<uint32_t>(term.size()),
+            .position_increment = position_increment};
+}
+
+// Words separated by spaces, each built in one reused scratch string: a base 
that did not copy the
+// terms would hand out the last word of a batch over and over.
+class WordTokenizer final : public PerTokenTokenizer {
+public:
+    size_t reserved_bytes() const override { return _scratch.capacity(); }
+
+    // next_token() calls after it returned false.
+    int calls_after_end = 0;
+
+protected:
+    void on_reset(std::string_view text) override {
+        _text = text;
+        _position = 0;
+        _ended = false;
+    }
+
+    bool next_token(TokenRef& token) override {
+        if (_ended) {
+            ++calls_after_end;
+            return false;
+        }
+        while (_position < _text.size() && _text[_position] == ' ') {
+            ++_position;
+        }
+        if (_position == _text.size()) {
+            _ended = true;
+            return false;
+        }
+        const size_t end = std::min(_text.find(' ', _position), _text.size());
+        _scratch.assign(_text.substr(_position, end - _position));
+        _position = end;
+        token = ref(_scratch, 1);
+        return true;
+    }
+
+private:
+    std::string_view _text;
+    size_t _position = 0;
+    bool _ended = true;
+    std::string _scratch;
+};
+
+// Each token, then its upper-case twin at the same position: two tokens out 
of one.
+class UpperCaseTwinFilter final : public PerTokenFilter {
+public:
+    using PerTokenFilter::PerTokenFilter;
+
+    size_t reserved_bytes() const override {
+        return _twin.capacity() + PerTokenFilter::reserved_bytes();
+    }
+
+protected:
+    void on_reset() override { _twin_pending = false; }
+
+    bool next_token(TokenRef& token) override {
+        if (_twin_pending) {
+            _twin_pending = false;
+            token = ref(_twin, 0);
+            return true;
+        }
+        if (!input_token(token)) {
+            return false;
+        }
+        _twin.assign(token.term());
+        std::ranges::transform(_twin, _twin.begin(),
+                               [](char c) { return 
static_cast<char>(std::toupper(c)); });
+        _twin_pending = true;
+        return true;
+    }
+
+private:
+    std::string _twin;
+    bool _twin_pending = false;
+};
+
+// Keeps every n-th token and adds the increments of the dropped ones to it, 
as a stop filter does.
+// With n above the token count it drops every token.
+class KeepEveryNthFilter final : public PerTokenFilter {
+public:
+    KeepEveryNthFilter(std::unique_ptr<TextTokenStream> in, size_t n)
+            : PerTokenFilter(std::move(in)), _n(n) {}
+
+    size_t reserved_bytes() const override { return 
PerTokenFilter::reserved_bytes(); }
+
+    // next_token() calls after it returned false.
+    int calls_after_end = 0;
+
+protected:
+    void on_reset() override {
+        _seen = 0;
+        _ended = false;
+    }
+
+    bool next_token(TokenRef& token) override {
+        if (_ended) {
+            ++calls_after_end;
+            return false;
+        }
+        int32_t dropped = 0;
+        while (input_token(token)) {
+            if (++_seen % _n == 0) {
+                token.position_increment += dropped;
+                return true;
+            }
+            dropped += token.position_increment;
+        }
+        _ended = true;
+        return false;
+    }
+
+private:
+    const size_t _n;
+    size_t _seen = 0;
+    bool _ended = true;
+};
+
+// The characters of each token, one token each. It holds the input token 
while it hands out the
+// characters, across next_batch() calls when the token is long, as 
word_delimiter holds a token
+// while it hands out its parts.
+class CharacterFilter final : public PerTokenFilter {
+public:
+    using PerTokenFilter::PerTokenFilter;
+
+    size_t reserved_bytes() const override { return 
PerTokenFilter::reserved_bytes(); }
+
+protected:
+    void on_reset() override {
+        _current = TokenRef();
+        _next = 0;
+    }
+
+    bool next_token(TokenRef& token) override {
+        while (_next == _current.size) {
+            if (!input_token(_current)) {
+                return false;
+            }
+            _next = 0;
+        }
+        token = ref(std::string_view(_current.data + _next, 1), 1);
+        ++_next;
+        return true;
+    }
+
+private:
+    TokenRef _current;
+    size_t _next = 0;
+};
+
+std::string words(size_t count, std::string_view prefix = "w") {
+    std::string text;
+    for (size_t i = 0; i < count; ++i) {
+        text += fmt::format("{}{} ", prefix, i);
+    }
+    return text;
+}
+
+std::vector<CollectedToken> word_tokens(size_t count, std::string_view prefix 
= "w") {
+    std::vector<CollectedToken> tokens;
+    for (size_t i = 0; i < count; ++i) {
+        tokens.push_back({.term = fmt::format("{}{}", prefix, i), 
.position_increment = 1});
+    }
+    return tokens;
+}
+
+// The sizes of the batches `stream` hands out for `text`.
+std::vector<size_t> batch_sizes(TextTokenStream& stream, std::string_view 
text) {
+    std::vector<size_t> sizes;
+    TokenBatch batch;
+    stream.reset(text);
+    while (stream.next_batch(batch)) {
+        sizes.push_back(batch.size());
+    }
+    return sizes;
+}
+
+std::unique_ptr<TextTokenStream> word_tokenizer() {
+    return std::make_unique<WordTokenizer>();
+}
+
+} // namespace
+
+TEST(TokenBatchTest, PushKeepsTheViewAndPushCopyCopies) {
+    std::string source = "abc";
+    TokenBatch batch;
+    batch.push(source, 1);
+    batch.push_copy(source, 2);
+    char* built = batch.allocate_term(3);
+    std::copy_n("def", 3, built);
+    batch.push({built, 3}, 0);
+    batch.push_copy({}, 1);
+    source[0] = 'X';
+
+    ASSERT_EQ(batch.size(), 4);
+    EXPECT_EQ(batch.tokens()[0].term(), "Xbc");
+    EXPECT_EQ(batch.tokens()[1].term(), "abc");
+    EXPECT_EQ(batch.tokens()[1].position_increment, 2);
+    EXPECT_EQ(batch.tokens()[2].term(), "def");
+    EXPECT_EQ(batch.tokens()[2].position_increment, 0);
+    EXPECT_EQ(batch.tokens()[3].size, 0);
+}
+
+// Term storage grows by adding blocks; a term copied before keeps its address 
and its bytes.
+TEST(TokenBatchTest, CopiedTermsStayPutWhileTermStorageGrows) {
+    TokenBatch batch;
+    std::vector<std::string> expected;
+    for (size_t i = 0; i < 10000; ++i) {
+        expected.push_back(fmt::format("term{}-{}", i, std::string(i % 97, 
'x')));
+        batch.push_copy(expected.back(), 1);
+    }
+    // Far more than the first 4 KiB block, so the terms are spread over 
several blocks.
+    ASSERT_GT(batch._terms.size(), 64 * 4096);
+    ASSERT_EQ(batch.size(), expected.size());
+    for (size_t i = 0; i < expected.size(); ++i) {
+        ASSERT_EQ(batch.tokens()[i].term(), expected[i]) << i;
+    }
+}
+
+TEST(TokenBatchTest, ClearKeepsTheTokenArrayAndTheLargestBlock) {
+    TokenBatch batch;
+    for (size_t i = 0; i < 3000; ++i) {
+        batch.push_copy(std::string(40, static_cast<char>('a' + i % 26)), 1);
+    }
+    const size_t token_capacity = batch._tokens.capacity();
+    const TokenRef* token_array = batch._tokens.data();
+    const size_t term_bytes = batch._terms.size();
+
+    batch.clear();
+    EXPECT_EQ(batch.size(), 0);
+    EXPECT_EQ(batch._tokens.capacity(), token_capacity);
+    // The blocks before the largest are freed, the largest stays.
+    EXPECT_LT(batch._terms.size(), term_bytes);
+    EXPECT_GT(batch._terms.size(), 0);
+
+    // A batch the largest block holds allocates nothing.
+    const size_t kept = batch.capacity_bytes();
+    for (size_t i = 0; i < 100; ++i) {
+        batch.push_copy(std::string(40, 'z'), 1);
+    }
+    EXPECT_EQ(batch.capacity_bytes(), kept);
+    EXPECT_EQ(batch._tokens.data(), token_array);
+}
+
+TEST(TokenBatchTest, ReleaseOversizedKeepsAtMostTheHighWaterMark) {
+    // An ordinary batch keeps its memory.
+    TokenBatch ordinary;
+    for (size_t i = 0; i < TextTokenStream::kBatchTokens; ++i) {
+        ordinary.push_copy("word", 1);
+    }
+    ordinary.clear();
+    const size_t kept = ordinary.capacity_bytes();
+    ordinary.release_oversized();
+    EXPECT_GT(kept, 0);
+    EXPECT_EQ(ordinary.capacity_bytes(), kept);
+
+    // Many tokens, then long terms: each leaves more than the mark behind, 
and gives it back.
+    TokenBatch many;
+    for (size_t i = 0; i < 10000; ++i) {
+        many.push("w", 1);
+    }
+    many.clear();
+    ASSERT_GT(many.capacity_bytes(), ANALYZER_SCRATCH_HIGH_WATER_BYTES);
+    many.release_oversized();
+    EXPECT_LE(many.capacity_bytes(), ANALYZER_SCRATCH_HIGH_WATER_BYTES);
+
+    TokenBatch long_terms;
+    long_terms.push_copy(std::string(1 << 20, 'x'), 1);
+    long_terms.clear();
+    ASSERT_GT(long_terms.capacity_bytes(), ANALYZER_SCRATCH_HIGH_WATER_BYTES);
+    long_terms.release_oversized();
+    EXPECT_LE(long_terms.capacity_bytes(), ANALYZER_SCRATCH_HIGH_WATER_BYTES);
+
+    long_terms.push_copy("again", 1);
+    EXPECT_EQ(long_terms.tokens()[0].term(), "again");
+}
+
+TEST(PerTokenStreamTest, TokenizerCopiesEveryTermOutOfItsScratch) {
+    WordTokenizer tokenizer;
+    EXPECT_EQ(collect_checked(tokenizer, " alpha  beta gamma "),
+              (std::vector<CollectedToken> {{.term = "alpha", 
.position_increment = 1},
+                                            {.term = "beta", 
.position_increment = 1},
+                                            {.term = "gamma", 
.position_increment = 1}}));
+    EXPECT_EQ(collect_checked(tokenizer, words(1000)), word_tokens(1000));
+}
+
+TEST(PerTokenStreamTest, BatchesHoldAtMostKBatchTokens) {
+    WordTokenizer tokenizer;
+    EXPECT_EQ(batch_sizes(tokenizer, words(1000)), (std::vector<size_t> {256, 
256, 256, 232}));
+    // A value of exactly one batch: the next call finds it exhausted and 
returns false.
+    EXPECT_EQ(batch_sizes(tokenizer, words(256)), (std::vector<size_t> {256}));
+    EXPECT_TRUE(batch_sizes(tokenizer, "   ").empty());
+}
+
+TEST(PerTokenStreamTest, NextTokenIsNotCalledAgainAfterItReturnedFalse) {
+    WordTokenizer tokenizer;
+    TokenBatch batch;
+    tokenizer.reset("a b");
+    EXPECT_TRUE(tokenizer.next_batch(batch));
+    EXPECT_FALSE(tokenizer.next_batch(batch));
+    EXPECT_FALSE(tokenizer.next_batch(batch));
+    EXPECT_EQ(tokenizer.calls_after_end, 0);
+
+    auto filter = std::make_unique<KeepEveryNthFilter>(word_tokenizer(), 2);
+    const std::string ten = words(10);
+    filter->reset(ten);
+    while (filter->next_batch(batch)) {
+    }
+    EXPECT_FALSE(filter->next_batch(batch));
+    EXPECT_EQ(filter->calls_after_end, 0);
+}
+
+TEST(PerTokenStreamTest, NothingComesOutBeforeAValueIsBound) {
+    WordTokenizer tokenizer;
+    TokenBatch batch;
+    EXPECT_FALSE(tokenizer.next_batch(batch));
+    UpperCaseTwinFilter filter(word_tokenizer());
+    EXPECT_FALSE(filter.next_batch(batch));
+    EXPECT_EQ(batch.size(), 0);
+}
+
+TEST(PerTokenStreamTest, FilterMakesSeveralTokensOutOfOne) {
+    UpperCaseTwinFilter filter(word_tokenizer());
+    const std::vector<CollectedToken> tokens = collect_checked(filter, 
words(300));
+    ASSERT_EQ(tokens.size(), 600);
+    for (size_t i = 0; i < 300; ++i) {
+        EXPECT_EQ(tokens[2 * i],
+                  (CollectedToken {.term = fmt::format("w{}", i), 
.position_increment = 1}));
+        EXPECT_EQ(tokens[2 * i + 1],
+                  (CollectedToken {.term = fmt::format("W{}", i), 
.position_increment = 0}));
+    }
+    EXPECT_EQ(batch_sizes(filter, words(300)), (std::vector<size_t> {256, 256, 
88}));
+}
+
+// A filter that drops every token of a batch keeps pulling its input: no call 
returns true with an
+// empty batch, and a value whose tokens are all dropped makes the first call 
return false.
+TEST(PerTokenStreamTest, FilterThatDropsTokensReturnsNoEmptyBatch) {
+    KeepEveryNthFilter drop_all(word_tokenizer(), 1000);
+    TokenBatch batch;
+    const std::string text = words(999);
+    drop_all.reset(text);
+    EXPECT_FALSE(drop_all.next_batch(batch));
+    EXPECT_EQ(batch.size(), 0);
+
+    // Three of 1000 tokens survive, from input batches far apart.
+    KeepEveryNthFilter keep_few(word_tokenizer(), 300);
+    EXPECT_EQ(collect_checked(keep_few, words(1000)),
+              (std::vector<CollectedToken> {{.term = "w299", 
.position_increment = 300},
+                                            {.term = "w599", 
.position_increment = 300},
+                                            {.term = "w899", 
.position_increment = 300}}));
+}
+
+// The filter hands out the characters of a 300-character token across two 
batches while it holds
+// the token, and the input batch the token lives in stays as it is meanwhile.
+TEST(PerTokenStreamTest, FilterHoldsAnInputTokenAcrossBatches) {
+    const std::string first(300, 'a');
+    const std::string second = "bcd";
+    CharacterFilter filter(word_tokenizer());
+    EXPECT_EQ(batch_sizes(filter, first + " " + second), (std::vector<size_t> 
{256, 47}));
+    const std::vector<CollectedToken> tokens = collect_checked(filter, first + 
" " + second);
+    ASSERT_EQ(tokens.size(), 303);
+    for (size_t i = 0; i < 300; ++i) {
+        EXPECT_EQ(tokens[i].term, "a") << i;
+    }
+    EXPECT_EQ(tokens[300].term, "b");
+    EXPECT_EQ(tokens[302].term, "d");
+}
+
+TEST(PerTokenStreamTest, ResetDropsTheRestOfAValue) {
+    TokenBatch batch;
+    WordTokenizer tokenizer;
+    // A value must stay valid while the stream reads it, so it is not a 
temporary.
+    const std::string long_text = words(1000);
+    tokenizer.reset(long_text);
+    ASSERT_TRUE(tokenizer.next_batch(batch));
+    EXPECT_EQ(collect_checked(tokenizer, "x y"),
+              (std::vector<CollectedToken> {{.term = "x", .position_increment 
= 1},
+                                            {.term = "y", .position_increment 
= 1}}));
+
+    UpperCaseTwinFilter twins(word_tokenizer());
+    twins.reset(long_text);
+    ASSERT_TRUE(twins.next_batch(batch));
+    EXPECT_EQ(collect_checked(twins, "x"),
+              (std::vector<CollectedToken> {{.term = "x", .position_increment 
= 1},
+                                            {.term = "X", .position_increment 
= 0}}));
+
+    // Cut in the middle of a held token.
+    CharacterFilter characters(word_tokenizer());
+    const std::string long_word(300, 'a');
+    characters.reset(long_word);
+    ASSERT_TRUE(characters.next_batch(batch));
+    EXPECT_EQ(collect_checked(characters, "bc"),
+              (std::vector<CollectedToken> {{.term = "b", .position_increment 
= 1},
+                                            {.term = "c", .position_increment 
= 1}}));
+}
+
+TEST(PerTokenStreamTest, 
ReservedBytesAddUpTheChainAndResetReleasesTheInputBatch) {
+    UpperCaseTwinFilter filter(word_tokenizer());
+    // One word of 1 MiB: the input batch copies it, so it holds more than the 
mark.
+    const std::string huge(1 << 20, 'h');
+    TokenBatch batch;
+    filter.reset(huge);
+    ASSERT_TRUE(filter.next_batch(batch));
+    const auto& tokenizer = static_cast<const WordTokenizer&>(*filter._in);
+    EXPECT_GT(filter._input.capacity_bytes(), 
ANALYZER_SCRATCH_HIGH_WATER_BYTES);
+    EXPECT_EQ(filter.reserved_bytes(), filter._twin.capacity() + 
filter._input.capacity_bytes() +
+                                               tokenizer.reserved_bytes());
+
+    filter.reset("short");
+    EXPECT_LE(filter._input.capacity_bytes(), 
ANALYZER_SCRATCH_HIGH_WATER_BYTES);
+    EXPECT_EQ(filter.reserved_bytes(), filter._twin.capacity() + 
filter._input.capacity_bytes() +
+                                               tokenizer.reserved_bytes());
+}
+
+TEST(PerTokenStreamTest, TestStreamsKeepTheContract) {
+    const std::vector<std::string> texts = {
+            "one", " a  b c ", words(1000), words(256), std::string(300, 'a'), 
"x"};
+    expect_text_stream_contract(word_tokenizer, texts);
+    expect_text_stream_contract(
+            [] { return 
std::make_unique<UpperCaseTwinFilter>(word_tokenizer()); }, texts);
+    expect_text_stream_contract(
+            [] { return std::make_unique<KeepEveryNthFilter>(word_tokenizer(), 
3); }, texts);
+    expect_text_stream_contract([] { return 
std::make_unique<CharacterFilter>(word_tokenizer()); },
+                                texts);
+    // Every token dropped, for every text.
+    expect_text_stream_contract(
+            [] { return std::make_unique<KeepEveryNthFilter>(word_tokenizer(), 
100000); }, texts);
+}
+
+} // namespace doris::segment_v2::inverted_index
diff --git a/be/test/storage/index/inverted/clucene/lucene_adapter_test.cpp 
b/be/test/storage/index/inverted/clucene/lucene_adapter_test.cpp
new file mode 100644
index 00000000000..9d19460ef3c
--- /dev/null
+++ b/be/test/storage/index/inverted/clucene/lucene_adapter_test.cpp
@@ -0,0 +1,455 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// The two adapters between the CLucene analysis interfaces and the 
TextAnalyzer interface:
+// LuceneBackedTextAnalyzer, a CLucene analyzer for the new consumers, and 
LuceneAnalyzerBridge, a
+// TextAnalyzer for the V1 and V2 writers.
+
+#include <CLucene.h>
+#include <fmt/format.h>
+#include <gen_cpp/olap_file.pb.h>
+#include <gtest/gtest.h>
+
+#include <climits>
+#include <functional>
+#include <memory>
+#include <string>
+#include <string_view>
+#include <utility>
+#include <vector>
+
+#include "common/exception.h"
+#include "common/status.h"
+#include "io/fs/local_file_system.h"
+#include "storage/index/index_file_writer.h"
+#include "storage/index/index_writer.h"
+#include "storage/index/inverted/analysis/analyzer/analyzer.h"
+#include "storage/index/inverted/analysis/inverted_index_parser.h"
+#include "storage/index/inverted/analysis/text_stream_contract.h"
+#include "storage/index/inverted/clucene/inverted_index_writer.h"
+#include "storage/index/inverted/clucene/lucene_analyzer_bridge.h"
+#include "storage/index/inverted/clucene/lucene_backed_text_analyzer.h"
+#include "storage/index/inverted/common/inverted_index_desc.h"
+#include "storage/tablet/tablet_schema.h"
+#include "util/slice.h"
+
+namespace doris::segment_v2::inverted_index {
+
+namespace {
+
+constexpr const char* kTestDir = "./ut_dir/lucene_adapter_test";
+
+using Tokens = std::vector<CollectedToken>;
+
+std::string words(size_t count) {
+    std::string text;
+    for (size_t i = 0; i < count; ++i) {
+        text += fmt::format("w{} ", i);
+    }
+    return text;
+}
+
+std::string read_all(lucene::util::Reader* reader) {
+    const char* start = nullptr;
+    const int32_t length = reader->read(reinterpret_cast<const 
void**>(&start), 0,
+                                        static_cast<int32_t>(reader->size()));
+    return length > 0 ? std::string(start, length) : std::string();
+}
+
+// A word of a script value: words are separated by spaces, "_" is an empty 
term, a leading '^'
+// puts the word at the position of the one before, and "!" is a failure where 
it stands.
+struct ScriptWord {
+    std::string_view term;
+    int32_t position_increment = 1;
+    bool fails = false;
+};
+
+// The next word of `text` from `*position`, or false at the end.
+bool next_script_word(std::string_view text, size_t* position, ScriptWord* 
word) {
+    while (*position < text.size() && text[*position] == ' ') {
+        ++*position;
+    }
+    if (*position == text.size()) {
+        return false;
+    }
+    const size_t end = std::min(text.find(' ', *position), text.size());
+    std::string_view term = text.substr(*position, end - *position);
+    *position = end;
+    *word = ScriptWord();
+    if (term == "!") {
+        word->fails = true;
+        return true;
+    }
+    if (term.starts_with('^')) {
+        word->position_increment = 0;
+        term.remove_prefix(1);
+    }
+    word->term = term == "_" ? term.substr(0, 0) : term;
+    return true;
+}
+
+// A CLucene stream over a script value; "!" throws a CLuceneError.
+class ScriptedLuceneStream final : public lucene::analysis::TokenStream {
+public:
+    explicit ScriptedLuceneStream(std::string text) : _text(std::move(text)) {}
+
+    lucene::analysis::Token* next(lucene::analysis::Token* token) override {
+        ScriptWord word;
+        if (!next_script_word(_text, &_position, &word)) {
+            return nullptr;
+        }
+        if (word.fails) {
+            _CLTHROWA(CL_ERR_IO, "scripted CLucene failure");
+        }
+        token->setTextNoCopy(word.term.data(), 
static_cast<int32_t>(word.term.size()));
+        token->setPositionIncrement(word.position_increment);
+        return token;
+    }
+
+    void close() override {}
+
+private:
+    const std::string _text;
+    size_t _position = 0;
+};
+
+// A CLucene analyzer of script values; the value "!!" makes tokenStream() 
itself throw.
+class ScriptedLuceneAnalyzer final : public lucene::analysis::Analyzer {
+public:
+    bool isSDocOpt() override { return true; }
+
+    lucene::analysis::TokenStream* tokenStream(const TCHAR*, const ReaderPtr& 
reader) override {
+        std::string text = read_all(reader.get());
+        if (text == "!!") {
+            _CLTHROWA(CL_ERR_IO, "scripted CLucene failure in tokenStream");
+        }
+        return new ScriptedLuceneStream(std::move(text));
+    }
+
+    lucene::analysis::TokenStream* tokenStream(const TCHAR*, 
lucene::util::Reader*) override {
+        throw Exception(ErrorCode::INVERTED_INDEX_NOT_SUPPORTED, "not used");
+    }
+
+    lucene::analysis::TokenStream* reusableTokenStream(const TCHAR*,
+                                                       lucene::util::Reader*) 
override {
+        throw Exception(ErrorCode::INVERTED_INDEX_NOT_SUPPORTED, "not used");
+    }
+
+    lucene::analysis::TokenStream* reusableTokenStream(const TCHAR*, const 
ReaderPtr&) override {
+        throw Exception(ErrorCode::INVERTED_INDEX_NOT_SUPPORTED, "not used");
+    }
+};
+
+// A native stream over a script value, handing out views of it; "!" throws a 
doris::Exception
+// from next_batch(), and a value starting with "!reset" throws from reset().
+class ScriptedTextStream final : public TextTokenizer {
+public:
+    void reset(std::string_view text) override {
+        if (text.starts_with("!reset")) {
+            throw Exception(ErrorCode::INVERTED_INDEX_ANALYZER_ERROR, 
"scripted failure in reset");
+        }
+        _text = text;
+        _position = 0;
+    }
+
+    bool next_batch(TokenBatch& batch) override {
+        batch.clear();
+        ScriptWord word;
+        while (batch.size() < kBatchTokens && next_script_word(_text, 
&_position, &word)) {
+            if (word.fails) {
+                throw Exception(ErrorCode::INVERTED_INDEX_ANALYZER_ERROR, 
"scripted failure");
+            }
+            batch.push(word.term, word.position_increment);
+        }
+        return batch.size() != 0;
+    }
+
+    size_t reserved_bytes() const override { return 0; }
+
+private:
+    std::string_view _text;
+    size_t _position = 0;
+};
+
+class ScriptedTextAnalyzer final : public TextAnalyzer {
+public:
+    std::unique_ptr<TextTokenStream> create_stream() const override {
+        return std::make_unique<ScriptedTextStream>();
+    }
+};
+
+const Tokens kScriptTokens = {{.term = "a", .position_increment = 1},
+                              {.term = "b", .position_increment = 0},
+                              {.term = "", .position_increment = 1},
+                              {.term = "c", .position_increment = 1}};
+
+ReaderPtr reader_of(const std::string& text) {
+    auto reader = std::make_shared<lucene::util::SStringReader<char>>();
+    reader->init(text.data(), static_cast<int32_t>(text.size()), false);
+    return reader;
+}
+
+// The tokens of a bridged stream, read as SDocumentsWriter::invertField() 
reads a field: reset(),
+// next() until it returns null with one token object, close().
+Tokens lucene_tokens(lucene::analysis::TokenStream* stream) {
+    Tokens tokens;
+    lucene::analysis::Token token;
+    stream->reset();
+    while (stream->next(&token) != nullptr) {
+        tokens.push_back({.term = std::string(token.termBuffer<char>(), 
token.termLength<char>()),
+                          .position_increment = token.getPositionIncrement()});
+    }
+    stream->close();
+    return tokens;
+}
+
+// The code of the doris::Exception `body` throws, or OK.
+int thrown_code(const std::function<void()>& body) {
+    try {
+        body();
+    } catch (const Exception& e) {
+        return e.code();
+    }
+    return ErrorCode::OK;
+}
+
+} // namespace
+
+TEST(LuceneBackedTextAnalyzerTest, HandsOutTheCLuceneTokens) {
+    const LuceneBackedTextAnalyzer 
analyzer(std::make_shared<ScriptedLuceneAnalyzer>(), {});
+    const auto stream = analyzer.create_stream();
+    EXPECT_EQ(collect_checked(*stream, "a ^b _ c"), kScriptTokens);
+    EXPECT_EQ(stream->reserved_bytes(), 0);
+
+    TokenBatch batch;
+    std::vector<size_t> sizes;
+    const std::string long_text = words(1000);
+    stream->reset(long_text);
+    while (stream->next_batch(batch)) {
+        sizes.push_back(batch.size());
+    }
+    EXPECT_EQ(sizes, (std::vector<size_t> {256, 256, 256, 232}));
+}
+
+TEST(LuceneBackedTextAnalyzerTest, ReadsTheValueThroughTheIndexCharFilter) {
+    const LuceneBackedTextAnalyzer analyzer(
+            std::make_shared<ScriptedLuceneAnalyzer>(),
+            {{INVERTED_INDEX_PARSER_CHAR_FILTER_TYPE, 
INVERTED_INDEX_CHAR_FILTER_CHAR_REPLACE},
+             {INVERTED_INDEX_PARSER_CHAR_FILTER_PATTERN, "-"},
+             {INVERTED_INDEX_PARSER_CHAR_FILTER_REPLACEMENT, " "}});
+    EXPECT_EQ(collect_checked(*analyzer.create_stream(), "a-b"),
+              (Tokens {{.term = "a", .position_increment = 1},
+                       {.term = "b", .position_increment = 1}}));
+}
+
+TEST(LuceneBackedTextAnalyzerTest, CLuceneErrorsComeOutAsDorisExceptions) {
+    const LuceneBackedTextAnalyzer 
analyzer(std::make_shared<ScriptedLuceneAnalyzer>(), {});
+    const auto stream = analyzer.create_stream();
+    TokenBatch batch;
+    EXPECT_EQ(thrown_code([&] {
+                  stream->reset("a b !");
+                  while (stream->next_batch(batch)) {
+                  }
+              }),
+              ErrorCode::INVERTED_INDEX_ANALYZER_ERROR);
+    EXPECT_EQ(thrown_code([&] { stream->reset("!!"); }), 
ErrorCode::INVERTED_INDEX_ANALYZER_ERROR);
+    // The next value starts over.
+    EXPECT_EQ(collect_checked(*stream, "c"), (Tokens {{.term = "c", 
.position_increment = 1}}));
+}
+
+TEST(LuceneBackedTextAnalyzerTest, KeepsTheTextStreamContract) {
+    const LuceneBackedTextAnalyzer 
scripted(std::make_shared<ScriptedLuceneAnalyzer>(), {});
+    expect_text_stream_contract([&] { return scripted.create_stream(); },
+                                {"a ^b _ c", words(1000), words(256), "x"});
+    const LuceneBackedTextAnalyzer standard(
+            
InvertedIndexAnalyzer::create_builtin_analyzer(InvertedIndexParserType::PARSER_STANDARD,
+                                                           "", "", ""),
+            {});
+    expect_text_stream_contract([&] { return standard.create_stream(); },
+                                {"The Quick brown fox", words(1000), "中文 mixed 
文本", "x"});
+}
+
+TEST(LuceneAnalyzerBridgeTest, HandsOutTheTokensOfTheTextAnalyzer) {
+    LuceneAnalyzerBridge bridge(std::make_shared<ScriptedTextAnalyzer>());
+    const std::string first = "a ^b _ c";
+    lucene::analysis::TokenStream* stream = bridge.reusableTokenStream(L"f", 
reader_of(first));
+    EXPECT_EQ(lucene_tokens(stream), kScriptTokens);
+
+    // A scalar column rebinds the same stream for every value.
+    const std::string second = words(1000);
+    EXPECT_EQ(bridge.reusableTokenStream(L"f", reader_of(second)), stream);
+    const Tokens tokens = lucene_tokens(stream);
+    ASSERT_EQ(tokens.size(), 1000);
+    EXPECT_EQ(tokens[999].term, "w999");
+
+    const std::string empty;
+    EXPECT_TRUE(lucene_tokens(bridge.reusableTokenStream(L"f", 
reader_of(empty))).empty());
+}
+
+// invertField() calls reset() before it reads a field. Before that, next() 
hands out nothing, as
+// DorisTokenizer does before reset() promotes its reader; a second reset() 
starts the value over.
+TEST(LuceneAnalyzerBridgeTest, ResetStartsTheValue) {
+    LuceneAnalyzerBridge bridge(std::make_shared<ScriptedTextAnalyzer>());
+    const std::string value = "a b";
+    lucene::analysis::TokenStream* stream = bridge.reusableTokenStream(L"f", 
reader_of(value));
+    lucene::analysis::Token token;
+    EXPECT_EQ(stream->next(&token), nullptr);
+    EXPECT_EQ(lucene_tokens(stream).size(), 2);
+    EXPECT_EQ(lucene_tokens(stream).size(), 2);
+}
+
+TEST(LuceneAnalyzerBridgeTest, TokenStreamMakesAStreamTheCallerOwns) {
+    LuceneAnalyzerBridge bridge(std::make_shared<ScriptedTextAnalyzer>());
+    const std::string first = "a b";
+    const std::string second = "c";
+    const std::unique_ptr<lucene::analysis::TokenStream> one(
+            bridge.tokenStream(L"f", reader_of(first)));
+    const std::unique_ptr<lucene::analysis::TokenStream> two(
+            bridge.tokenStream(L"f", reader_of(second)));
+    EXPECT_NE(one.get(), two.get());
+    EXPECT_EQ(lucene_tokens(two.get()), (Tokens {{.term = "c", 
.position_increment = 1}}));
+    EXPECT_EQ(lucene_tokens(one.get()), (Tokens {{.term = "a", 
.position_increment = 1},
+                                                 {.term = "b", 
.position_increment = 1}}));
+}
+
+TEST(LuceneAnalyzerBridgeTest, ReaderPointerOverloadsThrow) {
+    LuceneAnalyzerBridge bridge(std::make_shared<ScriptedTextAnalyzer>());
+    const std::string value = "a";
+    const ReaderPtr reader = reader_of(value);
+    EXPECT_EQ(thrown_code([&] { bridge.tokenStream(L"f", reader.get()); }),
+              ErrorCode::INVERTED_INDEX_NOT_SUPPORTED);
+    EXPECT_EQ(thrown_code([&] { bridge.reusableTokenStream(L"f", 
reader.get()); }),
+              ErrorCode::INVERTED_INDEX_NOT_SUPPORTED);
+}
+
+// A doris::Exception from a bridged stream passes through the CLucene frames 
of
+// IndexWriter::addDocument() to the V2 writer, which reports it, for a string 
column and for an
+// array column, thrown from reset() and from next_batch(). LeakSanitizer 
checks that the CLucene
+// state the exception skipped over is freed.
+class LuceneBridgeWriterTest : public testing::Test {
+protected:
+    void SetUp() override {
+        
ASSERT_TRUE(io::global_local_filesystem()->delete_directory(kTestDir).ok());
+        
ASSERT_TRUE(io::global_local_filesystem()->create_directory(kTestDir).ok());
+        TabletIndexPB pb;
+        pb.set_index_type(IndexType::INVERTED);
+        pb.set_index_id(91);
+        pb.set_index_name("bridge_writer_idx");
+        pb.add_col_unique_id(0);
+        pb.mutable_properties()->insert({"parser", "english"});
+        pb.mutable_properties()->insert({"support_phrase", "true"});
+        _meta.init_from_pb(pb);
+    }
+
+    void TearDown() override {
+        _writer.reset();
+        _index_file_writer.reset();
+        _kept_analyzers.clear();
+        
EXPECT_TRUE(io::global_local_filesystem()->delete_directory(kTestDir).ok());
+    }
+
+    // A V2 writer for `column` whose analyzer is the bridge over 
ScriptedTextAnalyzer, in place of
+    // the writer before.
+    void make_writer(const TabletColumn& column, const std::string& name) {
+        _writer.reset();
+        _index_file_writer.reset();
+        const std::string prefix = std::string(kTestDir) + "/" + name;
+        const auto fs = io::global_local_filesystem();
+        io::FileWriterPtr file_writer;
+        
ASSERT_TRUE(fs->create_file(InvertedIndexDescriptor::get_index_file_path_v2(prefix),
+                                    &file_writer)
+                            .ok());
+        _index_file_writer = std::make_unique<IndexFileWriter>(
+                fs, prefix, "bridge_rowset", /*seg_id=*/0, 
InvertedIndexStorageFormatPB::V2,
+                std::move(file_writer));
+        const Status created =
+                IndexColumnWriter::create(&column, &_writer, 
_index_file_writer.get(), &_meta);
+        ASSERT_TRUE(created.ok()) << created.to_string();
+        auto* legacy = 
static_cast<InvertedIndexColumnWriter<FieldType::OLAP_FIELD_TYPE_STRING>*>(
+                _writer.get());
+        // The CLucene IndexWriter keeps a raw pointer to the analyzer it was 
made with.
+        _kept_analyzers.push_back(legacy->_analyzer);
+        legacy->set_analysis_for_test(
+                std::make_shared<lucene::util::SStringReader<char>>(),
+                
std::make_shared<LuceneAnalyzerBridge>(std::make_shared<ScriptedTextAnalyzer>()));
+    }
+
+    static TabletColumn text_column() {
+        TabletColumn column;
+        column.set_unique_id(0);
+        column.set_name("text");
+        column.set_type(FieldType::OLAP_FIELD_TYPE_STRING);
+        column.set_length(INT_MAX);
+        column.set_is_nullable(true);
+        return column;
+    }
+
+    static TabletColumn array_column() {
+        TabletColumn element = text_column();
+        TabletColumn column;
+        column.set_unique_id(0);
+        column.set_name("texts");
+        column.set_type(FieldType::OLAP_FIELD_TYPE_ARRAY);
+        column.set_is_nullable(true);
+        column.add_sub_column(element);
+        return column;
+    }
+
+    TabletIndex _meta;
+    std::vector<std::shared_ptr<lucene::analysis::Analyzer>> _kept_analyzers;
+    std::unique_ptr<IndexFileWriter> _index_file_writer;
+    std::unique_ptr<IndexColumnWriter> _writer;
+};
+
+TEST_F(LuceneBridgeWriterTest, AnExceptionFromTheStreamFailsAddValues) {
+    for (const std::string bad : {"x ! y", "!reset z"}) {
+        make_writer(text_column(), "scalar_" + std::to_string(bad.size()));
+        const std::vector<std::string> rows = {"fine words", bad};
+        const std::vector<Slice> slices = {Slice(rows[0]), Slice(rows[1])};
+        const Status status =
+                _writer->add_values(reinterpret_cast<const 
uint8_t*>(slices.data()), slices.size());
+        EXPECT_EQ(status.code(), ErrorCode::INVERTED_INDEX_ANALYZER_ERROR)
+                << bad << ": " << status.to_string();
+        // Caught around IndexWriter::addDocument(), not where the field is 
made.
+        EXPECT_NE(status.to_string().find("while adding document"), 
std::string::npos)
+                << status.to_string();
+        EXPECT_NE(status.to_string().find("scripted failure"), 
std::string::npos)
+                << status.to_string();
+    }
+}
+
+TEST_F(LuceneBridgeWriterTest, AnExceptionFromTheStreamFailsAddArrayValues) {
+    for (const std::string bad : {"x ! y", "!reset z"}) {
+        make_writer(array_column(), "array_" + std::to_string(bad.size()));
+        const std::vector<std::string> elements = {"fine words", bad};
+        const std::vector<Slice> slices = {Slice(elements[0]), 
Slice(elements[1])};
+        const std::vector<uint8_t> null_map = {0, 0};
+        const std::vector<uint64_t> offsets = {0, 2};
+        const Status status = _writer->add_array_values(
+                sizeof(Slice), reinterpret_cast<const 
uint8_t*>(slices.data()), null_map.data(),
+                offsets.data(), 1);
+        EXPECT_EQ(status.code(), ErrorCode::INVERTED_INDEX_ANALYZER_ERROR)
+                << bad << ": " << status.to_string();
+        // Caught around IndexWriter::addDocument(), not where the field is 
made.
+        EXPECT_NE(status.to_string().find("while adding document"), 
std::string::npos)
+                << status.to_string();
+        EXPECT_NE(status.to_string().find("scripted failure"), 
std::string::npos)
+                << status.to_string();
+    }
+}
+
+} // namespace doris::segment_v2::inverted_index


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to