Skip to content

[feature](inverted-index) Add Japanese (Kuromoji) morphological analyzer#64667

Open
nishant94 wants to merge 16 commits into
apache:masterfrom
nishant94:feat/kuromoji-japanese-analyzer
Open

[feature](inverted-index) Add Japanese (Kuromoji) morphological analyzer#64667
nishant94 wants to merge 16 commits into
apache:masterfrom
nishant94:feat/kuromoji-japanese-analyzer

Conversation

@nishant94

Copy link
Copy Markdown

What problem does this PR solve?

Issue Number: #64646

Related PR: None

Problem Summary:
Doris has no Japanese-aware tokenizer for the inverted index. Japanese text has no spaces between words, so the existing parsers can't segment it and MATCH / MATCH_PHRASE on Japanese columns end up with poor recall and precision.

This PR adds a built-in kuromoji parser for Japanese, in the same style as the existing Chinese IK analyzer. It's opt-in per column:

 INDEX content_idx (`content`) USING INVERTED
 PROPERTIES("parser" = "kuromoji", "parser_mode" = "search");

After indexing, MATCH, MATCH_PHRASE and TOKENIZE() run against the segmented Japanese terms.

How it works:

  • Native C++ under be/src/storage/index/inverted/analyzer/kuromoji/, so there's no JVM on the indexing path. KuromojiAnalyzer / KuromojiTokenizer mirror the IK analyzer/tokenizer, with a Viterbi cost-model segmenter over the IPADIC connection-cost matrix.
    • The dictionary is a process-wide singleton loaded once from ${inverted_index_dict_path}/kuromoji. An offline converter compiles raw IPADIC into a compact C++ runtime format (double-array trie + cost matrix + char/unknown tables) at build time, so no binary blob is committed.
    • search (default), normal and extended modes are supported. No thrift/proto changes — parser and mode ride as strings in the index properties.

Dictionary source is mecab-ipadic-2.7.0-20070801 (NAIST-2003 license, the same lexicon Lucene kuromoji uses).

Release note

Support Japanese text tokenization in the inverted index via a new kuromoji parser (PROPERTIES("parser"="kuromoji")), with search/normal/extended modes.

Check List (For Author)

  • Test
    • Regression test
    • Unit Test
    • Manual test (add detailed scripts or steps below)
  CREATE TABLE test_jp (
    id BIGINT,
    content TEXT,
    INDEX idx_content (content) USING INVERTED
      PROPERTIES("parser" = "kuromoji", "parser_mode" = "search")
  ) ENGINE=OLAP
  DUPLICATE KEY(id)
  DISTRIBUTED BY HASH(id) BUCKETS 1
  PROPERTIES("replication_num" = "1");

  INSERT INTO test_jp VALUES
    (1, '東京都に住んでいます'),
    (2, '日本語の形態素解析エンジン');

  -- search-mode decompounding: 東京都 also matches 東京
  SELECT id FROM test_jp WHERE content MATCH '東京';          -- expect: 1
  SELECT id FROM test_jp WHERE content MATCH_PHRASE '形態素解析'; -- expect: 2

  -- inspect segmentation directly
  SELECT TOKENIZE('東京都に住んでいます', '"parser"="kuromoji","parser_mode"="search"');
  • Behavior changed:
    • No.
    • Yes. It adds a new opt-in kuromoji parser. Existing parsers and their output are unchanged; the new behavior only applies to indexes that explicitly set parser="kuromoji".
  • Does this need documentation?
    • No.
    • Yes. PR Link to Doris-Website.

Check List (For Reviewer who merge this PR)

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

@hello-stephen

Copy link
Copy Markdown
Contributor

Thank you for your contribution to Apache Doris.
Don't know what should be done next? See How to process your PR.

