本記事は Zilliz 公式ブログの A Developer's Guide to Exploring Milvus 2.6 Features on Zilliz Cloud(2026年2月11日 / Ivan Tang)を日本語向けに翻訳・再構成したものです。日本語環境固有の観点は「🇯🇵」マークのセクションとして加筆しています。
Milvus 2.6 の機能を 4 回に分けて扱うシリーズの第 3 回です。日本語で検索を作る人にとって、シリーズ中で一番関係が深い回だと思います。
| 回 | テーマ |
|---|---|
| ① Embedding Function | 埋め込み生成をデータベースに寄せる |
| ② Lexical Highlighting | なぜヒットしたのかを見せる |
| ③ N-gram インデックス(本記事) | 部分一致を速くする |
| ④ Decay Ranker と Boosting | ランキングを業務要件に寄せる |
N-gram インデックス
N-gram インデックスは、文字列を短く重なり合う断片に分解することで、部分一致・柔軟な一致・ワイルドカード的な検索を可能にする、検索エンジンの定番手法です。
日本語の例で見ると、「東京都庁」を 2-gram(bigram)に分解するとこうなります。
東京都庁 → 東京 / 京都 / 都庁
先頭から 1 文字ずつずらして 2 文字を切り出すだけです。辞書は使いません。この「東京都庁」から「京都」という断片が生まれてしまう点が、後述する N-gram の弱点そのものなので、頭の片隅に置いておいてください。
有用なシナリオは次の通りです。
-
部分文字列検索の性能改善 ——
LIKE %解約%のような中間一致。日本語は単語の切れ目が明示されないため、業務検索では中間一致が多用されます - インクリメンタルサーチ / オートコンプリート
- あいまい検索
-
識別子の検索 —— 型番(
SCM-2400A)、法人番号、申請番号、ドメイン名。形態素解析ではうまく切れない文字列が該当します
どう動くのか
指定した n-gram を含む文書を、各 n-gram から「それを含む文書 ID のリスト」への転置インデックスによって効率的に特定します。リストはソートされた状態で保持され、圧縮とクエリ実行の両方が効率化されます。
Milvus の場合、n-gram インデックスは Tantivy の上に構築されており、Tantivy は delta encoding、bitpacking、skip list といった圧縮技術で転置リストを小さく保ちます。結果として軽量なインデックスになります。
テキストフィールドを全走査する代わりに、Milvus は次の順で処理します。LIKE "%解約手続%" というクエリを例にすると、
-
述語を取り出す ——
解約手続 -
設定された gram サイズで分解する ——
解約/約手/手続 - 転置インデックスを引く —— それぞれの断片を含む文書 ID のリストを取得
- 積集合を取る —— 3 つすべてを含む文書だけを候補として残す
-
元の
LIKEパターンと照合する —— 候補が本当に「解約手続」という並びを含むかを検証
5 の検証が必要な理由は、4 の段階では断片が揃っているだけで、並び順は保証されないからです。たとえば「解約の際は、約手形の手続に注意」という文書は 3 つの断片をすべて含みますが、「解約手続」という連続した文字列は含みません。この種の偽候補を最後に落とします。
全走査に比べて、実際に中身を確認する対象が候補だけに絞られる、というのが速度の源です。
min_gram と max_gram で、生成する n-gram の最小長・最大長を指定できます。詳細は NGRAM Index のドキュメントを参照してください。
インデックスの作成と検索は次のようになります。
from pymilvus import MilvusClient
client = MilvusClient(uri="YOUR_CLUSTER_ENDPOINT", token="YOUR_API_KEY")
# NGRAM インデックスを作成する
index_params = client.prepare_index_params()
index_params.add_index(
field_name="content",
index_type="NGRAM",
index_name="ngram_index_content",
params={
"min_gram": 2, # 日本語は 2 から始めるのが無難
"max_gram": 3,
},
)
client.create_index(
collection_name="documents",
index_params=index_params,
)
# 中間一致で検索する
res = client.query(
collection_name="documents",
filter='content like "%解約手続%"',
output_fields=["title", "content"],
limit=10,
)
like の中間一致(%〜%)が、NGRAM インデックスによって全走査を回避できるパターンです。前方一致(解約手続%)や後方一致(%解約手続)も同様に高速化されます。
N-gram インデックスを使ったオートコンプリートのデモです。
ベストプラクティス
-
代表的なクエリでベンチマークを取る。 本番投入前に、
min_gramとmax_gramの設定が実際のユーザー行動と噛み合っているかを検証してください - ベクトル検索と戦略的に組み合わせる。 NGRAM で高速化したフィルタリングを事前フィルタとして使い、ベクトル類似度計算の前に候補集合を減らすと、全体のクエリレイテンシが改善します
-
インデックスを張りすぎない。 すべての VARCHAR フィールドが NGRAM の恩恵を受けるわけではありません。ワイルドカード付き
LIKEで頻繁に使うフィールドを優先してください - N-gram インデックスは大文字小文字を区別します。 トークンは元テキストのまま索引されるため、クエリ側も同じ表記で一致させる必要があります
🇯🇵 形態素解析 vs N-gram —— 日本語全文検索の古典的トレードオフ
ここからが日本語固有の話です。
日本語の全文検索には、昔から 2 つの流派があります。
| 形態素解析(lindera など) | N-gram(bigram) | |
|---|---|---|
| 分割方法 | 辞書に基づいて単語単位に切る | 機械的に N 文字ずつ切る |
| 弱点 | 検索漏れ(辞書にない語が切れない) | 検索ノイズ(意図しない語にマッチ) |
| 典型的な失敗 | 「東京都」で検索したのに、「東京」「都」に切られて漏れる | 「京都」で検索すると「東京都庁」がヒットする |
| インデックスサイズ | 小さい | 大きい |
| 未知語・新語 | 弱い | 強い |
PostgreSQL の pg_bigm、MySQL の ngram parser、Groonga / Mroonga など、日本語圏の検索基盤で bigram インデックスが定番なのは、辞書メンテナンスなしで検索漏れを防げるからです。逆に、形態素解析は精度が高い代わりに辞書の整備が前提になります。
Milvus 2.6 は、この両方を持っています。 lindera による形態素解析ベースの BM25 全文検索と、N-gram インデックスによる部分一致検索です。どちらか一方を選ぶのではなく、用途で使い分けるのが日本語では現実的だと思います。
- 語単位の全文検索・BM25 ランキング → lindera + BM25
- 部分一致・型番・オートコンプリート → N-gram インデックス
- 意味検索 → dense ベクトル
min_gram / max_gram は日本語だと 2 が基本
英語の例では 3-gram が使われていますが、日本語は bigram(2)が定石です。理由は単純で、日本語は 1 文字あたりの情報量が多く、2 文字あればかなり絞り込めるためです。3-gram にするとインデックスは小さくなりますが、2 文字の検索語(「契約」「解約」「請求」など)が引けなくなります。
日本語の業務検索では 2 文字のクエリは非常に多いので、まずは min_gram=2 から始めるのが無難です。
表記の揺れをどう吸収するか
原記事の「大文字小文字を区別する」は英語のケースの話ですが、日本語ではもっと厄介です。
まず前提として、NGRAM インデックスは VARCHAR の生の値に対して張られます。 enable_analyzer を伴う BM25 / TEXT_MATCH とは別系統なので、アナライザのフィルタチェーンは LIKE 検索には効きません。 つまりこれは設定で直る話ではなく、スキーマ設計の話です。
定石は、表示用フィールドと検索用フィールドを分けることです。
from pymilvus import DataType
schema = client.create_schema()
schema.add_field(field_name="id", datatype=DataType.INT64, is_primary=True)
# 表示用:元のテキストをそのまま保持する
schema.add_field(field_name="content", datatype=DataType.VARCHAR, max_length=65535)
# 検索用:正規化済み。こちらに NGRAM を張る
schema.add_field(field_name="content_norm", datatype=DataType.VARCHAR, max_length=65535)
正規化は投入時とクエリ時で必ず同じ関数を通します。ここを別々に実装すると、いずれ片方だけ変更されて静かに壊れます。
import unicodedata
def normalize(text: str) -> str:
"""投入時とクエリ時の両方でこれを通す"""
t = unicodedata.normalize("NFKC", text) # 全角英数 → 半角、半角カナ → 全角カナ
t = t.lower()
return t
# 検索:正規化済みフィールドを引き、表示は元フィールドから返す
res = client.query(
collection_name="documents",
filter=f'content_norm like "%{normalize(user_query)}%"',
output_fields=["content", "title"],
limit=10,
)
注意すべきなのは、NFKC で吸収できる揺れが限られていることです。
| 揺れの種類 | 例 | NFKC | 対処 |
|---|---|---|---|
| 全角 / 半角 |
ABC / ABC、サーバ / サーバ、123 / 123
|
✅ | NFKC で解決 |
| ひらがな / カタカナ |
みかん / ミカン
|
❌ |
normalize() に独自の変換を追加 |
| 長音の有無 |
サーバ / サーバー
|
❌ | 末尾長音の除去ルールを追加、または BM25 側でシノニム |
| 旧字体・異体字 |
髙橋 / 高橋、﨑 / 崎
|
❌ | 対応表を持つ。人名・地名では頻出 |
全角半角だけ NFKC で片付くと思っていると、カタカナ長音と異体字が残ります。日本企業のマスタデータでは、この 2 つは避けて通れません。必要な変換を normalize() に足していく形になります。
なお BM25 側は事情が違います。 アナライザはインデックス時とクエリ時に自動で同じ処理を適用するため、正規化をサーバ側に置けば非対称になりません。 長音や異体字は synonym フィルタで expand: false を指定し、代表形に寄せるのが素直です。
使い分けの指針
- BM25 / TEXT_MATCH → アナライザに寄せる。サーバ側で完結し、対称性が保証される
- NGRAM /
LIKE→ 正規化済みフィールドを別に持つ。クライアント側で対称性を担保する
最後に、過剰な正規化は逆効果です。ひらがな・カタカナを機械的に統合すると、区別が意味を持つ場面(商品名、固有名詞)で誤ヒットが増えます。自社データで実際に起きている揺れを数えてから、必要な変換だけを入れるのが結局は近道です。
オートコンプリートの日本語特有の問題
デモにあるオートコンプリートを日本語でやる場合、もうひとつ考慮点があります。日本語入力は変換前と変換後で文字列が変わります。
ユーザーが「けいやく」と打っている途中の状態では、まだ「契約」になっていません。ここでサジェストを出したいなら、読み仮名のフィールドを別に持って、そちらにも N-gram インデックスを張る必要があります。漢字のフィールドだけに索引していると、変換確定するまでサジェストが出ない、という挙動になります。
これは前項の正規化用フィールドと同じ発想で、用途ごとにフィールドを分ける設計です。
content … 表示用(元テキスト)
content_norm … 検索用(正規化済み、NGRAM)
content_yomi … サジェスト用(読み仮名、NGRAM)
商品名や地名を扱うサービスだと、この読み対応の有無が体験の差になります。
次回
最終回は Decay Ranker と Boosting です。検索結果の並び順を、鮮度や文書の重要度といった業務要件に合わせて制御する機能を扱います。シリーズ全体のまとめも最終回に置きます。
参考リンク
原記事
- A Developer's Guide to Exploring Milvus 2.6 Features on Zilliz Cloud(Ivan Tang, 2026年2月11日)
ドキュメント
日本語アナライザ関連
