WordPress ブログ記事を知識ソースとした GraphRAG チャットアプリ。 Blazor WASM + LadybugDB + WebLLM をブラウザ内で動かし、外部 API 不要でオフライン動作する。
azuremoe-chat/
├── src/
│ ├── AzureMoe.Chat.Core/ 共有ライブラリ (スキーマ定数・チャンク化)
│ ├── AzureMoe.Chat.Ingest/ インジェスト CLI
│ ├── AzureMoe.Chat.Verify/ 検索動作確認 CLI
│ └── AzureMoe.Chat.Web/ Blazor WASM チャットアプリ (Phase 2)
├── docs/
│ └── architecture.md 設計ドキュメント
├── poc/ Phase 0 技術検証コード
└── model/ 埋め込みモデル置き場 (git 管理外)
└── Xenova/
└── multilingual-e5-small/
- .NET 10 SDK
- Windows x64 (LadybugDB ネイティブバインディングが win-x64 のみ)
- WordPress エクスポート XML ファイル
- エンティティ抽出用ローカル LLM (OpenAI 互換エンドポイント)
インジェストと検索ツールの両方が Xenova/multilingual-e5-small (ONNX 量子化版) を使用する。
| 項目 | 値 |
|---|---|
| HuggingFace リポジトリ | Xenova/multilingual-e5-small |
| ベースモデル | intfloat/multilingual-e5-small (XLM-RoBERTa ベース) |
| パラメータ数 | 約 117M |
| 埋め込み次元 | 384 |
| 最大シーケンス長 | 512 トークン |
| モデルファイルサイズ | model_quantized.onnx (INT8) — 約 118 MB |
| 言語 | 100 言語対応 (日本語含む) |
以下のファイルを model/Xenova/multilingual-e5-small/ に配置する。
必要なファイル:
model/Xenova/multilingual-e5-small/
├── tokenizer.json トークナイザー設定
├── tokenizer_config.json トークナイザー設定
├── special_tokens_map.json 特殊トークン定義
├── sentencepiece.bpe.model SentencePiece モデル (~4.9 MB)
└── onnx/
└── model_quantized.onnx INT8 量子化モデル (~118 MB) ← 必須
model.onnx(FP32 フル精度, ~470 MB) も使用可能。model_quantized.onnxが優先される。
方法 1: huggingface-hub (推奨)
pip install huggingface-hub
huggingface-cli download Xenova/multilingual-e5-small \
tokenizer.json tokenizer_config.json special_tokens_map.json sentencepiece.bpe.model \
onnx/model_quantized.onnx \
--local-dir model/Xenova/multilingual-e5-small方法 2: 手動ダウンロード
HuggingFace の Files タブ から上記ファイルを個別にダウンロードし、フォルダ構成通りに配置する。
- WordPress 管理画面 → ツール → エクスポート
- 「すべてのコンテンツ」または「投稿」を選択してエクスポート
- ダウンロードした
.xmlファイルを.tmp/フォルダに配置
.tmp/
└── wordpress.2026-06-14.xml
WordPress XML を読み込んでエンティティを抽出し、GraphDB を構築して out/ に出力する。
dotnet run --project src/AzureMoe.Chat.Ingest設定の優先順位: コマンドライン引数 > 環境変数 > appsettings.json > デフォルト値
| 引数 | 環境変数 | デフォルト | 説明 |
|---|---|---|---|
--XmlDir |
— | .tmp |
WordPress エクスポート XML が入ったディレクトリ |
--MaxPosts |
— | 0 (全件) |
処理する最大投稿数。動作確認時は 10 など小さい値に |
| 引数 | 環境変数 | デフォルト | 説明 |
|---|---|---|---|
--ModelDir |
— | model/Xenova/multilingual-e5-small |
ONNX モデルのディレクトリパス |
エンティティ・Azure サービス名の抽出に使用する OpenAI 互換エンドポイント。 通常記事はチャンクごとにエンティティ/関係/サービス名を LLM で抽出する。 Azure Update 記事のサービス名は HTML の H2 構造から確定的に取得するため LLM 抽出対象外。
| 引数 | 環境変数 | デフォルト | 説明 |
|---|---|---|---|
--LlmBaseUrl |
LLM_BASE_URL |
http://localhost:11434/v1 |
LLM エンドポイントのベース URL |
--LlmModel |
LLM_MODEL |
qwen3:8b |
モデル名 (サーバーに読み込まれているモデル) |
--LlmApiKey |
LLM_API_KEY |
(なし) | API キー。ローカルサーバーは通常不要 |
主要な LLM サーバー別設定例:
| サーバー | LlmBaseUrl |
LlmModel 例 |
|---|---|---|
| Ollama | http://localhost:11434/v1 |
qwen3:8b, llama3.1:8b |
| LM Studio | http://localhost:1234/v1 |
ロード中のモデル名 |
| llama.cpp server | http://localhost:8080/v1 |
(引数不要のことも多い) |
| 引数 | 環境変数 | デフォルト | 説明 |
|---|---|---|---|
--OutDir |
— | out |
.lbdb ファイルと manifest.json の出力先 |
機密情報以外をファイルで管理したい場合:
{
"XmlDir": ".tmp",
"ModelDir": "model/Xenova/multilingual-e5-small",
"LlmBaseUrl": "http://localhost:11434/v1",
"LlmModel": "qwen3:8b",
"OutDir": "out"
}out/
├── blog-20260614120000.lbdb GraphDB ファイル (Ladybug 0.17.x 形式)
└── manifest.json メタデータ (モデル情報・件数・SHA-256)
各
Chunkには所属Postの日付・タイトル・年・月が非正規化保存される (チャンク単位の日付フィルタ用)。 グラフの詳細構造は「GraphDB 構造」セクションを参照。
構築済み .lbdb を読み取り専用で開き、データが期待通りかを検証する。
「2026年2月の話題」のような時期クエリで的外れな結果が返る場合の原因切り分けに使う。
# 1. 統計サマリ: ノード/エッジ件数・日付分布(月別)・次数上位のエンティティ/サービス・サンプル Chunk
dotnet run --project src/AzureMoe.Chat.Ingest -- inspect
# DB を明示指定 (省略時は out/ → wwwroot/data/ の順に最新 .lbdb を自動検出)
dotnet run --project src/AzureMoe.Chat.Ingest -- inspect out/blog-20260614120000.lbdb
# 2. 任意の Cypher を実行 (結果をテーブル表示)
dotnet run --project src/AzureMoe.Chat.Ingest -- inspect --cypher \
"MATCH (p:Post) WHERE p.date >= '2026-02-01' AND p.date < '2026-03-01' RETURN count(p)"
# 3. 自然文クエリでサンプルベクトル検索 (埋め込みモデルが必要・期待した記事が返るか確認)
dotnet run --project src/AzureMoe.Chat.Ingest -- inspect --query "2026年2月のAzure Functionsの更新" --topk 8| 引数 | デフォルト | 説明 |
|---|---|---|
| (位置引数) | (自動検出) | 対象 .lbdb ファイルパス |
--cypher "..." |
— | 任意の Cypher を実行して結果を表示 |
--query "..." |
— | 自然文を埋め込んでベクトル検索 (上位 --topk 件) |
--model |
model/Xenova/multilingual-e5-small |
--query 用 ONNX モデルのディレクトリ |
--topk |
8 |
--query で返す件数 |
既存の .lbdb にブログ記事を 1 件追加する。WordPress XML 全件再インジェストをせずに
新着記事だけをインクリメンタルに反映したいときに使う。
処理の流れ:
- 指定 URL のブログ記事を HTTP で取得
- 元の
.lbdbを日付付きファイル名でコピー (元ファイルは変更しない) - コピーしたファイルにチャンク・埋め込み・LLM 抽出結果を追記
- ベクトルインデックスを再作成してから
manifest.jsonを更新
# 基本: URL と元 DB を指定して追記
dotnet run --project src/AzureMoe.Chat.Ingest -- append \
https://example.com/2026/06/azure-update-june/ \
out/blog-20260618120000.lbdb
# 既に存在する URL を上書きしたい場合
dotnet run --project src/AzureMoe.Chat.Ingest -- append \
https://example.com/2026/06/azure-update-june/ \
out/blog-20260618120000.lbdb --Override
# 出力先を変える / LLM を切り替える
dotnet run --project src/AzureMoe.Chat.Ingest -- append \
https://example.com/2026/06/azure-update-june/ \
out/blog-20260618120000.lbdb \
--OutDir T:\temp\output \
--LlmBaseUrl http://localhost:1234/v1 \
--LlmModel qwen3-8b| 引数 | デフォルト | 説明 |
|---|---|---|
<url> (位置 1) |
— | 取得するブログ記事の URL |
<sourceDbPath> (位置 2) |
— | コピー元の .lbdb ファイルパス |
--Override |
false |
同じ URL の Post が既に存在する場合に削除して上書きする |
--OutDir |
out |
出力 .lbdb と manifest.json の書き出し先 |
--ModelDir |
model/Xenova/multilingual-e5-small |
ONNX 埋め込みモデルのディレクトリ |
--LlmBaseUrl |
http://localhost:11434/v1 |
LLM エンドポイント |
--LlmModel |
qwen3:8b |
LLM モデル名 |
--LlmApiKey |
(なし) | LLM API キー |
同じ URL を
--Overrideなしで追記しようとするとエラーになる。 出力ファイル名はblog-<yyyyMMdd>.lbdb(実行日の UTC 日付)。
インジェストが生成する .lbdb ファイルの内部構造。LadybugDB (グラフDB) に格納され、
ブラウザの WASM 版も同じファイルをそのまま開く。
ブログには構造的に異なる 2 種類の投稿が混在するため、タイトルで判定して処理を分岐させる。
週次アップデートまとめ記事。H2 見出しがサービス名、その配下の箇条書きが更新内容になっている。
<h2>Azure Functions</h2> ← serviceName = "Azure Functions"
<ul>
<li>Flex Consumption で新機能 ← Chunk 1 (chunkType = "update_item")
<ul><li>詳細...</li></ul> ← 子要素は親と一体で保持
</li>
<li>従量課金で改善 ← Chunk 2 (update_item)
</li>
</ul>
<h2>Azure Container Apps</h2> ← serviceName = "Azure Container Apps"
...
- チャンク単位:
<li>1 件 + その子要素(子要素は親への補足コメントとして一体保持) - 埋め込み入力:
"Azure Functions\n\nFlex Consumption で新機能..."(サービス名をプレフィックス) - サービス名: H2 テキストから確定的に取得(LLM 不要)
特定テーマについての文章記事。
- チャンク単位: 段落境界での 800 文字区切り(超長段落は文末で 1,200 文字まで強制分割)
- 埋め込み入力:
"記事タイトル\n\nチャンクテキスト" - セクション情報: 直前の H2/H3 見出しを
sectionTitleフィールドに記録
| ノード | 主なフィールド | 説明 |
|---|---|---|
Post |
id, title, url, date, year, month |
ブログ記事 1 件 |
Chunk |
下表参照 | テキストチャンク(ベクトル付き) |
AzureService |
name |
Azure サービス名(正式名称) |
Entity |
name, type, description |
LLM 抽出エンティティ(人物・技術・機能など) |
Tag |
name |
WordPress タグ |
Chunk フィールド詳細
| フィールド | 型 | 説明 |
|---|---|---|
id |
INT64 | 主キー |
postId |
INT64 | 所属 Post の id |
ordinal |
INT64 | Post 内での順序 |
text |
STRING | チャンク本文 |
date / year / month |
STRING / INT64 | Post から非正規化(日付フィルタ用) |
title |
STRING | Post タイトル(引用表示用) |
sectionTitle |
STRING | 直前の H2/H3 見出しテキスト |
serviceName |
STRING | Azure Update 記事のみ: H2 のサービス名。通常記事は空文字 |
chunkType |
STRING | "update_item" (Update 記事の箇条書き) / "prose" (通常テキスト) |
emb |
FLOAT[384] | multilingual-e5-small による埋め込みベクトル |
Post ──HAS_CHUNK──────▶ Chunk
Post ──TAGGED──────────▶ Tag
Post ──COVERS_SERVICE──▶ AzureService ← Azure Update 記事は H2 から、通常記事は LLM から収集
Chunk ──MENTIONS───────▶ Entity ← LLM 抽出(全記事)
Entity ──RELATED_TO────▶ Entity ← LLM 抽出(全記事)
| インデックス名 | 対象 | 距離計算 |
|---|---|---|
chunk_emb_idx |
Chunk.emb |
コサイン類似度 (HNSW) |
| 記事タイプ | 収集元 | 特徴 |
|---|---|---|
| Azure Update 記事 | H2 見出しテキスト | 確定的・LLM 不要・ブレなし |
| 通常記事 | LLM (EntityExtractor) | 本文から柔軟に抽出 |
構築した GraphDB に対してベクトル検索を対話的に試せるツール。
# out/ から最新 .lbdb を自動検出
dotnet run --project src/AzureMoe.Chat.Verify
# DB を明示指定
dotnet run --project src/AzureMoe.Chat.Verify -- --DbPath out/blog-20260614120000.lbdb
# 結果件数を変える
dotnet run --project src/AzureMoe.Chat.Verify -- --TopK 10| 引数 | デフォルト | 説明 |
|---|---|---|
--DbPath |
(自動検出) | 検索対象の .lbdb ファイルパス |
--OutDir |
out |
DbPath 未指定時に最新 .lbdb を探すディレクトリ |
--ModelDir |
model/Xenova/multilingual-e5-small |
ONNX モデルのディレクトリパス |
--TopK |
5 |
返す検索結果の件数 |
> 検索クエリを入力して Enter
> \stats 統計情報を再表示
> q 終了
Blazor WebAssembly でブラウザ内完結の GraphRAG チャット。 LadybugDB (WASM)・transformers.js をすべてブラウザ内で実行する。 以下の優先順位で LLM バックエンドを自動選択するため どの環境でも動作する:
- Chrome 組み込み AI (Gemini Nano) — Chrome 127+ でモデルダウンロード不要
- transformers.js + WebGPU — WebGPU 対応ブラウザで GPU 推論
- transformers.js + WASM CPU — すべての環境で動作 (低速)
| 役割 | バックエンド | モデル / エンジン | 備考 |
|---|---|---|---|
| LLM (テキスト生成) | Chrome 組み込み AI | Gemini Nano | ダウンロード不要・最優先 |
| LLM (テキスト生成) | transformers.js | onnx-community/Qwen2.5-0.5B-Instruct (q4) |
Chrome AI 非対応時に自動ダウンロード |
| LLM (テキスト生成) | OpenAI 互換 HTTP | 任意 (LM Studio / Ollama 等) | /llm コマンドで実行中に切替可 |
| 埋め込み | transformers.js | Xenova/multilingual-e5-small |
起動時に自動ダウンロード |
モデルは transformers.js が Hugging Face Hub からダウンロードし、Cache API で自動キャッシュする。 2 回目以降はオフラインでも動作する。
必要な Chrome バージョン: Chrome 138+ (Dev または Canary チャンネル)。
ハードウェア要件:
- 空きストレージ 22 GB 以上
- RAM 16 GB 以上、または GPU VRAM 4 GB 以上
- Windows 10 / macOS 13 / Linux (ChromeOS Plusも可)
セットアップ手順:
chrome://flags/#optimization-guide-on-device-model→ Enabledchrome://flags/#prompt-api-for-gemini-nano→ Enabled (または "Enabled multilingual")- Chrome を再起動
chrome://on-device-internalsを開き、Gemini Nano モデルのダウンロード状況を確認。バージョンが0.0.0.0の場合は "Check for update" をクリック
DevTools で確認する方法 (F12 → Console):
// Chrome 138+ の場合
typeof LanguageModel // "function" なら有効
await LanguageModel.availability() // "available" なら即利用可能
// 旧ビルドの場合
typeof window.ai?.languageModel // "object" なら有効有効になると起動時に「Chrome 組み込み AI (Gemini Nano) 利用可能 — ダウンロード不要」と表示される。
Chrome AI が使えない環境向けに appsettings.json で変更可能:
{
"LlmModelId": "onnx-community/Qwen2.5-1.5B-Instruct",
"LlmDtype": "q4"
}| モデル | サイズ (q4) | 特徴 |
|---|---|---|
onnx-community/Qwen2.5-0.5B-Instruct |
~350 MB | 軽量・高速 (デフォルト) |
onnx-community/Qwen2.5-1.5B-Instruct |
~900 MB | 日本語品質が向上 |
- Node.js 18+ (
npm installが csproj ビルド時に自動実行される) - インジェストで生成した
manifest.jsonと.lbdbファイル - インターネット接続 (初回: モデルダウンロード。2 回目以降は不要)
1. インジェストで DB を生成
dotnet run --project src/AzureMoe.Chat.Ingest2. 生成物を Web プロジェクトの data ディレクトリにコピー
# Windows
copy out\manifest.json src\AzureMoe.Chat.Web\wwwroot\data\
copy out\*.lbdb src\AzureMoe.Chat.Web\wwwroot\data\
# Linux / macOS
cp out/manifest.json src/AzureMoe.Chat.Web/wwwroot/data/
cp out/*.lbdb src/AzureMoe.Chat.Web/wwwroot/data/3. 開発サーバーを起動
dotnet run --project src/AzureMoe.Chat.Webブラウザで https://localhost:5001 を開く。
src/AzureMoe.Chat.Web/wwwroot/appsettings.json で変更できる。
| キー | デフォルト | 説明 |
|---|---|---|
ManifestUrl |
data/manifest.json |
manifest.json の URL |
DbBaseUrl |
data/ |
DB ファイルのベース URL (manifest の databaseFile を結合) |
LlmModelId |
onnx-community/Qwen2.5-0.5B-Instruct |
LLM モデル ID (HuggingFace) |
LlmDtype |
q4 |
量子化精度。q4 / q8 / fp16 など |
LlmMaxNewTokens |
4096 |
最終回答の最大生成トークン数 (上限。EOS で自然停止) |
LlmEvalMaxTokens |
512 |
充足判定 (Deep) の最大トークン数 |
EmbeddingModelId |
Xenova/multilingual-e5-small |
埋め込みモデル ID (HuggingFace) |
RetrievalMode |
Normal |
探索の深さ。Fast / Normal / Deep (UI の /mode でも変更可) |
RagTopK |
6 |
HTTP LLM モード時の最終参照数上限 |
MaxContextChars |
6000 |
HTTP LLM モード時に LLM へ渡す文脈の最大文字数 |
LocalRagTopK |
3 |
ローカル WASM LLM モード時の参照数上限 (2B 級モデル向け圧縮設定) |
LocalMaxContextChars |
2500 |
ローカル WASM LLM モード時の文脈最大文字数 |
LocalPerRefMaxChars |
800 |
ローカル WASM LLM モード時の参照1件あたりの最大文字数 |
HistoryTurns |
3 |
Deep モードで LLM に渡す直近会話ターン数 |
DeepMaxRounds |
3 |
Deep モードの検索クエリ数 (round-0 + 上位タイトルでの追検索) |
VerifyGrounding |
true |
生成後に「回答が参考情報で裏付けられるか」を検査し、不十分なら警告を表示 |
SystemPrompt |
(組み込み) | システムプロンプト (キャラ付け + GraphRAG ルール) |
検索の深さと応答速度のトレードオフを選べる。UI の /mode で実行中にも切替可能。
| モード | 検索の広さ | 内容 | 会話履歴 | 体感 |
|---|---|---|---|---|
Fast |
狭 | グラフ探索なし・純ベクトル検索 | 使用しない | 最軽量 |
Normal |
中 | グラフ探索あり | 使用しない (単発質問) | バランス (既定) |
Deep |
広 | 関連エンティティまで辿る + 上位記事タイトルで決定的に追検索 (複数クエリ, 最大 DeepMaxRounds) |
直近 HistoryTurns ターン |
高精度・低速 |
全モード共通の2段構え:
- recall — モードに応じてベクトル+グラフ+日付で候補を広く集める。
- precision — 候補を**「元の質問」へのコサイン類似度で再ランクし、質問の関連プール (上位) から外れたものを足切り**する。 グラフのつながり (共有タグ/エンティティ/サービス) は同点時の微加点に留め、的外れな拡張が本筋の記事を上回らないようにする。 これにより Deep でも「件数は多いが質問に合わない」を防ぐ。
質問から日付 (例: 「2026年2月」「先月」) を検出した場合は、再ランクも日付窓の中で行い範囲外の記事を除外する。
応答時間について: ブラウザ内 CPU では最終回答の生成時間が支配的で、これは全モード共通。 そのため Fast と Normal の体感差は小さく (主に検索の広さと精度で差が出る)、 複数クエリを投げる Deep が明確に遅い。各ステップの進行状況はチャット画面に表示される。
グラウンディングの担保: LLM が学習知識で一般論を返さないよう、全モードで次を行う。
- 関連する参考情報が1件も見つからない場合は生成を行わず、「見つからなかった」旨を返す (
VerifyGroundingとは独立に常時)。 - 生成後に
VerifyGrounding=true なら、回答が参考情報で裏付けられるかを検査する。裏付け不足と判定された場合は、より厳密なプロンプト(参考情報のみ・[n] 引用必須)で1回だけ再生成し、それでも裏付けられなければ回答の下に⚠警告を表示する。 - 検索結果は「元の質問」への関連度で再ランク・足切りされるため、文脈には質問に合致した参照のみが渡る(precision 重視)。
チャット入力欄で以下のコマンドが使える。
| コマンド | 動作 |
|---|---|
/help |
コマンド一覧を表示 |
/mode [fast|normal|deep] |
探索モードを表示 / 変更 (引数なしで現在値を表示) |
/llm [endpoint [model]] |
外部 OpenAI 互換 LLM を設定 (例: /llm http://localhost:1234/v1 gpt-model)。引数なしでローカル WASM に戻す |
/debug [on|off] |
デバッグ出力の表示切替。ON 時は埋め込み・Cypher・LLM プロンプト/応答をチャット内に表示 |
/info |
現在の LLM / 埋め込みモデル情報と探索モードを表示 |
/license |
ライセンス情報を表示 |
/clear |
画面と会話履歴をクリア |
/reload |
アプリを再起動 |
| (それ以外) | RAG クエリとして実行 |
応答生成中は入力欄の右に 「■ 停止」ボタンが表示され、クリックすると LLM の生成を即座に中断する
(緊急停止)。途中まで生成された回答は残る。長い生成 (LlmMaxNewTokens が大きい) や、まれに小型モデルが
同じ文言を繰り返すループに入った場合の保険として使える (ループ自体は no_repeat_ngram_size で抑制済み)。
URL に以下のクエリパラメータを付けると、ページを開いた時点から /llm モードが有効になる。
/llm コマンドによる手動設定と同じ効果。
| パラメータ | 必須 | 説明 |
|---|---|---|
llm |
必須 | OpenAI 互換エンドポイントの URL |
model |
任意 | モデル名。省略時はエンドポイント側のデフォルト |
# LM Studio
https://chat.azure.moe/?llm=http://localhost:1234/v1&model=qwen3-8b
# Ollama
https://chat.azure.moe/?llm=http://localhost:11434/v1&model=qwen3:8b
# OpenAI 互換 API
https://chat.azure.moe/?llm=https://api.openai.com/v1&model=gpt-4o
スマートフォンでの利用: iOS/Android ではメモリ制約によりローカル LLM・埋め込みモデルをスキップし、 GraphDB に対してキーワード検索のみ行うモードで起動する。
?llm=...を付けると外部 LLM との組み合わせで回答生成も可能になる。
Mixed Content に注意: デプロイ済みサイト (
https://) からhttp://localhostへの接続は ブラウザの Mixed Content ポリシーによりブロックされる (CORS 設定とは無関係)。 LM Studio / Ollama などローカルサーバーを使う場合は、開発用サーバー (dotnet run) 経由でhttp://localhost:5001からアクセスするか、HTTPS に対応したリバースプロキシを用意すること。
- 起動時: manifest.json 取得 → DB ダウンロード → LadybugDB WASM 初期化 → transformers.js モデル読み込み
- 質問入力時 (モードにより広さが変わる): クエリ解析 (日付・キーワード抽出) → ベクトル+グラフ+日付で候補を収集 → 元の質問への関連度で再ランク・足切り → URL 単位でまとめてコンテキスト構築 (本文中の URL を除去して圧縮) → LLM でストリーミング生成 → 接地検査
- WebGPU 非対応環境: WASM CPU にフォールバックして生成 (低速)