SolrでCJKTokenizerを使いながら1文字検索する

そのままでは日本語1文字で検索できないことで有名なSolr(Lucene)/[CJKTokenizer](http://lucene.apache.org/core/3_6_0/api/all/org/apache/lucene/analysis/cjk/CJKTokenizer.html "Javadoc")ですが、力技である程度まで対応できます。対象はSolr3.xですが、Solr1.4でもできたような…

##前提
Solrに限らずハックを仕掛けるときに、再ビルドまでやると結構な手間でつい挫折したくなります。Solrは幸いなことにプラガブルに作られており、設定ファイルでクラス名を指定するものは、継承関係を満たしていればすべて自前で用意したクラスのもので差し替えることができます。

今回も必要な部分だけ置き換えたクラスを作成し、設定ファイルで対応するようにします。

##置き換えクラス
タイトルを覆すようですが、まず思いつくのは[Tokenizer](https://lucene.apache.org/core/3_6_0/api/all/org/apache/lucene/analysis/Tokenizer.html "Javadoc")を自前のものに差し替えてしまうことです。しかし、Tokenizerはパッと見でかなりコメントレスな上、インデックスの作成という点ではコア部分なので、ヘタなTokenizerを作ってバグでもあった日には目も当てられません。

ぶっちゃけ、何よりこの方法を思いついた時にはすでに数日を掛けてCJKTokenizerでインデックスを生成した後でしたので、再インデックスすることなく検索クエリ生成時にTokenizer以外の部分に手を入れて対策することを考えたのでした。

さて、Solrではリクエスト中のクエリ文字列を[Query](http://lucene.apache.org/core/3_6_0/api/all/org/apache/lucene/search/Query.html "Javadoc")オブジェクトへ変換するのに[QParser](http://lucene.apache.org/solr/api-3_6_0/org/apache/solr/search/QParser.html "Javadoc")を使うことになっています。このQParserのファクトリクラスの[QParserPlugin](http://lucene.apache.org/solr/api-3_6_0/org/apache/solr/search/QParserPlugin.html "Javadoc")がsolrconfig.xmlで宣言されています。

```xml:solrconfig.xml
<config>
  <!--指定されていない場合のデフォルトなので、solrconfig.xmlに記載が無いこともあります-->
  <queryParser name="lucene" class="org.apache.solr.search.LuceneQParserPlugin"/>
</config>
```

ところで、Solrのあまり知られてなさそうな機能として、異なるnameを持つqueryParserを同時に宣言しておいて、クエリでどのQParserを使うか使い分けるという機能があります。

クエリでQParserを指定しない場合、デフォルトでは`name="lucene"`のQParserPluginが使用されます。
そして、`name="lucene"`のデフォルトの[LuceneQParserPlugin](http://lucene.apache.org/solr/api-3_6_0/org/apache/solr/search/LuceneQParserPlugin.html "Javadoc")は、QueryParserを拡張したSolrQueryParserを呼び出すLucenQParserを生成します。さすがにややこしいですね。

たぶん使わないであろうqueryParser指定機能のことは忘れて、LuceneQParserPluginのコードをそっくりそのまま頂いて、この部分を差し替えてしまうのがいいようです。

##プラグイン
差し替えるコードは次のとおりです。

```java:ExtLuceneQParserPlugin.java
package solr;

import org.apache.lucene.queryParser.ParseException;
import org.apache.lucene.queryParser.QueryParser;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.PrefixQuery;
import org.apache.solr.common.params.CommonParams;
import org.apache.solr.common.params.SolrParams;
import org.apache.solr.common.util.NamedList;
import org.apache.solr.request.SolrQueryRequest;
import org.apache.solr.search.QParser;
import org.apache.solr.search.QParserPlugin;
import org.apache.solr.search.QueryParsing;
import org.apache.solr.search.SolrQueryParser;

public class ExtLuceneQParserPlugin extends QParserPlugin {
	public static final String NAME = "lucene";

	@Override
	public void init(NamedList args) {
		// nothing to do
	}

	// 参考までに、キャッシュされず、全リクエストごとに単純に呼びだされます
	@Override
	public QParser createParser(String qstr, SolrParams localParams, SolrParams params,
			SolrQueryRequest req) {
		return new LuceneQParser(qstr, localParams, params, req);
	}
}

class LuceneQParser extends QParser {
	private SolrQueryParser lparser = null;

	public LuceneQParser(String qstr, SolrParams localParams, SolrParams params,
			SolrQueryRequest req) {
		super(qstr, localParams, params, req);
	}

	@Override
	public Query parse() throws ParseException {
		String qstr = getString();

		String defaultField = getParam(CommonParams.DF);
		if (defaultField == null) {
			defaultField = getReq().getSchema().getDefaultSearchFieldName();
		}
		lparser = new CustomSolrQueryParser(this, defaultField);
		lparser.setAutoGeneratePhraseQueries(true);

		String opParam = getParam(QueryParsing.OP);
		if (opParam != null) {
			lparser.setDefaultOperator("AND".equals(opParam) ? QueryParser.Operator.AND : QueryParser.Operator.OR);
		} else {
			lparser.setDefaultOperator(QueryParser.Operator.AND);
		}

		return lparser.parse(qstr);
	}

	@Override
	public String[] getDefaultHighlightFields() {
		return new String[] { lparser.getField() };
	}
}

class CustomSolrQueryParser extends SolrQueryParser {

	public CustomSolrQueryParser(QParser parser, String defaultField) {
		super(parser, defaultField);
	}

	@Override
	protected Query newTermQuery(org.apache.lucene.index.Term term) {
		String value = term.text();
		if (value.length() == 1) {
			Character.UnicodeBlock ub = Character.UnicodeBlock.of(value.charAt(0));
			if (ub != Character.UnicodeBlock.BASIC_LATIN
					&& ub != Character.UnicodeBlock.HALFWIDTH_AND_FULLWIDTH_FORMS) {
				return new PrefixQuery(term);
			}
		}
		return super.newTermQuery(term);
	}
}
```
SolrQueryParserの親のQueryParserは`newTermQuery`のような拡張用のメソッドを用意してくれているので、大変ラクですね。

あとは、このコードをコンパイルして、jarに固めて適当なディレクトリ`/path/to/plugin/jars`にでも設置し、solrconfig.xmlを編集します。

```xml:solrconfig.xml
<config>
  <lib dir="/path/to/plugin/jars" />
  <queryParser name="lucene" class="solr.ExtLuceneQParserPlugin"/>
</config>
```

###動作
Solrに渡されたクエリ文字列は構文解析された後、要素ごとにTokenizerが呼ばれ、その結果に応じてQueryを生成しますが、日本語1文字クエリの場合、なんだかんだあって`newTermQuery`が呼ばれます。

ここは普通は`new TermQuery()`するだけなんですが、ここで日本語1文字だけで検索しようとしている場合、[PrefixQuery](http://lucene.apache.org/core/3_6_0/api/all/org/apache/lucene/search/PrefixQuery.html "Javadoc")に置き換えてやることにします。「あ」なら「あ*」で検索されたかのように振る舞うわけです。

##さいごに
Solr4.xでもこのコードは有効ですが、インデックスを使い回さないのなら、新しい[CJKBigramFilter](http://lucene.apache.org/core/4_0_0-BETA/analyzers-common/org/apache/lucene/analysis/cjk/CJKBigramFilter.html "Javadoc")のunigram出力オプションを使ったほうが良いでしょう。

PrefixQueryの検索負荷は高いですし、CJKTokenizerの都合上「ABあ」のような、アルファベット+日本語一文字クエリに対しては少しも解決していないことは注意してください。