0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AI for Scienceとは? #6 ─ GraphRAGで日本語論文を扱う:日本語テキストのチャンク分割最適化

0
Last updated at Posted at 2026-01-27

はじめに

この記事は「GraphRAGで日本語論文を扱う」シリーズの第6回です。

今回は、日本語テキストのチャンク分割(Chunking)の最適化について解説します。GraphRAGのデフォルト設定では、英語を前提としたトークンベースの分割が行われるため、日本語テキストでは単語の途中で分割されてしまう問題があります。

本記事では、GiNZA(spaCy + SudachiPy)を使用した日本語対応チャンカーの実装と、bge-m3などの日本語対応埋め込みモデルへの切り替え方法を紹介します。

シリーズ記事

# タイトル 内容
1 Ollama + GraphRAGで論文検索 基本セットアップ
2 GraphRAGのチャンク管理とカスタマイズ チャンク設定の詳細
3 GraphRAGのインデックス構築と最適化 並列処理・リトライ戦略
4 GraphRAGのGlobal SearchとLocal Searchの使い分け クエリモードの理解
5 DRIFT/LazyGraphRAGによる高速検索 新クエリモードの活用
6 日本語テキストのチャンク分割最適化(本記事) 日本語対応

日本語チャンク分割の課題

問題1: 単語境界がない

英語は単語間にスペースがあるため、トークン境界での分割が自然に機能します。しかし日本語には単語間のスペースがなく、tiktokenのトークン境界で分割すると、意味のある単位で分割されません。

英語: "Machine learning is powerful." → ["Machine", " learning", " is", " powerful", "."]
日本語: "機械学習は強力です。" → ["機械", "学", "習", "は", "強", "力", "です", "。"]

問題2: トークン数の違い

tiktokenのcl100k_baseエンコーディングでは、日本語1文字あたり約1〜3トークンを消費します。これは英語(1単語≈1〜2トークン)と比べて効率が悪いです。

import tiktoken
enc = tiktoken.get_encoding("cl100k_base")

# 英語
english = "This is a test."
print(f"英語: {len(english)}文字 → {len(enc.encode(english))}トークン")
# 英語: 15文字 → 5トークン

# 日本語
japanese = "これはテストです。"
print(f"日本語: {len(japanese)}文字 → {len(enc.encode(japanese))}トークン")
# 日本語: 9文字 → 11トークン

問題3: 文字化けの発生

デフォルトのトークンベース分割では、マルチバイト文字の途中で分割されることがあり、文字化けが発生する場合があります。

デフォルト分割の末尾: ...チャンク分割を実現する。### 3.1 形態素解析ベースの�
カスタム分割の末尾:   ...チャンク分割を実現する。### 3.1 形態素解析ベースのチャンク分割

解決策: GiNZAベースの日本語チャンカー

GiNZAとは

GiNZAは、spaCyをベースとした日本語NLPライブラリです。SudachiPyを形態素解析器として使用し、高精度なトークン化・品詞タグ付け・文境界検出を提供します。

主な特徴:

  • spaCy v3.7互換
  • Universal Dependencies準拠
  • 3種類の分割モード(A/B/C)
  • Transformer版モデル(ja_ginza_electra)も利用可能

インストール

# 標準モデル
pip install -U ginza ja_ginza

# 高精度Transformer版(16GB以上のメモリ推奨)
pip install -U ginza ja_ginza_electra

# GraphRAG関連
pip install graphrag tiktoken

日本語チャンカーの実装

以下は、GiNZAを使用した日本語チャンカーの実装例です。

"""
日本語テキストチャンカー for GraphRAG
"""
import re
from dataclasses import dataclass
from typing import Optional, Callable

try:
    import spacy
    HAS_GINZA = True
except ImportError:
    HAS_GINZA = False

import tiktoken


@dataclass
class JapaneseChunk:
    """チャンク結果"""
    text: str
    n_tokens: int
    n_sentences: int
    source_doc_indices: list[int]


@dataclass
class JapaneseChunkerConfig:
    """チャンカー設定"""
    max_tokens: int = 800           # 日本語は小さめ推奨
    overlap_tokens: int = 80
    encoding_model: str = "cl100k_base"
    spacy_model: str = "ja_ginza"
    use_sentence_boundary: bool = True
    fallback_to_regex: bool = True


