IWordBreaker とファイル検索

「『プリキュア』で検索したら『ハートキャッチプリキュア』にマッチしない」という Windows Search の話.

Windows7に深刻なバグを発見したので、警鐘を鳴らすために晒してみます。
再現に使用したOSはWindows7 Home Premium x64です。

バグの再現手順

 
!!! 悪用厳禁 !!!
 
●1.適当にフォルダを作る 名前は何でもOK


 
●2.作ったフォルダーを開いて、
ハートキャッチプリキュア
ふたりはプリキュア
プリキュア
の3つのフォルダを新規作成する

 

●3.検索窓に「プリキュア」と入力してみる


 

●4.「ハートキャッチプリキュア」が無かったことにされる

ちくしょう!誰がこんなことを!メディーック!!メディーーーーック!!

対処方法

検索窓に「*プリキュア」と入れると全部ヒットするみたい。

でも、XPの頃は「プリキュア」で全部ヒットしてたのでなんか腑に落ちないアレが。

ちなみに検索インデックスの有無は関係ないみたいです。

#2010/10/30 11:05 追記
VistaやMacOSでも再現するとか。
Windowsの人は、「Everything」を使うと幸せになれるらしいです。

「従来何も考えずにファイル名の部分文字列で検索できていたのものを,どうしてアスタリスクが必要にしちゃったの?」という方向の話のような気もしますが,その辺は置いておいて久しぶりに IWordBreaker とか.
Windows 7 に標準で付いてくる日本語向け IWordBreaker 実装に「ハートキャッチプリキュア」等を食わせてみます.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Security;
using Microsoft.Win32;
using WordBreaker;

namespace WordBreakerTest
{
  using HRESULT = System.UInt32;
  public struct HResults
  {
    public const HRESULT S_OK = 0x00000000;
    public const HRESULT S_FALSE = 0x00000001;
    public const HRESULT E_FAIL = 0x80004005;
    public const HRESULT WBREAK_E_END_OF_TEXT = 0x80041780;
    public const HRESULT LANGUAGE_S_LARGE_WORD = 0x00041781;
    public const HRESULT WBREAK_E_QUERY_ONLY = 0x80041782;
    public const HRESULT WBREAK_E_BUFFER_TOO_SMALL = 0x80041783;
    public const HRESULT LANGUAGE_E_DATABASE_NOT_FOUND = 0x80041784;
    public const HRESULT WBREAK_E_INIT_FAILED = 0x80041785;
  }

  public enum WORDREP_BREAK_TYPE
  {
    WORDREP_BREAK_EOW = 0,
    WORDREP_BREAK_EOS = 1,
    WORDREP_BREAK_EOP = 2,
    WORDREP_BREAK_EOC = 3
  }

