本稿では、.NET Framework 上で動作する検索エンジン「Lucene.Net」と、中国語および英語の形態素解析を行うコンポーネント「盘古分詞(PanGu)」を統合し、WeChat 公式アカウントのインテリジェントな自動応答機能を実装する方法について解説します。
盘古分詞の主な機能
このプロジェクトにおいて採用している分詞ライブラリは、以下の特性を持ち、検索精度の向上に寄与します。
- 未登録語の識別:辞書に含まれていない新用語や固有名詞を自動で検出。
- 頻度優先アルゴリズム:文脈に基づき、最も可能性の高い分割を選択することで曖昧性を解消。
- マルチ粒度出力:精度と粒度のバランスを考慮し、複数の候補結果を返す機能を提供。
- 人名認識:文脈から人物名を正しく抽出。(例:「张三」の認識)
- 繁簡体字対応:繁体字の入力を簡体字として処理、または両形式を併記可能。
- 全角文字サポート:英数字の全角形式も正しく認識。
プロジェクト構造とセットアップ
実行ファイルの配置先には、Lucene 系の DLL と词典ファイルを含むフォルダを用意します。具体的には、アプリケーションベースディレクトリ配下に \Config\lucenedir という索引用ディレクトリを作成し、初期化プロセスにおいてこれを対象とします。
キーワード検索インデックスの作成
データベースに登録された応答ルール(キーワードとアクションの紐付け情報)を、Lucene 検索用にインデックス化する処理です。スレッド安全性を確保しつつ、既存インデックスのロック解除や新規書込を行います。
/// <summary>
/// データベースからキーワード情報を取得し、検索用インデックスビルドを行う
/// </summary>
public void SyncKeywordDataToIndex()
{
lock (_executionLock)
{
if (_isProcessing) return;
_isProcessing = true;
try
{
var indexDirPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, @"Config\lucenedir");
var targetDirectory = FSDirectory.Open(new DirectoryInfo(indexDirPath), new NativeFSLockFactory());
// 既存インデックスの有無を確認
bool isIndexExists = IndexReader.IndexExists(targetDirectory);
if (isIndexExists && IndexWriter.IsLocked(targetDirectory))
{
// ロック状態の強制解除
IndexWriter.Unlock(targetDirectory);
}
// 書込オブジェクトの初期化
using (var idxWriter = new IndexWriter(targetDirectory, new PanGuAnalyzer(), !isIndexExists, IndexWriter.MaxFieldLength.UNLIMITED))
{
var service = new WechatKeyWordService();
var rules = service.GetActiveRules();
foreach (var rule in rules)
{
var doc = new Document();
// 検索対象となるキーワードフィールド(解析あり)
doc.Add(new Field("keyword_content", rule.KeyWord ?? "", Field.Store.YES, Field.Index.ANALYZED, TermVector.WITH_POSITIONS_OFFSETS));
// メタデータ(検索除外または完全一致向け)
doc.Add(new Field("rule_id", rule.Id.ToString(), Field.Store.YES, Field.Index.NOT_ANALYZED));
doc.Add(new Field("response_type", rule.TypeCode.ToString(), Field.Store.YES, Field.Index.NOT_ANALYZED));
doc.Add(new Field("raw_text", rule.Contents ?? "", Field.Store.YES, Field.Index.NOT_ANALYZED));
doc.Add(new Field("media_path", rule.FilePath ?? "", Field.Store.YES, Field.Index.NOT_ANALYZED));
// 追加メディア情報
doc.Add(new Field("img_url", rule.PicUrl ?? "", Field.Store.YES, Field.Index.NOT_ANALYZED));
doc.Add(new Field("title_info", rule.Title ?? "", Field.Store.YES, Field.Index.NOT_ANALYZED));
idxWriter.AddDocument(doc);
}
idxWriter.Commit();
_lastUpdateTime = DateTime.Now;
}
}
catch (Exception ex)
{
Logger.Error("インデックス更新エラー", ex);
}
finally
{
_isProcessing = false;
}
}
}
ユーザー入力に対する検索処理
ユーザーからのメッセージを受信したら、盘古分詞でトークン分割を行い、Lucene インデックスに対してブールクエリを生成して類似度の高いエントリーを検索します。
/// <summary>
/// ユーザーメッセージに基づき最適な応答コンテンツを特定する
/// </summary>
public string FindBestMatchResponse(string userInput)
{
if (string.IsNullOrWhiteSpace(userInput)) return "";
try
{
var indexLocation = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, @"Config\lucenedir");
// 入力テキストの単語分割
var tokenList = PanGuSplitter.Analyze(userInput);
using (var fsDirectory = FSDirectory.Open(new DirectoryInfo(indexLocation), new NoLockFactory()))
using (var reader = IndexReader.Open(fsDirectory, true))
{
var searcher = new IndexSearcher(reader);
var analyzer = new StandardAnalyzer(Lucene.Net.Util.Version.LUCENE_29);
var queryParser = new QueryParser(Lucene.Net.Util.Version.LUCENE_29, "keyword_content", analyzer);
var combinedQuery = new BooleanQuery();
// 各単語を SHOULD クラースに追加(OR 論理でのマッチング)
foreach (var token in tokenList)
{
combinedQuery.Add(new TermQuery(new Term("keyword_content", token)), Occur.SHOULD);
}
var hitCollector = TopScoreDocCollector.Create(1000, true);
searcher.Search(combinedQuery, null, hitCollector);
var topDocs = hitCollector.TopDocs(0, hitCollector.GetTotalHits()).ScoreDocs;
if (topDocs.Length == 0) return "";
// スコアが高い順に上位の結果を取得
var bestMatchDocId = topDocs[0].Doc;
var matchedDoc = searcher.Doc(bestMatchDocId);
// マッチしたレスポンス情報のマッピング
var responseType = int.Parse(matchedDoc.Get("response_type"));
var contentData = matchedDoc.Get("raw_text");
var mediaLink = matchedDoc.Get("img_url");
return BuildResponseXml(responseType, contentData, mediaLink);
}
}
catch (Exception ex)
{
Logger.Warn("検索処理中の例外", ex);
return "";
}
}
private string BuildResponseXml(int type, string text, string url)
{
switch (type)
{
case 0: // テキスト
return FormatTextXml(text);
case 1: // 画像
var uploadedImg = UploadMedia(url);
return uploadedImg != null ? FormatImageXml(uploadedImg.MediaId) : "";
default:
// その他メディアタイプ対応
return "";
}
}
業務フローと連携
上記の検索ロジックにより、ユーザーが「积分查一下」といった自然な表現を入力しても、分詞によって「积分」「查」「一下」に分解され、「积分」というキーワードを持つインデックスヒットが可能になります。ヒットした設定に従って、テキスト返答だけでなく、カスタムメニューへのリンク(H5 ページ遷移など)や画像素材を含めた応答を生成し、WeChat サーバーへ XML レスポンスとして返却します。
例えば、ユーザーがポイント情報を問い合わせた場合、以下のようなハイパーリンク付きのテキストメッセージを生成し、外部 Web サイトへ誘導することが可能です。
<a href='http://example.com/h5/user-points'>確認はこちら</a> のマイページで残高を確認できます。