Claude Codeでkuromojiのユーザー辞書を作る — 社内用語の分割を検証する
社内用語や製品名がkuromojiで細切れになる問題を、用語集TSVから辞書CSVを生成して直します。登録前後の分割をLucene 9.11.1で比べ、Claude Codeのrulesで形式の約束を固定します。
社内の検索基盤にkuromojiを使っていると、製品名や社内用語がばらばらに切られて検索に当たらない場面が出ます。たとえば「経費精算くん」は、辞書を足さないと「経費 / 精算 / くん」の3語に分かれます。
直し方は、kuromojiのユーザー辞書に語を登録することです。ただし辞書CSVの書式は厳しく、間違えると読み込みの時点で例外になります。そこで、用語集のTSVから辞書CSVを作るスクリプトと、登録前後の分割を並べて見る確認プログラムを、Claude Codeに回させる構成を作ります。
分割結果はすべて、Lucene 9.11.1(OpenJDK 17)で実際に流した出力です。Elasticsearchへの載せ方は、プラグインの設定項目に沿って示します。
登録前に、どの語がどう切られるかを見る
ユーザー辞書は「切られて困る語」にだけ効きます。まず登録前の分割を実測します。次の3語は、用語集に載せた社内用語の例です。
| 入力 | 辞書なし(NORMAL) | 辞書あり(NORMAL) |
|---|---|---|
| 経費精算くんで申請しました | 辞書なし(NORMAL)経費 / 精算 / くん / で / 申請 / し / まし / た | 辞書あり(NORMAL)経費精算くん / で / 申請 / し / まし / た |
| 月次締めの前に確認する | 辞書なし(NORMAL)月次 / 締め / の / 前 / に / 確認 / する | 辞書あり(NORMAL)月次締め / の / 前 / に / 確認 / する |
| 稟議フローを見直す | 辞書なし(NORMAL)稟議 / フロー / を / 見直す | 辞書あり(NORMAL)稟議 / フロー / を / 見直す |
3行目の「稟議フロー」は、登録前から「稟議 / フロー」と切られています。辞書を足しても結果は変わりません。差が出ない語は、辞書に入れる意味がありません。
用語集の全語を機械的にCSVへ流すより、登録前の分割を見て、崩れた語だけを残すほうが辞書は小さく保てます。
切り方を指定する登録もあります。「東京スカイツリー」は辞書なしだと「東京 / スカイ / ツリー」ですが、東京 スカイツリーと分割を指定して登録すると「東京 / スカイツリー」に変わりました。語を1つにまとめる登録と、切り方を指定する登録の2種類があると押さえておけば足ります。
辞書CSVは4列で、書式違反は読み込み時に落ちる
ユーザー辞書の書式は、Elasticsearchのkuromojiプラグインの説明では次の4列です。
<text>,<token 1> ... <token n>,<reading 1> ... <reading n>,<part-of-speech tag>表層形、空白区切りの分割、空白区切りの読み、品詞タグの順です。kuromoji_tokenizerの説明は、既定でMeCab-IPADIC辞書を使い、ユーザー辞書はそこに追加されると書いています。例として挙がっているのは、次の1行です。
東京スカイツリー,東京 スカイツリー,トウキョウ スカイツリー,カスタム名詞LuceneのUserDictionaryは、この4列を読み込む時点で検査します。壊した行を読ませると、次のように落ちました。
| 壊し方 | Lucene 9.11.1の挙動 |
|---|---|
分割を連結しても表層形にならない(経費精算くん,経費 精算,…) | Lucene 9.11.1の挙動Illegal user dictionary entry … the concatenated segmentation (経費精算) does not match the surface form (経費精算くん) |
| 分割の数と読みの数が合わない | Lucene 9.11.1の挙動the number of segmentations (1) does not the match number of readings (2) |
| 品詞の列を省いた3列の行 | Lucene 9.11.1の挙動ArrayIndexOutOfBoundsException(メッセージは列の添字のみ) |
| 同じ表層形の行を2回書く | Lucene 9.11.1の挙動UnsupportedOperationException(Outputs.merge) |
1行目の「連結が表層形に一致すること」は、手で書くと間違えやすい規則です。「経費精算くん」を「経費 精算」と分割して「くん」を落とすと、辞書を開いた時点で止まります。
重複行だけは、例外から原因が読めません。Elasticsearchのトークナイザーにはlenientという設定があり、user_dictionaryをtext列で重複排除するかどうかを決めます。既定はfalseで、重複があるとエラーになります。重複の検査は、辞書を生成する側に持たせるのが安全です。
読みの列は、Elasticsearchの例がどれも全角カタカナです。以降のスクリプトも、この例に合わせて読みを全角カタカナだけ通します。
用語集TSVから辞書CSVを生成する
運用では、人が触る用語集と、機械が読む辞書CSVを分けます。用語集は3列のTSV(表層形・分割・読み)で、品詞は全行カスタム名詞に固定します。
# 表層形 分割 読み
経費精算くん 経費精算くん ケイヒセイサンクン
稟議フロー 稟議 フロー リンギ フロー
月次締め 月次締め ゲツジシメ
東京スカイツリー 東京 スカイツリー トウキョウ スカイツリー次のスクリプトは、上で見た書式違反をCSVを書く前に検査します。標準ライブラリだけで動きます。
#!/usr/bin/env python3
"""glossary.tsv から Kuromoji 用の userdict.csv を作る。"""
import csv, re, sys
KATA = re.compile(r"^[ァ-ヶー]+$")
POS = "カスタム名詞"
def main(src, dst):
seen, rows, errors = set(), [], []
with open(src, encoding="utf-8", newline="") as f:
for n, rec in enumerate(csv.reader(f, delimiter="\t"), 1):
if not rec or rec[0].startswith("#"):
continue
if len(rec) != 3:
errors.append(f"{n}行目: 列数が3ではありません")
continue
surface, seg, reading = (c.strip() for c in rec)
parts, yomi = seg.split(), reading.split()
if "".join(parts) != surface:
errors.append(f"{n}行目: 分割を連結すると {''.join(parts)}、表層形は {surface}")
if len(parts) != len(yomi):
errors.append(f"{n}行目: 分割 {len(parts)} 個に対し読みが {len(yomi)} 個")
if not all(KATA.match(y) for y in yomi):
errors.append(f"{n}行目: 読みは全角カタカナだけにしてください")
if surface in seen:
errors.append(f"{n}行目: {surface} が重複しています")
seen.add(surface)
rows.append(f"{surface},{seg},{reading},{POS}")
if errors:
print("\n".join(errors), file=sys.stderr)
sys.exit(1)
with open(dst, "w", encoding="utf-8") as f:
f.write("\n".join(rows) + "\n")
print(f"{len(rows)}語を {dst} に書き出しました")
if __name__ == "__main__":
main(*sys.argv[1:3])用語集に経費精算くん 経費 精算 ケイヒ セイサンと書いて流すと、終了コード1で次のメッセージを返します。
1行目: 分割を連結すると 経費精算、表層形は 経費精算くんLuceneの例外メッセージと同じ内容を、行番号つきで早く返す役割です。Claude Codeにこのメッセージを読ませれば、「くん」を戻す修正まで任せられます。
登録前後の分割を並べて見る確認プログラム
辞書が読み込めても、期待どおりに切れる保証はありません。分割結果を直接見るために、LuceneのJapaneseTokenizerを呼ぶ小さなJavaプログラムを置きます。javaが使える環境なら、Luceneのjarを3本(lucene-core / lucene-analysis-kuromoji / lucene-analysis-common、今回は9.11.1)並べるだけで動きます。
import java.io.*;
import java.nio.file.*;
import org.apache.lucene.analysis.ja.*;
import org.apache.lucene.analysis.ja.dict.UserDictionary;
import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
public class Seg {
public static void main(String[] a) throws Exception {
UserDictionary ud = null;
if (!a[0].equals("-")) {
try (Reader r = Files.newBufferedReader(Paths.get(a[0]))) {
ud = UserDictionary.open(r);
}
}
var mode = JapaneseTokenizer.Mode.valueOf(a[1]);
for (String s : Files.readAllLines(Paths.get(a[2]))) {
var t = new JapaneseTokenizer(ud, true, true, mode);
t.setReader(new StringReader(s));
var c = t.addAttribute(CharTermAttribute.class);
t.reset();
var sb = new StringBuilder();
while (t.incrementToken()) sb.append(c).append(" / ");
t.end();
t.close();
System.out.println(s + "\n -> " + sb);
}
}
}第1引数が-なら辞書なし、CSVのパスなら辞書あり。第2引数はNORMALかSEARCHで、第3引数は確認したい文を1行ずつ書いたファイルです。コンパイルと実行は次のとおりです。
CP=lucene-core.jar:lucene-analysis-kuromoji.jar:lucene-analysis-common.jar
python3 -I build_userdict.py glossary.tsv userdict.csv
javac -cp $CP -d out Seg.java
java -cp $CP:out Seg - NORMAL sample.txt
java -cp $CP:out Seg userdict.csv NORMAL sample.txt出力は、登録前が「経費 / 精算 / くん / で / …」、登録後が「経費精算くん / で / …」でした。前の節の表は、この2回の実行結果をそのまま並べたものです。
SEARCHモードでは、登録した語が分解されなくなる
kuromojiには、複合語を分解して検索に当てやすくするSEARCHモードがあります。Elasticsearchのmode設定はnormal、search、extendedの3つです。辞書なしでSEARCHを実行すると、「関西国際空港」は次のように分解されました。
| 入力 | 辞書なし(SEARCH) | 辞書あり(SEARCH) |
|---|---|---|
| 関西国際空港へ向かう | 辞書なし(SEARCH)関西 / 国際 / 空港 / へ / 向かう | 辞書あり(SEARCH)関西国際空港 / へ / 向かう |
| 東京スカイツリーに行く | 辞書なし(SEARCH)東京 / スカイ / ツリー / に / 行く | 辞書あり(SEARCH)東京 / スカイツリー / に / 行く |
辞書に関西国際空港,関西国際空港,カンサイコクサイクウコウ,カスタム名詞を足すと、SEARCHでも1語のまま残りました。登録した語は、複合語の分解の対象から外れます。
「製品名は分解せずに引きたいが、一般の複合語は分解して引きたい」という要求には、ユーザー辞書が合います。
Elasticsearchに載せる
生成したCSVの載せ方は2通りです。1つは、$ES_HOME/config/userdict_ja.txtのようにファイルとして置き、user_dictionaryで指す方法です。もう1つは、user_dictionary_rulesにルールを配列で直接書く方法です。ドキュメントの例は次の形です。
PUT kuromoji_sample
{
"settings": {
"index": {
"analysis": {
"tokenizer": {
"kuromoji_user_dict": {
"type": "kuromoji_tokenizer",
"mode": "extended",
"user_dictionary_rules": [
"東京スカイツリー,東京 スカイツリー,トウキョウ スカイツリー,カスタム名詞"
]
}
},
"analyzer": {
"my_analyzer": {
"type": "custom",
"tokenizer": "kuromoji_user_dict"
}
}
}
}
}
}ファイル方式は、各ノードのconfig配下に同じファイルを置く前提です。user_dictionary_rulesならインデックス設定に辞書が含まれるので、ファイルの配布が要りません。語が数百行を超えるなら、CSVを読んでこのJSONを組み立てる処理を生成スクリプトに足す手もあります。
反映後は_analyzeで確かめます。ドキュメントの例では、東京スカイツリーを分析すると「東京」と「スカイツリー」の2トークンが返ります。
GET kuromoji_sample/_analyze
{
"analyzer": "my_analyzer",
"text": "東京スカイツリー"
}Claude Codeに任せる範囲と、rulesで固定する約束
ここからがClaude Code側の設定です。用語集の追加は、次の流れを繰り返す作業です。
- 用語集TSVに行を足す
- 生成スクリプトで検査してCSVを書き出す
- 確認プログラムで登録前後の分割を見る
- 変化しない語を用語集から外す
この流れの約束を、Claude Codeの.claude/rules/に書きます。pathsを付けたルールは、一致するファイルをClaudeがRead、Write、Editしたときに読み込まれます。Bashのcatやheadで1ファイルを読んだ場合も対象です。辞書まわりのファイルだけに効かせたいので、pathsで絞ります。
---
paths:
- "search/glossary.tsv"
- "search/userdict.csv"
- "search/build_userdict.py"
---
- userdict.csv は手で編集しない。glossary.tsv を直して build_userdict.py で生成する
- 用語集の列は 表層形・分割・読み の3列。分割と読みは空白区切りで、個数をそろえる
- 分割を連結すると表層形に一致すること。一致しない行はLuceneが読み込みで例外にする
- 読みは全角カタカナ。品詞は生成スクリプトが カスタム名詞 で固定する
- 行を足したら必ず check.sh を実行し、登録前後の分割が変わった語だけを残す
- 変化しない語(辞書なしでも同じ分割になる語)は用語集に入れないpathsは、ルールのフロントマターでClaude Codeが読む唯一のフィールドです。それ以外のフィールドはエラーなしで無視されるので、descriptionのような独自キーを足しても効きません。
ルールを書くときの注意が2つあります。1つ目は量です。メモリーの説明は、CLAUDE.mdを1ファイル200行以内に収める目安を示し、長いほど遵守率が下がると書いています。辞書の約束は専用ルールに切り出し、CLAUDE.mdからは外します。
2つ目は読み込まれる場面です。生成スクリプトの出力先に新しいファイルをWriteで作る場面では、pathsつきのルールが読まれない既知の問題がありました。経緯と現状はClaude Code rulesがWriteの新規ファイルで読まれない問題と現状にまとめています。
ルールは指示であって、厳密な遵守は保証されません。メモリーの説明は、コミットの前や編集の後など決まった時点で必ず走らせたい処理を、hookで書くよう勧めています。辞書の検査はこちらに当たります。失敗をClaudeに返す具体的な設定は、Claude CodeでGDScriptをGUTでテストするのPostToolUseの節が参考になります。
check.shは、生成スクリプトと確認プログラムを順に呼ぶだけで足ります。
#!/bin/sh
set -e
CP=lib/lucene-core.jar:lib/lucene-analysis-kuromoji.jar:lib/lucene-analysis-common.jar
python3 -I search/build_userdict.py search/glossary.tsv search/userdict.csv
echo "--- 辞書なし"; java -cp $CP:out Seg - NORMAL search/sample.txt
echo "--- 辞書あり"; java -cp $CP:out Seg search/userdict.csv NORMAL search/sample.txtsample.txtには、用語集の語を含む実際の検索文を1行ずつ書いておきます。Claudeはこの出力の2つのブロックを見比べて、変化がない語を用語集から外せます。
用語集そのものをCLAUDE.mdに置く運用は、Claude Codeのドメイン用語集をCLAUDE.mdに置くに書かれています。そちらの用語集をTSVの元データにする手もあります。
つまずきやすい点
落とし穴
分割が表層形に戻らず、読み込みで落ちる
「経費精算くん」を「経費 精算」と分割すると、the concatenated segmentation (経費精算) does not match the surface formで止まります。分割はすべて連結して表層形に戻る形にします。生成スクリプトの検査が最初に見る箇所です。
- 辞書に入れても効かない語がある: 「稟議フロー」のように、登録前から意図どおり切れる語があります。差が出ない語は用語集から外します。
- 読みは分割1語につき1つ: 分割が3語なら読みも3語です。個数がずれると
the number of segmentations … does not the match number of readingsになります。 - 重複は例外から読みにくい: Luceneでは
UnsupportedOperationExceptionとして出るだけで、重複とは書かれません。スクリプト側で表層形の重複を弾いておきます。 - 確認はNORMALとSEARCHの両方で: 検索側のモードが
SEARCHなら、sample.txtの確認もSEARCHで実行します。登録した語が分解されなくなる挙動は、モードで見え方が変わるためです。
まとめ
ユーザー辞書は、切られて困る語に絞って登録するほど運用が軽くなります。絞り込みの根拠になるのが、登録前後の分割の比較です。差が出ない語を外し、書式違反は生成スクリプトの段階で止め、約束はpathsつきのrulesに残します。
この3点を固定すれば、用語集に行を足すたびにClaude Codeが検査して結果を返す流れになります。
本番のElasticsearchのmodeやフィルター構成で同じ結果になるかは、辞書を載せたインデックスの_analyzeで最後に確かめます。今回の実測はLucene 9.11.1のJapaneseTokenizer単体の結果で、使っているElasticsearchが内蔵するLuceneとは版が違うことがあります。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。