  [SuppressUnmanagedCodeSecurity]
  [ComImport, Guid("CC907054-C058-101A-B554-08002B33B0E6"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
  public interface IWordSink
  {
    [PreserveSig, MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
    HRESULT PutWord(
        uint cwc,
        [In][MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 0, ArraySubType = UnmanagedType.U2)] char[] pwcInBuf,
        uint cwcSrcLen,
        uint cwcSrcPos);
    [PreserveSig, MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
    HRESULT PutAltWord(
        uint cwc,
        [In][MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 0, ArraySubType = UnmanagedType.U2)] char[] pwcInBuf,
        uint cwcSrcLen,
        uint cwcSrcPos);
    [PreserveSig, MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
    HRESULT StartAltPhrase();
    [PreserveSig, MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
    HRESULT EndAltPhrase();
    [PreserveSig, MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
    HRESULT PutBreak(WORDREP_BREAK_TYPE breakType);
  }

  [SuppressUnmanagedCodeSecurity]
  [ComImport, Guid("CC906FF0-C058-101A-B554-08002B33B0E6"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
  public interface IPhraseSink
  {
    [Obsolete("Not supported.")]
    [PreserveSig, MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
    HRESULT PutSmallPhrase(
        [In][MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 1, ArraySubType = UnmanagedType.U2)] char[] pwcNoun,
        uint cwcNoun,
        [In][MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 3, ArraySubType = UnmanagedType.U2)] char[] pwcModifier,
        uint cwcModifier, uint ulAttachmentType);
    [PreserveSig, MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
    HRESULT PutPhrase(
        [In][MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 1, ArraySubType = UnmanagedType.U2)] char[] pwcPhrase,
        uint cwcPhrase);
  }

  public class WordSink : IWordSink
  {
    public Action<string, uint, uint> OnWord { get; set; }
    public Action<string, uint, uint> OnAltWord { get; set; }
    public Action<WORDREP_BREAK_TYPE> OnBreak { get; set; }
    #region CWordSink Members
    public HRESULT PutWord(
        uint cwc,
        [In][MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 0, ArraySubType = UnmanagedType.U2)] char[] pwcInBuf,
        uint cwcSrcLen,
        uint cwcSrcPos)
    {
      if (OnWord != null)
      {
        OnWord(new string(pwcInBuf), cwcSrcLen, cwcSrcPos);
      }
      return HResults.S_OK;
    }
    public HRESULT PutAltWord(
        uint cwc,
        [In][MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 0, ArraySubType = UnmanagedType.U2)] char[] pwcInBuf,
        uint cwcSrcLen,
        uint cwcSrcPos)
    {
      if (OnAltWord != null)
      {
        OnAltWord(new string(pwcInBuf), cwcSrcLen, cwcSrcPos);
      }
      return HResults.S_OK;
    }
    public HRESULT StartAltPhrase()
    {
      return HResults.S_OK;
    }
    public HRESULT EndAltPhrase()
    {
      return HResults.S_OK;
    }
    public HRESULT PutBreak(WORDREP_BREAK_TYPE breakType)
    {
      if (OnBreak != null)
      {
        OnBreak(breakType);
      }
      return HResults.S_OK;
    }
    #endregion
  }

  public class CPhraseSink : IPhraseSink
  {
    #region CPhraseSink Members
    public HRESULT PutSmallPhrase(
        [In][MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 1, ArraySubType = UnmanagedType.U2)] char[] pwcNoun,
        uint cwcNoun,
        [In][MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 3, ArraySubType = UnmanagedType.U2)] char[] pwcModifier,
        uint cwcModifier,
        uint ulAttachmentType)
    {
      return HResults.S_OK;
    }
    public HRESULT PutPhrase(
        [In][MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 1, ArraySubType = UnmanagedType.U2)] char[] pwcPhrase,
        uint cwcPhrase)
    {
      return HResults.S_OK;
    }
    #endregion
  }

  [UnmanagedFunctionPointer(CallingConvention.StdCall)]
  public delegate uint FillTextBufferDelegate(ref TEXT_SOURCE pTextSource);

  [StructLayout(LayoutKind.Sequential)]
  public struct TEXT_SOURCE
  {
    [MarshalAs(UnmanagedType.FunctionPtr)]
    public FillTextBufferDelegate pfnFillTextBuffer;
    [MarshalAs(UnmanagedType.LPWStr)]
    public string awcBuffer;
    public uint iEnd;
    public uint iCur;
  }

  [SuppressUnmanagedCodeSecurity]
  [ComImport, Guid("D53552C8-77E3-101A-B552-08002B33B0E6"), InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
  public interface IWordBreaker
  {
    [PreserveSig, MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
    HRESULT Init(
        [MarshalAs(UnmanagedType.Bool)] bool fQuery,
        uint maxTokenSize, [MarshalAs(UnmanagedType.Bool)] out bool pfLicense);
    [PreserveSig, MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
    HRESULT BreakText(
        ref TEXT_SOURCE pTextSource, [MarshalAs(UnmanagedType.Interface)] IWordSink pWordSink,
        [MarshalAs(UnmanagedType.Interface)] IPhraseSink pPhraseSink);
    [PreserveSig, MethodImpl(MethodImplOptions.InternalCall, MethodCodeType = MethodCodeType.Runtime)]
    HRESULT GetLicenseToUse([MarshalAs(UnmanagedType.LPWStr)] out string ppwcsLicense);
  }

  public static class Program
  {
    public static void BreakText(string text, bool forQuery)
    {
      const string kWordBreakerKey =
          @"HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\ContentIndex\Language\Japanese_Default";
      var guid = new Guid(Registry.GetValue(kWordBreakerKey, @"WBreakerClass", string.Empty) as string);
      var wordBreakerType = Type.GetTypeFromCLSID(guid);

      // A newer wordbreaker shipped with MS Office 2010.
      // wordBreakerType = Type.GetTypeFromProgID("NLG.Japanese Wordbreaker.4.1");

      var wordBreaker = default(IWordBreaker);
      try
      {
        wordBreaker = Activator.CreateInstance(wordBreakerType) as IWordBreaker;

        var license = true;
        wordBreaker.Init(forQuery, 4096, out license);

        var filler = (FillTextBufferDelegate)((ref TEXT_SOURCE _) => HResults.WBREAK_E_END_OF_TEXT);
        var pTextSource = new TEXT_SOURCE()
        {
          pfnFillTextBuffer = filler,
          awcBuffer = text,
          iCur = 0,
          iEnd = checked((uint)text.Length),
        };

        var dictionary = new Dictionary<WORDREP_BREAK_TYPE, string>
        {
          {WORDREP_BREAK_TYPE.WORDREP_BREAK_EOC, "[EOC]"},
          {WORDREP_BREAK_TYPE.WORDREP_BREAK_EOP, "[EOP]"},
          {WORDREP_BREAK_TYPE.WORDREP_BREAK_EOS, "[EOS]"},
          {WORDREP_BREAK_TYPE.WORDREP_BREAK_EOW, "[EOW]"},
        };

        var words = new List<string>();
        var altWords = new List<string>();
        wordBreaker.BreakText(ref pTextSource, new WordSink
        {
          OnWord = (word, _, __) => words.Add(word),
          OnAltWord = (word, _, __) => altWords.Add(word),
          OnBreak = type => { words.Add(dictionary[type]); altWords.Add(dictionary[type]); },
        }, new CPhraseSink());
        GC.KeepAlive(filler);
        Console.WriteLine("Words: " + string.Join("/", words));
        Console.WriteLine("Alt Words: " + string.Join("/", altWords));
      }
      catch
      {
        if (wordBreaker != null)
        {
          Marshal.ReleaseComObject(wordBreaker);
          wordBreaker = null;
        }
      }
    }

    [MTAThread]
    static void Main(string[] args)
    {
      BreakText("プリキュア", false);
      BreakText("ふたりはプリキュア", false);
      BreakText("ハートキャッチプリキュア", false);
      BreakText("マイコンピューター", false);
      BreakText("情シス", false);
    }
  }
}
Words: プリキュア
Alt Words:
Words: ふたり/は/プリキュア
Alt Words:
Words: ハトキアッチプリキュア
Alt Words: ハートキャッチプリキュア
Words: マイコンピュタ
Alt Words: マイコンピューター
Words: 情/シス
Alt Words:

さすがに "プリキュア" で分割してくれたりはしないようですね.というかそもそも,「欧文地名以外の複合語をカタカナ表記するときは分かち書き」という Microsoft のスタイルガイド が遵守されているのが前提なのか,カタカナの連続は何も考えずにくっつけているだけのような挙動にも見えました.あんまりちゃんと実験してませんが.
ちなみに,SharePoint に付属する WordBreaker では,以下のようにユーザ辞書ファイルを使うことが出来るようです.

4. 以下に従い、ファイルを保存します。
場所 "C:\Program Files\Microsoft Office Servers\12.0\Bin"
(日本語ワードブレーカ nlsdata0011.dllが存在する場所)
ファイル名 "Custom0011.lex" (0011 は言語 ID)
文字コード "Unicode"

さらにこの nlsdata0011.dll というファイルですが,手元の Windows 7 Ja 環境では同名のファイルがシステムディレクトリに存在します.試しに %SystemRoot%\System32\Custom0011.lex (と %SystemRoot%\SysWOW64\Custom0011.lex) というファイルを作り,以下の内容を入力し,BOM 付き UTF-16 ファイルで保存してみます.

#CUSTOMER_WB
情シス
プリキュア

改めて最初のコードを実行すると,結果は以下のようになりました.

Words: プリキュア
Alt Words:
Words: ふたり/は/プリキュア
Alt Words:
Words: ハトキアッチプリキュア
Alt Words: ハートキャッチプリキュア
Words: マイコンピュタ
Alt Words: マイコンピューター
Words: 情シス
Alt Words:

少なくとも「情シス」の方は 1 word として認識されるようになりました.また,実行中に Custom0011.lex が読み込まれていることも,Process Monitor のログから確かめられました.
一方,ユーザ辞書に「プリキュア」を追加しても,"ハートキャッチ/プリキュア" と分割されませんでした.これは,以下の SharePoint での事例と同じもののようです.

ワードブレーキング (設定箇所 : サーバー定義ファイル)

こちらは、セミナーの資料では省略していましたが、懇親会でご質問がありましたので記載しておきます (懇親会場でご回答させて頂きました)。

例えば、「ペドロ&カプリシャス」のようなキーワードを検索したい場合、インデックス収集時に、間の記号(アンパサント &)によって、「ペドロ」と「カプリシャス」でキーワードが自動的に区切られます。こうした場合には、カスタムディクショナリー(Custom Dictionary) を設定することで、こうした自動ブレークを阻止し、「ペドロ&カプリシャス」で完全マッチの検索をおこなうことができます。

カスタムディクショナリの設定ファイルを配置する場所は、シソーラスファイルとは異なり、%programfiles%\Microsoft Office Servers\12\bin\CustomLANG.lex です。(日本語の場合は、Custom0011.lex です。) 設定を反映させるには、インデックスの再収集以外に、クエリー時のブレーク箇所も正しく認識させる必要があるため、ファイル編集後は、 Office SharePoint Server Search サービス (osearch) の再起動と、再クロールの双方をおこなってください。

カスタムディクショナリの作成方法については、以下の記事が参考になります。

TechNet : ユーザー辞書を作成する (Office SharePoint Server 2007)
http://technet.microsoft.com/ja-jp/library/cc263242.aspx

実は、懇親会では、「ワードブレークを阻止したい」 というご質問ではなく、逆に 「ワードを分割して認識させられないか」 というものでした。私は、この回答として、「カスタム辞書 (上記の CustomLANG.lex) を編集することで認識させられる可能性があるかもしれない」 とお答えしてしまいましたが、すみません、動作を確認してみたところ、本来分割されていないワードを分割して認識させることは不可能でした。(この予測は誤っておりました。申し訳ありません . . .)

発端の話も,「ワードを分割して認識させられないか」の一種だと思いますが,どうも現世代の Microsoft 製 IWordBreaker 実装ではユーザ辞書を使ってもこの問題を回避できなさそうな感じです. 次なる手段としては,自分で IWordBreaker を実装 して,HKLM\SYSTEM\CurrentControlSet\Control\ContentIndex\Language\Japanese_Default 以下の WBreakerClass を置き換えてしまう,あたりでしょうか.試してはいないので,うまくいくかは分かりませんが.