🚧

プロンプトに書く日本語文のために形態素解析と係り受け解析をしてみる

に公開

この記事は、ポート株式会社 サービス開発部 Advent Calendar 2025の19日目の記事です。

プロンプトに含まれる日本語文について、形態素解析や係り受けの解析をしてその結果を参考にしつつ作成することで、お祈り要素が減ると個人的に考えており、そのためのツールを作ってみたという内容です。

技術的には、自然言語処理に用いる要素 (今回は、MeCabとCaboCha) についての軽い紹介と、Pythonを使った可視化WebページがAIでささっと作れたという内容となります。

背景・個人の思想

プロンプトをうまいこと調整して、任意のタスクを達成できるようにしたいということがよくあります。
ただ、プロンプトの調整に当たって、今書いている日本語文が構造的に正しいものなのか、誰から見ても意図した意味合いで伝わるものなのか、などが心配になります。

それに対して、形態素解析や係り受け解析を行って、プロンプトに記載している日本語文が機械的にどのように解釈されるかを知ることで、そういった不安を軽減できると考えました。
形態素解析や係り受け解析自体が常に正しい結果を出すとも限りませんし、正しい日本語の文法で書いても性能がでるとは限りませんので、その点には注意が必要です。

しかし、文法や言葉選びに関する調整幅が減り、別の要素の調整に時間を割けると考えると多少のメリットはあるだろうと考えます。

関連情報

形態素解析とは

形態素解析とは、文から単語の区切りや品詞などを求める処理のことです。

今回は、MeCabと呼ばれるソフトウェアを使わせていただき、形態素解析を行います。

https://taku910.github.io/mecab/

係り受け解析とは

係り受け解析とは、文から係り受けの関係についての依存構造を求める処理のことです。

今回は、CaboChaと呼ばれるソフトウェアを使わせていただき、係り受け解析を行います。

https://taku910.github.io/cabocha/

形態素解析、係り受け解析までの実施

手元の環境で形態素解析や係り受け解析を実施するまでの手順を説明します。
以下はUbuntu 24.04を前提とした操作です。

MeCab + mecab-ipadic-NEologd

MeCabに加えて、「mecab-ipadic-NEologd」という辞書をインストールします。
mecab-ipadic-NEologdとは、MeCab用のシステム辞書の一種で、Web上から得た比較的新しい語彙を含んでいます。

https://github.com/neologd/mecab-ipadic-neologd

READMEを参考にしつつ、コマンドを実行していきました。

# mecabと関連ツールのインストール
sudo apt install mecab libmecab-dev make curl xz-utils file

# mecab-ipadic-NEologdのインストール
git clone --depth 1 https://github.com/neologd/mecab-ipadic-neologd.git
cd mecab-ipadic-neologd
./bin/install-mecab-ipadic-neologd -n -a

# 動作確認
echo "私はitoです。" | mecab -d  "$(mecab-config --dicdir)/mecab-ipadic-neologd"

その他、辞書や好みでいれた未知語に関する設定を mymecabrc ファイルとして適当な場所に保存しました。
ファイルは以下のような内容です。(dicdirのパスは、echo "$(mecab-config --dicdir)/mecab-ipadic-neologd" の結果)

dicdir = /usr/lib/x86_64-linux-gnu/mecab/dic/mecab-ipadic-neologd/
unk-feature = 未知語,*,*,*,*,*,*,*,*

これを読み込みつつMeCabを実行するには、mecab -r ./mymecabrc のようにオプションでパスを指定します。

CaboCha

CaboChaはMeCabとCRF++に依存しています。CRF++をまだインストールしていない場合は、インストールが必要です。
https://taku910.github.io/crfpp/ のInstallationの手順を参考に実施します。

# Google DriveからダウンロードしたCRF++-0.58.tar.gzを解凍
cd ./CRF++-0.58
./configure
make
sudo make install
sudo ldconfig

次に、CaboChaをインストールします。https://taku910.github.io/cabocha/ のインストール を参考に以下の手順で実施しました。

# Google Driveからダウンロードしたcabocha-0.69.tar.bz2を解凍
cd ./cabocha-0.69
./configure --with-charset=utf8
make
sudo make install
sudo ldconfig