class JapaneseTextChunker:
    """GiNZAベースの日本語チャンカー"""
    
    def __init__(self, config: Optional[JapaneseChunkerConfig] = None):
        self.config = config or JapaneseChunkerConfig()
        self._nlp = None
        self._encoder = None
        
    def _get_nlp(self):
        """spaCyモデルを遅延ロード"""
        if self._nlp is None and HAS_GINZA:
            try:
                self._nlp = spacy.load(self.config.spacy_model)
            except OSError:
                pass
        return self._nlp
    
    def _get_encoder(self):
        """tiktokenエンコーダーを取得"""
        if self._encoder is None:
            enc = tiktoken.get_encoding(self.config.encoding_model)
            self._encoder = enc.encode
            self._decoder = enc.decode
        return self._encoder, self._decoder
    
    def count_tokens(self, text: str) -> int:
        """トークン数をカウント"""
        encode, _ = self._get_encoder()
        return len(encode(text))
    
    def split_sentences(self, text: str) -> list[str]:
        """文に分割"""
        nlp = self._get_nlp()
        if nlp:
            doc = nlp(text)
            return [sent.text.strip() for sent in doc.sents if sent.text.strip()]
        # フォールバック: 正規表現
        return [s.strip() for s in re.split(r'(?<=[。!?\n])\s*', text) if s.strip()]
    
    def chunk_text(self, text: str) -> list[JapaneseChunk]:
        """テキストをチャンクに分割"""
        if not text.strip():
            return []
        
        sentences = self.split_sentences(text)
        chunks = []
        current_sentences = []
        current_tokens = 0
        encode, decode = self._get_encoder()
        
        for sentence in sentences:
            sentence_tokens = len(encode(sentence))
            
            # 1文がmax_tokensを超える場合
            if sentence_tokens > self.config.max_tokens:
                if current_sentences:
                    chunks.append(self._make_chunk(current_sentences, current_tokens))
                    current_sentences, current_tokens = [], 0
                
                # 長い文を分割
                chunks.extend(self._split_long_sentence(sentence))
                continue
            
            # 追加するとmax_tokensを超える場合
            if current_tokens + sentence_tokens > self.config.max_tokens:
                if current_sentences:
                    chunks.append(self._make_chunk(current_sentences, current_tokens))
                    # オーバーラップ
                    current_sentences, current_tokens = self._get_overlap(
                        current_sentences, encode
                    )
            
            current_sentences.append(sentence)
            current_tokens += sentence_tokens
        
        if current_sentences:
            chunks.append(self._make_chunk(current_sentences, current_tokens))
        
        return chunks
    
    def _make_chunk(self, sentences: list[str], n_tokens: int) -> JapaneseChunk:
        return JapaneseChunk(
            text="".join(sentences),
            n_tokens=n_tokens,
            n_sentences=len(sentences),
            source_doc_indices=[0]
        )
    
    def _split_long_sentence(self, sentence: str) -> list[JapaneseChunk]:
        """長い文をトークンベースで分割"""
        encode, decode = self._get_encoder()
        encoded = encode(sentence)
        chunks = []
        
        for i in range(0, len(encoded), 
                       self.config.max_tokens - self.config.overlap_tokens):
            chunk_tokens = encoded[i:i + self.config.max_tokens]
            chunks.append(JapaneseChunk(
                text=decode(chunk_tokens),
                n_tokens=len(chunk_tokens),
                n_sentences=1,
                source_doc_indices=[0]
            ))
        
        return chunks
    
    def _get_overlap(self, sentences: list[str], encode) -> tuple[list[str], int]:
        """オーバーラップ用の文を取得"""
        overlap_sentences = []
        overlap_tokens = 0
        
        for sentence in reversed(sentences):
            sentence_tokens = len(encode(sentence))
            if overlap_tokens + sentence_tokens > self.config.overlap_tokens:
                break
            overlap_sentences.insert(0, sentence)
            overlap_tokens += sentence_tokens
        
        return overlap_sentences, overlap_tokens

使用例

# 設定
config = JapaneseChunkerConfig(
    max_tokens=800,
    overlap_tokens=80,
    spacy_model="ja_ginza"
)

# チャンカー初期化
chunker = JapaneseTextChunker(config)

# テキスト分割
text = """
機械学習を用いた自然言語処理の最新動向について解説する。
近年、Transformerアーキテクチャの登場により、言語モデルの性能は飛躍的に向上した。
BERTやGPTに代表される事前学習モデルは、様々なNLPタスクで高い性能を発揮している。
"""

chunks = chunker.chunk_text(text)

for i, chunk in enumerate(chunks):
    print(f"Chunk {i+1}: {chunk.n_tokens} tokens, {chunk.n_sentences} sentences")
    print(f"  {chunk.text[:50]}...")

日本語対応埋め込みモデル

Ollama対応モデル

日本語テキストの埋め込みには、多言語対応モデルを使用する必要があります。

モデル サイズ 日本語対応 特徴
bge-m3 567MB BAAI製、100言語対応、Multi-Granularity
nomic-embed-text - 英語中心、日本語は非最適
paraphrase-multilingual 278MB sentence-transformers
qwen3-embedding 0.6B-8B 最新、複数サイズ

bge-m3の導入

# Ollamaでbge-m3をダウンロード
ollama pull bge-m3

# 動作確認
ollama run bge-m3 "これはテストです"

GraphRAG設定

settings.yamlでbge-m3を使用する設定:

# 埋め込み設定
embeddings:
  llm:
    api_key: ${GRAPHRAG_API_KEY}
    type: openai_embedding
    model: bge-m3
    api_base: http://localhost:11434/v1

# チャンク設定(日本語最適化)
chunks:
  size: 800                    # 日本語は小さめ
  overlap: 80
  strategy: sentences          # 文ベース分割
  encoding_model: cl100k_base

Azure AI Foundry を使用する場合

エンタープライズ環境やクラウドネイティブな構成では、Azure AI Foundry の埋め込みモデルを使用することで、スケーラビリティとセキュリティを両立できます。

Azure AI Foundry 対応埋め込みモデル

モデル 次元数 日本語対応 特徴
text-embedding-3-large 3,072 最新・高精度、MIRACL平均54.9
text-embedding-3-small 1,536 コスト効率重視
text-embedding-ada-002 1,536 旧世代、互換性用
Cohere-embed-v3-multilingual 1,024 日本語含む10言語対応
embed-v-4-0 (Cohere) 256-1,536 最新、マルチモーダル対応

推奨: 日本語論文を扱う場合は text-embedding-3-large または Cohere-embed-v3-multilingual を推奨します。MIRACLベンチマーク(多言語検索)で高いスコアを記録しています。

GraphRAG設定(Azure AI Foundry版)

# settings.yaml - Azure AI Foundry 版
embeddings:
  llm:
    api_key: ${AZURE_OPENAI_API_KEY}
    type: openai_embedding
    model: text-embedding-3-large
    api_base: https://<your-resource>.openai.azure.com/
    api_version: "2024-06-01"
    # 次元数を削減してコスト最適化(オプション)
    # dimensions: 1536

# チャンク設定(日本語最適化)
chunks:
  size: 800
  overlap: 80
  strategy: sentences
  encoding_model: cl100k_base

Cohere Embed v3 Multilingual を使用する場合

Azure AI Foundry ではCohereモデルもサーバーレスAPIとしてデプロイ可能です。

# settings.yaml - Cohere Embed v3 Multilingual 版
embeddings:
  llm:
    api_key: ${AZURE_AI_FOUNDRY_KEY}
    type: openai_embedding
    model: Cohere-embed-v3-multilingual
    api_base: https://<your-resource>.services.ai.azure.com/models/
    api_version: "2024-05-01-preview"

Azure AI Foundry のベストプラクティス

項目 推奨設定 理由
リージョン Japan East / Japan West 日本語処理のレイテンシ最小化
モデル選択 text-embedding-3-large MIRACLベンチマーク最高スコア
次元数 3,072(デフォルト)または1,536 精度とコストのバランス
バッチサイズ 最大2,048件 API制限内で効率化
トークン上限 8,192トークン/リクエスト 日本語チャンクサイズ800推奨

コスト最適化のヒント

  1. 次元数の削減: text-embedding-3-largedimensionsパラメータで次元数を削減可能

    # dimensions=1536 でも ada-002 より高精度
    response = client.embeddings.create(
        model="text-embedding-3-large",
        input="日本語テキスト",
        dimensions=1536  # 3072 → 1536 でストレージ50%削減
    )
    
  2. バッチ処理: 複数チャンクを1リクエストで処理

    # 最大2048件のバッチ処理
    chunks = ["チャンク1", "チャンク2", ...]
    response = client.embeddings.create(
        model="text-embedding-3-large",
        input=chunks[:2048]
    )
    
  3. リージョン配置: Azure AI Search と同一リージョンに配置してレイテンシ削減

検証結果

テスト環境

  • GraphRAG 2.7.0
  • GiNZA 5.2.0 + ja_ginza
  • tiktoken (cl100k_base)

分割品質の比較

日本語の学術論文テキスト(約2,000文字)を使用して比較しました。

デフォルト(トークンベース):

チャンク末尾: ...形態素解析ベースの�

→ マルチバイト文字の途中で分割され、文字化けが発生

カスタム(日本語対応):

チャンク末尾: ...形態素解析ベースのチャンク分割

→ 文境界で適切に分割され、文字化けなし

トークン効率

項目 デフォルト カスタム
チャンク数 3 3
平均トークン数 631.7 631.7
分割品質 △ 文字化けあり ◎ 適切
処理時間 高速 やや遅い(+10ms程度)

まとめ

本記事では、GraphRAGで日本語テキストを扱う際のチャンク分割の課題と解決策を紹介しました。

ポイント

  1. GiNZAで文境界を検出: 意味のある単位で分割
  2. 文ベースでチャンクを構築: 文字化けを防止
  3. bge-m3で埋め込み: 日本語対応の多言語モデルを使用(ローカル環境)
  4. Azure AI Foundry: エンタープライズ環境ではtext-embedding-3-largeまたはCohere-embed-v3-multilingualを推奨
  5. チャンクサイズは小さめに: 日本語は1文字≈2-3トークン

次回予告

次回は、日本語論文でのGraphRAGの実際の検索精度評価と、パフォーマンス最適化について解説します。

参考文献

  1. GiNZA NLP Library - Megagon Labs
  2. Microsoft GraphRAG - GitHub
  3. multilingual-e5-large - HuggingFace
  4. BGE-M3 - Ollama
  5. spaCy Models & Languages - spaCy Documentation
  6. Azure AI Foundry Embedding Models - Microsoft Learn
  7. Cohere Embed v3 Multilingual - Microsoft Learn
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?