Please clearly describe your PR:

  1. What problem was fixed (it's best to include specific error reporting information). How it was fixed.
  2. Which behaviors were modified. What was the previous behavior, what is it now, why was it modified, and what possible impacts might there be.
  3. What features were added. Why was this function added?
  4. Which code was refactored and why was this part of the code refactored?
  5. Which functions were optimized and what is the difference before and after the optimization?

@nishant94

Copy link
Copy Markdown
Author

run buildall

@yiguolei

Copy link
Copy Markdown
Contributor

@nishant94 have you tried icu analyzer? because I think icu could handle many different languages.

@nishant94

nishant94 commented Jun 22, 2026

Copy link
Copy Markdown
Author

@nishant94 have you tried icu analyzer? because I think icu could handle many different languages.

@yiguolei The ICU Analyzer is not good as the Kuromoji. There is huge difference between icu and kuromoji when it comes to morphology of the Japanese words. So I think it worth it adding this new parser.

@BiteTheDDDDt

Copy link
Copy Markdown
Contributor

Is the code under be/src/storage/index/inverted/analyzer/kuromoji entirely original or derived from other projects? Perhaps we need to clarify the situation regarding this part.

@nishant94

Copy link
Copy Markdown
Author

Is the code under be/src/storage/index/inverted/analyzer/kuromoji entirely original or derived from other projects? Perhaps we need to clarify the situation regarding this part.

This is original code but it is modeled on Apache Lucene's kuromoji.

@hello-stephen

Copy link
Copy Markdown
Contributor

FE UT Coverage Report

Increment line coverage 44.44% (4/9) 🎉
Increment coverage report
Complete coverage report

@nishant94 nishant94 force-pushed the feat/kuromoji-japanese-analyzer branch from 389fcfb to b79db3c Compare June 22, 2026 09:57
@nishant94

Copy link
Copy Markdown
Author

run buildall

@hello-stephen

Copy link
Copy Markdown
Contributor

BE UT Coverage Report

Increment line coverage 82.40% (791/960) 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 54.51% (21439/39327)
Line Coverage 38.17% (205347/537919)
Region Coverage 34.16% (161044/471416)
Branch Coverage 35.14% (70517/200651)

@hello-stephen

Copy link
Copy Markdown
Contributor

BE UT Coverage Report

Increment line coverage 84.10% (836/994) 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 54.50% (21433/39329)
Line Coverage 38.13% (205092/537920)
Region Coverage 34.11% (160793/471446)
Branch Coverage 35.11% (70468/200678)

@hello-stephen

Copy link
Copy Markdown
Contributor

BE Regression && UT Coverage Report

Increment line coverage 83.85% (462/551) 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 74.11% (28441/38375)
Line Coverage 58.02% (309954/534209)
Region Coverage 54.69% (258833/473301)
Branch Coverage 56.10% (112608/200725)

@hello-stephen

Copy link
Copy Markdown
Contributor

FE Regression Coverage Report

Increment line coverage 66.67% (6/9) 🎉
Increment coverage report
Complete coverage report

@nishant94

Copy link
Copy Markdown
Author

run buildall

@morningman morningman self-assigned this Jun 23, 2026
@hello-stephen

Copy link
Copy Markdown
Contributor

FE UT Coverage Report

Increment line coverage 44.44% (4/9) 🎉
Increment coverage report
Complete coverage report

@hello-stephen

Copy link
Copy Markdown
Contributor

FE Regression Coverage Report

Increment line coverage 35.29% (6/17) 🎉
Increment coverage report
Complete coverage report

@hello-stephen

Copy link
Copy Markdown
Contributor

BE UT Coverage Report

Increment line coverage 84.10% (836/994) 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 54.72% (21523/39332)
Line Coverage 38.18% (205493/538169)
Region Coverage 34.17% (161179/471738)
Branch Coverage 35.13% (70561/200832)

@hello-stephen

Copy link
Copy Markdown
Contributor

BE Regression && UT Coverage Report

Increment line coverage 83.85% (462/551) 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 74.19% (28466/38371)
Line Coverage 58.03% (310151/534436)
Region Coverage 54.77% (259367/473580)
Branch Coverage 56.13% (112755/200875)

Comment thread be/src/storage/index/inverted/analyzer/analyzer.cpp Outdated
@nishant94 nishant94 force-pushed the feat/kuromoji-japanese-analyzer branch from db0ee69 to 06b4ef6 Compare June 24, 2026 03:59
@nishant94 nishant94 requested a review from yiguolei June 24, 2026 05:18
@nishant94

Copy link
Copy Markdown
Author

run buildall

@hello-stephen

Copy link
Copy Markdown
Contributor

FE UT Coverage Report

Increment line coverage 44.44% (4/9) 🎉
Increment coverage report
Complete coverage report

@hello-stephen

Copy link
Copy Markdown
Contributor

BE UT Coverage Report

Increment line coverage 84.20% (842/1000) 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 54.58% (21476/39348)
Line Coverage 38.09% (205045/538313)
Region Coverage 34.07% (160754/471838)
Branch Coverage 35.04% (70387/200890)

@hello-stephen

Copy link
Copy Markdown
Contributor

BE Regression && UT Coverage Report

Increment line coverage 84.02% (468/557) 🎉

Increment coverage report
Complete coverage report

Category Coverage
Function Coverage 74.22% (28491/38387)
Line Coverage 58.10% (310621/534591)
Region Coverage 55.02% (260603/473686)
Branch Coverage 56.29% (113100/200937)

Comment thread be/dict/kuromoji/README.md
nishant94 added 9 commits July 1, 2026 14:56
- Added `darts.h` to `.clang-format-ignore` and `.licenserc.yaml`.
- Improved code formatting in various Kuromoji source files for better readability.
- Updated tests files to include necessary headers.
…mposition

- Added support for search mode in the Kuromoji Viterbi segmenter, applying penalties for long all-kanji and other tokens to enhance search recall.
- Updated the KuromojiMode enumeration to reflect the new search and extended modes.
- Modified the KuromojiTokenizer to utilize the new mode functionality.
- Added unit tests to validate the behavior of the search mode, ensuring correct segmentation of compounds.
- Updated NOTICE.txt to include Apache Lucene as a dependency for the kuromoji analyzer.
…wn words

- Implemented functionality in the Kuromoji Viterbi segmenter to decompose unknown (out-of-vocabulary) words into per-character unigrams when in extended mode, aligning with Lucene's JapaneseTokenizer behavior.
- Added unit tests to validate the correct segmentation of unknown words in both normal and extended modes, ensuring expected outputs for various input scenarios.
- Modified error messages to include 'kuromoji' parser in the parser mode validation.
- Enhanced tests for the Japanese analyzer to assert expected tokenization results.
- Introduced a new configuration option `enable_kuromoji_analyzer` to toggle the Kuromoji analyzer functionality.
- Updated unit tests to validate the behavior of the Kuromoji analyzer when enabled and disabled.
- Modified tests to enable the Kuromoji analyzer for specific test cases.
- Updated the namespace for Kuromoji components from `doris::segment_v2::kuromoji` to `doris::segment_v2::inverted_index::kuromoji` across multiple files for better organization and clarity.
- Updated the CMake configuration to ensure the required Kuromoji dictionary files are present at build time, failing the build if any are missing.
- Modified the KuromojiAnalyzer and KuromojiTokenizer to throw exceptions when the dictionary is not loaded, preventing silent fallbacks to per-codepoint tokenization.
- Improved error handling and validation in the dictionary loading process to ensure robust operation.
- Updated unit tests to validate the new behavior, ensuring that missing dictionaries trigger appropriate errors.
@nishant94 nishant94 force-pushed the feat/kuromoji-japanese-analyzer branch from 698dfb5 to cb16636 Compare July 1, 2026 09:26
@nishant94 nishant94 requested a review from airborne12 July 1, 2026 09:28
@nishant94

Copy link
Copy Markdown
Author

run buildall

@hello-stephen

Copy link
Copy Markdown
Contributor

FE UT Coverage Report

Increment line coverage 44.44% (4/9) 🎉
Increment coverage report
Complete coverage report

yiguolei
yiguolei previously approved these changes Jul 1, 2026
@Ryan19929

Copy link
Copy Markdown
Contributor

Since this is modeled after Lucene Kuromoji, have you checked how the Doris implementation performs in practice? A small benchmark for indexing throughput would be helpful.

[Non-blocking] Viterbi hot path allocates per byte position; +16% measured with a small change

here is what I measured (Release build, single pipeline task, sql cache off, 50×1MB natural text, sum(length(TOKENIZE(...))), best of 4):

parser corpus 50MB time throughput
kuromoji (this PR) ja 19.3 s 2.6 MB/s
kuromoji (prototype below) ja 16.2 s 3.1 MB/s (+16%)
icu ja 11.2 s 4.5 MB/s
ik zh 11.0 s 4.5 MB/s
chinese zh 6.7 s 7.5 MB/s

To be fair, the other rows are not apples-to-apples baselines: icu does much lighter work on Japanese than a full lattice/Viterbi morphological analysis, and ik/chinese run on a Chinese corpus, so some gap is expected and inherent to what kuromoji does. I'm only including them as a rough sense of scale — kuromoji is the slowest builtin analyzer but stays within the same order of magnitude, so I don't see this as blocking.

That said, a profile shows a good chunk of the time goes to avoidable heap allocations, so there are two cheap wins in KuromojiViterbi::segment():

  • std::vector<std::vector<int>> ending_at(n + 1) (kuromoji_viterbi.cpp:124): one vector object per document byte (~1M constructions for a 1MB doc) plus one heap allocation per reachable position.
  • matches declared inside the per-position loop (kuromoji_viterbi.cpp:170): one malloc/free per position; common_prefix_search() already clears it, so it can be hoisted.

Prototype of exactly these two changes: 19.3s → 16.2s, byte-identical tokenizer output on the 50MB corpus. The <<= flip preserves the original tie-break (chain iterates newest-first, original vector oldest-first).

--- a/be/src/storage/index/inverted/analyzer/kuromoji/kuromoji_viterbi.cpp
+++ b/be/src/storage/index/inverted/analyzer/kuromoji/kuromoji_viterbi.cpp
@@ -121,18 +121,25 @@ void KuromojiViterbi::segment(std::string_view text, std::vector<KuromojiMorphem
     }
 
     std::vector<VNode> nodes;
-    std::vector<std::vector<int>> ending_at(n + 1); // node indices ending at each byte position
+    // Intrusive per-end-position chain: end_head[e] is the most recent node index
+    // ending at byte position e, end_next[i] links to the previous one. This avoids
+    // allocating n+1 std::vector objects per document.
+    std::vector<int32_t> end_head(n + 1, -1);
+    std::vector<int32_t> end_next;
 
     // BOS (index 0): ends at position 0, context id 0, zero cost.
     nodes.push_back(VNode {0, 0, 0, 0, 0, false, 0, 0, -1});
-    ending_at[0].push_back(0);
+    end_next.push_back(-1);
+    end_head[0] = 0;
 
     // Add a node and relax it against all nodes ending at its start position.
     auto add_node = [&](uint32_t s, uint32_t e, int16_t lid, int16_t rid, int16_t wcost, bool known,
                         uint32_t wid) {
         int64_t best = KMJ_INF;
         int best_prev = -1;
-        for (int pe : ending_at[s]) {
+        // Chain is iterated newest-first; "<=" keeps the oldest node on cost ties,
+        // matching the original insertion-order "<" selection exactly.
+        for (int pe = end_head[s]; pe >= 0; pe = end_next[pe]) {
             const VNode& pv = nodes[static_cast<std::size_t>(pe)];
             if (pv.total_cost >= KMJ_INF) {
                 continue;
@@ -140,7 +147,7 @@ void KuromojiViterbi::segment(std::string_view text, std::vector<KuromojiMorphem
             const int64_t c =
                     pv.total_cost + _dict.connection_cost(static_cast<uint32_t>(pv.right_id),
                                                           static_cast<uint32_t>(lid));
-            if (c < best) {
+            if (c <= best) {
                 best = c;
                 best_prev = pe;
             }
@@ -154,12 +161,15 @@ void KuromojiViterbi::segment(std::string_view text, std::vector<KuromojiMorphem
         const auto idx = static_cast<int>(nodes.size());
         nodes.push_back(
                 VNode {s, e, lid, rid, wcost, known, wid, best + wcost + penalty, best_prev});
-        ending_at[e].push_back(idx);
+        end_next.push_back(end_head[e]);
+        end_head[e] = idx;
     };
 
     uint32_t pos = 0;
+    // Reused across positions; common_prefix_search clears it on entry.
+    std::vector<KuromojiDictionary::PrefixMatch> matches;
     while (pos < n) {
-        if (ending_at[pos].empty()) {
+        if (end_head[pos] < 0) {
             pos += decode_utf8(text, pos).len; // unreachable boundary; skip
             continue;
         }
@@ -167,7 +177,6 @@ void KuromojiViterbi::segment(std::string_view text, std::vector<KuromojiMorphem
         const auto before = nodes.size();
 
         // System-dictionary words (common-prefix search).
-        std::vector<KuromojiDictionary::PrefixMatch> matches;
         _dict.common_prefix_search(text.data() + pos, n - pos, &matches);
         bool any_known = false;
         for (const auto& mt : matches) {
@@ -219,14 +228,14 @@ void KuromojiViterbi::segment(std::string_view text, std::vector<KuromojiMorphem
     // EOS: best node ending at n connected to the EOS context (id 0).
     int64_t best = KMJ_INF;
     int best_prev = -1;
-    for (int pe : ending_at[n]) {
+    for (int pe = end_head[n]; pe >= 0; pe = end_next[pe]) {
         const VNode& pv = nodes[static_cast<std::size_t>(pe)];
         if (pv.total_cost >= KMJ_INF) {
             continue;
         }
         const int64_t c =
                 pv.total_cost + _dict.connection_cost(static_cast<uint32_t>(pv.right_id), 0);
-        if (c < best) {
+        if (c <= best) {
             best = c;
             best_prev = pe;
         }

- Replaced the `ending_at` vector with `end_head` and `end_next` for better memory management and performance during node processing.
- Updated node addition and traversal logic to utilize the new data structures, enhancing the segmenter's efficiency in handling word segmentation.
@nishant94

Copy link
Copy Markdown
Author

[Non-blocking] Viterbi hot path allocates per byte position; +16% measured with a small change

@Ryan19929 I am glad to see you spent time for benchmarking analyzers. Love to see these benchmarks. Also your optimization suggestion is pretty helpful. I took the refernce from it and swapped the ending_at vector-of-vectors for the intrusive end_head/end_next chain, and hoisted matches out of the per-position loop so common_prefix_search() reuses one buffer.

Thank you Ryan !!

@nishant94

Copy link
Copy Markdown
Author

run buildall

@nishant94 nishant94 requested a review from yiguolei July 3, 2026 11:45
@hello-stephen

Copy link
Copy Markdown
Contributor

FE UT Coverage Report

Increment line coverage 44.44% (4/9) 🎉
Increment coverage report
Complete coverage report

- Changed the values in the `unk.per_category[CAT_DEFAULT]` entry from `{5, 5, 4769, "unk-default"}` to `{2, 2, 4769, "unk-default"}` to correct the test setup.
- Modified CMake configuration to conditionally include the Kuromoji dictionary files only for non-test builds (MAKE_TEST=ON).
- Adjusted the custom target for generating the Kuromoji dictionary to reflect the new conditional behavior, ensuring it remains a manual target during unit-test builds.
@nishant94

Copy link
Copy Markdown
Author

run buildall

@nishant94

Copy link
Copy Markdown
Author

/review

@nishant94

Copy link
Copy Markdown
Author

run buildall

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants