はじめに
Pythonで自然言語処理やRAGの実験をしていると、手元のデータに対して軽く検索をかけたいことがあります。
たとえば、
- JSONLファイルを検索したい
- 日本語テキストをキーワード検索したい
- RAG用にローカル検索を試したい
- ElasticsearchやOpenSearchを立てるほどではない
- Dockerやサーバーの準備なしで検索したい
- ベクトル検索も簡単に試したい
というケースです。
そこで、PythonからApache Luceneを手軽に使えるライブラリとして、nlp4j-local-search を作成しました。
PyPIで公開したので、現在は次のようにインストールできます。
pip install nlp4j-local-search
nlp4j-local-search とは
nlp4j-local-search は、Pythonからローカル検索を簡単に利用するためのライブラリです。
内部ではApache Luceneを利用しています。
特徴は次の通りです。
- PythonからLuceneを利用できる
- Elasticsearch / OpenSearch / Solr が不要
- Dockerが不要
- ローカル環境だけで全文検索できる
- 日本語検索に対応
- ベクトル検索にも対応
- 小さな実験やRAGのプロトタイプに使いやすい
検索エンジンサーバーを立てずに、Pythonコードだけで検索を試せるのがポイントです。
インストール
PyPIからインストールできます。
pip install nlp4j-local-search
Javaのライブラリを内部で利用するため、Java実行環境が必要です。
基本的なテキスト検索
まずは日本語テキスト検索の例です。
from nlp4j_local_search import SearchEngine
with SearchEngine("ja") as search:
search.add("1", "東京都は日本の都道府県のひとつです")
search.add("2", "京都は日本の都市です。")
search.add("3", "京都市には任天堂の本社があります")
search.add_json({
"id": "4",
"body": "京都府は広いです"
})
search.commit()
results = search.search("京都", 10)
for i, result in enumerate(results):
print(f"result[{i}].id: {result.id}")
print(f"result[{i}].body: {result.body}")
print(f"result[{i}].score: {result.score}")
SearchEngine("ja") とすることで、日本語用の検索を利用します。
ドキュメントの追加は、次の2種類に対応しています。
search.add("1", "東京都は日本の都道府県のひとつです")
のようにIDと本文を指定する方法と、
search.add_json({
"id": "4",
"body": "京都府は広いです"
})
のようにJSON風の辞書で追加する方法です。
ドキュメントを追加した後は、
search.commit()
を呼び出してから検索します。
results = search.search("京都", 10)
第1引数が検索クエリ、第2引数が取得件数です。
「京都」で検索して「東京都」がヒットしにくい
日本語検索で意外と重要なのが、単純な文字列部分一致ではなく、形態素解析を利用した検索ができることです。
たとえば、単純な部分一致検索では、
京都
で検索したときに、
東京都
もヒットしてしまうことがあります。
しかし、検索用途によっては、これはノイズになります。
nlp4j-local-search ではLuceneの日本語解析を利用することで、より検索エンジンらしい挙動を目指しています。
たとえば次のようなデータを登録した場合、
search.add("1", "東京都は日本の都道府県のひとつです")
search.add("2", "京都は日本の都市です。")
search.add("3", "京都市には任天堂の本社があります")
search.add_json({
"id": "4",
"body": "京都府は広いです"
})
京都 で検索すると、京都に関係する文書を取得できます。
results = search.search("京都", 10)
ローカルで手軽に使える検索ライブラリでありながら、日本語検索でありがちなノイズをある程度抑えられる点が便利です。
ベクトル検索
nlp4j-local-search では、テキスト検索だけでなくベクトル検索も利用できます。
ベクトル検索を利用する場合は、SearchEngine の初期化時に vector_dimension を指定します。
from nlp4j_local_search import SearchEngine
with SearchEngine("ja", vector_dimension=2) as search:
search.add("1_East", [1.0, 0.0])
search.add("2_North", [1.0, 1.0])
search.add("3_West", [-1.0, 0.0])
search.add("4_South", [-1.0, -1.0])
search.commit()
results = search.search([0.9, 0.1], 10)
for i, result in enumerate(results):
print(f"result[{i}].id: {result.id}")
print(f"result[{i}].body: {result.body}")
print(f"result[{i}].score: {result.score}")
print("---")
この例では、2次元ベクトルを使っています。
search.add("1_East", [1.0, 0.0])
search.add("2_North", [1.0, 1.0])
search.add("3_West", [-1.0, 0.0])
search.add("4_South", [-1.0, -1.0])
検索ベクトルは次の通りです。
results = search.search([0.9, 0.1], 10)
[0.9, 0.1] は [1.0, 0.0] に近いため、最も近い結果として 1_East が返ることを期待できます。
assert results[0].id == "1_East"
このように、Embeddingのようなベクトルを登録しておけば、ローカルで近傍検索を試すことができます。
ベクトル次元数のチェック
ベクトル検索では、登録するベクトルと検索するベクトルの次元数が一致している必要があります。
たとえば、2次元ベクトル用に初期化した場合、
with SearchEngine("ja", vector_dimension=2) as search:
次のような3次元ベクトルを追加しようとするとエラーになります。
search.add("test", [1.0, 2.0, 3.0])
検索時も同様です。
search.search([1.0, 2.0, 3.0], 10)
このように、ベクトル次元数の不一致を検出できるようにしています。
Embeddingを扱う場合、モデルによって次元数は異なります。
たとえば、384次元、768次元、1024次元など、モデルごとに固定の次元数があります。
そのため、ベクトル検索を使う場合は、最初に指定した vector_dimension と登録・検索に使うベクトルの次元数を合わせる必要があります。
テキスト検索とベクトル検索の使い分け
SearchEngine("ja") のように vector_dimension を指定しない場合は、通常のテキスト検索用として利用します。
with SearchEngine("ja") as search:
search.add("test", "テストテキスト")
search.commit()
results = search.search("テスト", 10)
一方、ベクトル検索を使う場合は、次のように vector_dimension を指定します。
with SearchEngine("ja", vector_dimension=2) as search:
search.add("1", [1.0, 0.0])
search.commit()
results = search.search([0.9, 0.1], 10)
vector_dimension を指定せずにベクトルを追加しようとすると、エラーになります。
with SearchEngine("ja") as search:
search.add("test", [1.0, 2.0])
これは、テキスト検索用のインデックスなのか、ベクトル検索用のインデックスなのかを明確にするためです。
サンプルコード全体
テキスト検索とベクトル検索をまとめると、次のようになります。
from nlp4j_local_search import SearchEngine
def test_text_search():
print("=== テキスト検索のテスト ===")
with SearchEngine("ja") as search:
search.add("1", "東京都は日本の都道府県のひとつです")
search.add("2", "京都は日本の都市です。")
search.add("3", "京都市には任天堂の本社があります")
search.add_json({
"id": "4",
"body": "京都府は広いです"
})
search.commit()
results = search.search("京都", 10)
for i, result in enumerate(results):
print(f"result[{i}].id: {result.id}")
print(f"result[{i}].body: {result.body}")
print(f"result[{i}].score: {result.score}")
assert len(results) == 3
def test_vector_search():
print("=== ベクトル検索のテスト ===")
with SearchEngine("ja", vector_dimension=2) as search:
search.add("1_East", [1.0, 0.0])
search.add("2_North", [1.0, 1.0])
search.add("3_West", [-1.0, 0.0])
search.add("4_South", [-1.0, -1.0])
search.commit()
results = search.search([0.9, 0.1], 10)
for i, result in enumerate(results):
print(f"result[{i}].id: {result.id}")
print(f"result[{i}].body: {result.body}")
print(f"result[{i}].score: {result.score}")
print("---")
assert len(results) == 4
assert results[0].id == "1_East"
if __name__ == "__main__":
test_text_search()
test_vector_search()
RAGの実験にも使える
nlp4j-local-search は、小規模なRAG実験にも使いやすいと思います。
たとえば、次のような用途です。
- Markdown化した文書を検索する
- PDFやPowerPointから抽出したテキストを検索する
- ローカルのJSONLを検索する
- 日本語キーワード検索を試す
- Embeddingを作成してベクトル検索を試す
- Elasticsearch導入前のプロトタイプを作る
本格的な検索サービスを構築する場合はElasticsearchやOpenSearchが便利ですが、実験段階では準備が重いこともあります。
そのようなとき、Pythonコードだけで検索を試せるローカル検索ライブラリがあると便利です。
ElasticsearchやOpenSearchとの違い
nlp4j-local-search は、ElasticsearchやOpenSearchの代替を完全に目指すものではありません。
大規模な検索システム、分散検索、クラスタ運用、可視化、権限管理、本番監視などが必要な場合は、ElasticsearchやOpenSearchのほうが向いています。
一方で、次のような用途では nlp4j-local-search が便利です。
- 手元のデータをすぐ検索したい
- サーバーを立てたくない
- Dockerなしで試したい
- Pythonだけで完結する実験にしたい
- Luceneの検索機能を軽く使いたい
- 日本語検索とベクトル検索をローカルで試したい
つまり、本番検索基盤というより、ローカル検索・NLP実験・RAGプロトタイプ向けのライブラリです。
まとめ
nlp4j-local-search を使うと、PythonからApache Luceneベースのローカル検索を手軽に利用できます。
インストールはPyPIから行えます。
pip install nlp4j-local-search
テキスト検索は次のように書けます。
with SearchEngine("ja") as search:
search.add("1", "京都は日本の都市です。")
search.commit()
results = search.search("京都", 10)
ベクトル検索は次のように書けます。
with SearchEngine("ja", vector_dimension=2) as search:
search.add("1_East", [1.0, 0.0])
search.commit()
results = search.search([0.9, 0.1], 10)
Elasticsearch、OpenSearch、Solr、Dockerを使わずに、Pythonだけでローカル検索を試せるのが特徴です。
日本語のキーワード検索とベクトル検索を組み合わせて、NLPやRAGの実験に活用していきたいと考えています。
以上.