以下で動作を確認します。先ほど作成した mymecabrc をオプションから指定しつつ実行します。

echo "私は本を読んでいます。" | cabocha -b ./mymecabrc

可視化Webページ実装

ここまでの手順でMeCabによる形態素解析とCaboChaによる係り受け解析を実施しました。

コマンドラインから使用することでも目的は達成できますが、入力文をテキストボックスに張り付けたかったり、わかりやすいグラフが見れるとよさそうです。
MeCabやCaboChaのPythonバインディングが提供されていることや、他の機械学習ベースの自然言語処理、LLMのトークナイザ等々との相性を考え、PythonベースでWebページを実装してみました。

Python環境の準備

misevenv、pipで簡単に環境を整備します。

mise use python@3.14
python -m venv .venv
source ./.venv/bin/activate
pip install mecab-python3 cabocha-python pyvis streamlit

Webページ本体の実装

形態素解析の結果をテーブルで表示し、係り受け解析の結果をグラフで表示するようなページを作成しました。
GPT-5.2 Thinkingさんに実装してもらっており、特にこだわりはありませんが、ノードを元の文の順で横一列にならべる、テキストボックスで改行したら別のグラフにする、方向を表す目印をエッジの中間につける、など指示をしています。

MeCabのTaggerクラスのコンストラクタや、CaboChaのParserメソッドの引数でコマンドラインで実行したときのようなオプションが指定できます。
先ほどから使っている、mymecabrc ファイルを読み込むようなオプションの指定をしています。

ソースコード (長いため省略)
# 以下のようにして実行する。
# streamlit run app.py

import os
import re
import html
import streamlit as st
import streamlit.components.v1 as components
import MeCab
import CaboCha

MECABRC = "./mymecabrc"

def mecab_tokens(text: str):
    tagger = MeCab.Tagger(f'-r "{MECABRC}"')
    node = tagger.parseToNode(text)

    out = []
    while node:
        if node.surface:
            feats = node.feature.split(",")
            out.append({
                "surface": node.surface,
                "pos": feats[0] if len(feats) > 0 else "",
                "pos1": feats[1] if len(feats) > 1 else "",
                "base": feats[6] if len(feats) > 6 else "",
                "feature": node.feature,
                "is_unknown": (feats[0] == "未知語"),
            })
        node = node.next
    return out

def cabocha_lattice(text: str) -> str:
    parser = CaboCha.Parser(f'-f1 --mecabrc={MECABRC}')  # -f1: lattice形式
    return parser.parseToString(text)

def parse_cabocha_lattice(lattice: str):
    chunks = []
    cur = None

    for line in lattice.splitlines():
        if line == "EOS":
            break
        if line.startswith("* "):
            parts = line.split()
            idx = int(parts[1])
            dst = int(parts[2][:-1])  # "2D" -> 2, "-1D" -> -1
            cur = {"idx": idx, "dst": dst, "tokens": []}
            chunks.append(cur)
            continue

        if "\t" in line and cur is not None:
            surf, feat = line.split("\t", 1)
            cur["tokens"].append({"surface": surf, "feature": feat})

    for c in chunks:
        c["text"] = "".join(t["surface"] for t in c["tokens"])
    return chunks

def build_arc_svg(
    chunks,
    step=140,
    margin=30,
    top_pad=60,
    bottom_pad=60,
    node_r=16,
    lane_gap=22,
    span_scale=10,
):
    """
    ノードを元の順序で横並び、係り受けを「下方向」に湾曲表示するSVGアーク図。
    - 背景は白固定
    - 方向は終点矢印 + 線の途中にも小さな矢印(marker-mid)
      ※ marker-mid を効かせるため、Bezierを中点で2本に分割して描画
    - 枠外に切れないよう、SVG高さを自動調整
    - 左端/右端のノード上ラベルがはみ出さないよう、text-anchor と x を動的に調整
    戻り値: (svg_html, svg_height)
    """
    if not chunks:
        svg = '<div style="background:white;padding:8px">No chunks</div>'
        return svg, 120

    # ---- ノードとエッジの準備 ----
    nodes = [{"idx": c["idx"], "text": c.get("text", "")} for c in chunks]

    edges = []
    for c in chunks:
        if c.get("dst", -1) != -1:
            s, d = c["idx"], c["dst"]
            l, r = (s, d) if s < d else (d, s)
            edges.append({"src": s, "dst": d, "l": l, "r": r, "span": r - l})

    # 短いエッジから置く(低いレーンに収まりやすい)
    edges.sort(key=lambda e: (e["span"], e["l"], e["r"]))

    # ---- レーン割り当て(区間が重ならないように)----
    lanes = []
    for e in edges:
        placed = False
        for lane_idx, intervals in enumerate(lanes):
            if all(e["r"] <= L or e["l"] >= R for (L, R) in intervals):
                intervals.append((e["l"], e["r"]))
                e["lane"] = lane_idx
                placed = True
                break
        if not placed:
            lanes.append([(e["l"], e["r"])])
            e["lane"] = len(lanes) - 1

    # ---- 座標計算 ----
    x = {n["idx"]: margin + n["idx"] * step for n in nodes}
    max_idx = max(n["idx"] for n in nodes)
    width = margin * 2 + (max_idx * step) + step

    # baseline(ノード中心Y)
    y0 = top_pad + node_r + 10

    # アーク高さ(下方向)
    min_h = node_r + 12
    max_h = 0
    for e in edges:
        h = min_h + e["span"] * span_scale + e["lane"] * lane_gap
        max_h = max(max_h, h)
        e["h"] = h

    # ★ 下側に十分な高さを確保(枠外に切れない)
    height = int(y0 + max_h + bottom_pad + node_r + 20)

    def esc(s: str) -> str:
        return html.escape(s, quote=True)

    # marker-mid を効かせるため、Cubic Bezier を t=0.5 で分割(De Casteljau)
    def split_cubic_half(p0, p1, p2, p3):
        def mid(a, b):
            return ((a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0)

        p01 = mid(p0, p1)
        p12 = mid(p1, p2)
        p23 = mid(p2, p3)
        p012 = mid(p01, p12)
        p123 = mid(p12, p23)
        p0123 = mid(p012, p123)  # t=0.5 の点

        return (p0, p01, p012, p0123), (p0123, p123, p23, p3)

    # ---- SVG組み立て ----
    svg = []
    svg.append(
        f'''<svg xmlns="http://www.w3.org/2000/svg"
              width="{width}" height="{height}"
              style="background:white">
        <defs>
          <!-- 終点矢印 -->
          <marker id="arrowEnd" markerWidth="10" markerHeight="10" refX="9" refY="3" orient="auto">
            <path d="M0,0 L10,3 L0,6 Z"></path>
          </marker>

          <!-- 途中矢印(小さめ) -->
          <marker id="arrowMid" markerWidth="8" markerHeight="8" refX="6" refY="3" orient="auto">
            <path d="M0,0 L8,3 L0,6 Z"></path>
          </marker>

          <style>
            .node-text {{ font: 14px sans-serif; }}
            .node {{ cursor: default; }}
            .edge {{
              fill: none;
              stroke-width: 2;
              opacity: 0.9;
              marker-end: url(#arrowEnd);
              marker-mid: url(#arrowMid);
            }}
            .baseline {{ stroke-width: 1; opacity: 0.25; }}
          </style>
        </defs>
        '''
    )

    # 背景を白で固定
    svg.append(f'<rect x="0" y="0" width="{width}" height="{height}" fill="white"></rect>')

    # baseline
    svg.append(
        f'<line class="baseline" x1="0" y1="{y0}" x2="{width}" y2="{y0}" stroke="black"></line>'
    )

    # ---- edges(下側に湾曲、src→dstの向きのまま描く)----
    for e in edges:
        x1, x2 = x[e["src"]], x[e["dst"]]
        y_ctrl = y0 + e["h"]  # ★下方向

        # もとの1本Cubic Bezier
        p0 = (x1, y0)
        p1 = (x1, y_ctrl)
        p2 = (x2, y_ctrl)
        p3 = (x2, y0)

        # marker-mid を効かせるため2本に分割
        (a0, a1, a2, a3), (b0, b1, b2, b3) = split_cubic_half(p0, p1, p2, p3)

        d = (
            f"M {a0[0]} {a0[1]} "
            f"C {a1[0]} {a1[1]}, {a2[0]} {a2[1]}, {a3[0]} {a3[1]} "
            f"C {b1[0]} {b1[1]}, {b2[0]} {b2[1]}, {b3[0]} {b3[1]}"
        )

        svg.append(
            f'<path class="edge" d="{d}" stroke="black"><title>{e["src"]}{e["dst"]}</title></path>'
        )

    # ---- nodes(ラベルは上側:左右端でははみ出さないようにアンカー調整)----
    FONT_SIZE = 14
    CHAR_W = FONT_SIZE * 0.6   # 雑な文字幅推定
    PAD = 6                    # 左右の安全余白
    CLAMP_MAX_CHARS = 60       # 推定幅の暴れ防止

    for n in nodes:
        xi = x[n["idx"]]
        raw_text = n["text"]
        label = esc(raw_text)

        # 概算テキスト幅(px)
        est_chars = min(len(raw_text), CLAMP_MAX_CHARS)
        text_w = est_chars * CHAR_W

        # デフォルトは中央
        anchor = "middle"
        x_label = xi

        # 左右にはみ出すならアンカー変更
        if (xi - text_w / 2) < PAD:
            anchor = "start"
            x_label = PAD
        elif (xi + text_w / 2) > (width - PAD):
            anchor = "end"
            x_label = width - PAD

        svg.append(
            f'''
            <g class="node">
              <circle cx="{xi}" cy="{y0}" r="{node_r}" stroke="black" fill="white"></circle>
              <text class="node-text" x="{xi}" y="{y0 + 5}" text-anchor="middle">{n["idx"]}</text>
              <text class="node-text" x="{x_label}" y="{y0 - node_r - 8}" text-anchor="{anchor}">{label}</text>
              <title>{n["idx"]}: {label}</title>
            </g>
            '''
        )

    svg.append("</svg>")
    return "".join(svg), height



# Streamlit UI
st.set_page_config(page_title="MeCab & CaboCha Visualizer", layout="wide")
st.title("MeCab 形態素解析 + CaboCha 係り受け解析 可視化")

text = st.text_area("入力文", value="こんにちは、形態素解析と係り受け解析を行います。", height=120)
toks = mecab_tokens(text)
unknowns = [t for t in toks if t["is_unknown"]]

st.subheader("形態素解析の結果")
st.caption(f"未知語候補: {len(unknowns)}")
st.dataframe(toks, use_container_width=True)

st.subheader("係り受け構造のグラフ表示")
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]

if not lines:
    st.info("入力が空です。")
else:
    for i, line in enumerate(lines, start=1):
        lattice_i = cabocha_lattice(line)
        chunks_i = parse_cabocha_lattice(lattice_i)

        svg_i, svg_h_i = build_arc_svg(chunks_i)

        st.markdown(f"**{i}行目**: {line}")
        components.html(svg_i, height=svg_h_i + 20, scrolling=True)

        if i != len(lines):
            st.divider()

以下にページのスクリーンショットを示します。まず、形態素解析の結果を表示する部分です。

形態素解析の結果

以下は、係り受けの関係をグラフで表したものです。各行ごと個別に解析しています。

係り受け解析の結果のグラフ表示

is_unknownの列にチェックがつく場合は、未知語の判定が出ています。

形態素解析で未知語が出る場合

まとめ

日本語文に対する形態素解析と係り受け解析を行い、Webページでの可視化を実施しました。
今回、MeCabやCaboChaを用いましたが、形態素解析や係り受け解析だけでも様々な方法があり、別の方法を用いたり並列で動かしてそれぞれの結果を見れるようにしても面白そうです。そのほか、トークン数をカウントしたり、テキストに色々都合の良い変換をかけたりすることを組み合わせてもよいと思います。

ライセンス面などの問題で、現状は作ったものを実務に投入していません。うまく別のツールへの切り替えや、モデルの学習などを行って、実務にも投入できたらうれしいなと思います。

ポート株式会社 エンジニアブログ

Discussion