やってみた 2026年9月28日

エージェント用メモリhindsightをAPIキー無しで試したら、日本語の検索順位は埋め込みではなくリランカーで変わった

XECIN PythonAIエージェントPostgreSQL全文検索

GitHubのトレンドを眺めていたら、vectorize-io/hindsight がDailyの3位に入っていました。説明は「Hindsight: Agent Memory That Learns」。取得した時点でスターは39,513、その日だけで4,520増えていました。

自分は普段フロント寄りの仕事をしているんですが、最近「チャット画面の裏のエージェントに、前回の話を覚えておいてほしい」という相談がちょくちょく来るんですよね。記憶の層を自前で書くか何かに任せるかで迷っていたところだったので、pip install 一発でサーバー無しの組み込みモードまであると聞いて、触っておきたくなりました。

ただ、READMEの例は llm_provider="openai" とAPIキーが前提です。課金せずにどこまで見られるかソースを眺めてみたら、HINDSIGHT_API_LLM_PROVIDER=none というLLMを一切呼ばないモードがありました。providers/none_llm.py のdocstringには「Retain uses chunks mode (no fact extraction), and reflect/consolidation are disabled.」とあります。書いた文を事実に分解はしないけど、そのまま塊として保存して検索はできる、という位置づけです。今回はこのモードで試しています。

試した環境は、使い捨てのDockerコンテナ(python:3.12-bookworm、Python 3.12.13 / Debian 12.14、--memory=4g --cpus=2)です。入れたのはPyPIの hindsight-all 0.10.1で、GitHubの最新リリースもv0.10.1(2026-09-21)でした。torchはCPU版を指定しています。

READMEの例そのままだと、30秒待って理由も分からず終わった

# コンテナ内(root のまま)
$ uv pip install --system hindsight-all==0.10.1 \
    --extra-index-url https://download.pytorch.org/whl/cpu --index-strategy unsafe-best-match
hindsight-all install ms=28626
# site-packages 合計 1,838,519,148 バイト / torch 2.14.0+cpu / transformers 5.15.1

# README の Python Embedded の with 文をそのまま(プロバイダだけ none)。以下、出力は抜粋
$ python t1.py
[t1] import 17.16s
RuntimeError: Server failed to start within 30.0 seconds
rc=1 total_ms=48763

# start(timeout=300) に延ばしてログを見る
$ python t2.py 300
RuntimeError: Failed to start embedded PostgreSQL after 5 attempts. Last error: Error: PostgreSQL error: Command error: stdout=; stderr=initdb: error: cannot be run as root
initdb: hint: Please log in (using, e.g., "su") as the (unprivileged) user that will own the server process.
ERROR:    Application startup failed. Exiting.
RuntimeError: Server failed to start within 300.0 seconds
rc=1 total_ms=308135

インストールは28.6秒で、拍子抜けするくらい速かったです。重いのは容量の方で、site-packagesが1.8GB近くになりました。

詰まったのはその次でした。READMEの with HindsightServer(...) as server: をそのまま書いて走らせたら、Server failed to start within 30.0 seconds の一文だけで終わります。初回はモデルのダウンロードもあるので「単に遅いのかな」と思ってtimeoutを300秒に延ばしてみたら、ログの途中に initdb: error: cannot be run as root が出ていました。組み込みのPostgreSQL(pg0)がrootでの初期化を拒否していて、5回リトライしたあと Application startup failed. Exiting. でサーバー側はもう諦めているんですよね。

それなのに呼び出し側は、300秒きっちり待ってから同じ「起動しなかった」を返してきました。hindsight/server.py の start() は、指定した秒数のあいだポートに繋がるかを試し続ける作りで、サーバーが起動を諦めたことはここまで伝わってきません。30秒のままだと、rootが原因だと分かる行が出る前に打ち切られていました。

素のDockerイメージはrootで動くのが普通なので、今思えばハマるべくしてハマった感じです。公式のDockerイメージを使う道もあるんですが、今回はpipの道で行きたかったので、一般ユーザーを作って実行し直しました。

一般ユーザーにしたら7秒で起動した

# useradd -m app のあと、docker exec -u app で実行した t2.py(計時の print を省略)
from hindsight import HindsightServer, HindsightClient

server = HindsightServer(llm_provider="none", llm_api_key="", llm_model="none")
try:
    server.start(timeout=120)
    client = HindsightClient(base_url=server.url)
    client.retain(bank_id="my-bank", content="Alice works at Google")
    client.recall(bank_id="my-bank", query="Where does Alice work?")
finally:
    server.stop()

# 出力
[t2] server up 7.02s url=http://127.0.0.1:51223
[t2] retain 0.13s -> success=True bank_id='my-bank' items_count=1 var_async=False operation_id=None operation_ids=None usage=TokenUsage(input_tokens=0, output_tokens=0, total_tokens=0, cached_tokens=0, thoughts_tokens=0)
[t2] recall 0.05s -> results=[RecallResult(id='ce4e903d...', type='world', text='Alice works at Google')] trace=None entities=None chunks=None source_facts=None source_facts_truncated=None

一般ユーザーにしたら、サーバーの起動が7.02秒、retain が0.13秒、recall が0.05秒で、READMEと同じ Alice works at Google が返ってきました。noneモードなのでトークン数は全部0です。

起動ログには、worker idがコンテナのホスト名になっていて、コンテナを作り直すとprocessingのまま残ったタスクが回収されない、という警告も出ていました。常用するなら HINDSIGHT_API_WORKER_ID は固定しておきたいところですね。

メモリは、pythonのプロセスが991,860KiB、postgres側が合計237,396KiBでした。埋め込みとリランカーのモデルを抱えているぶん、python側だけでほぼ1GBあります。

ついでに、noneモードで reflect を呼ぶと、HTTP 400で {"detail":"Reflect requires an LLM provider. Current provider is set to 'none'. Set HINDSIGHT_API_LLM_PROVIDER to a real provider (e.g., openai, anthropic, gemini)."} が返ってきました。できない操作を黙って空で返すのではなく、何が足りないかをそのまま言ってくれるのは助かります。そのぶん、さっきの起動失敗が呼び出し側にタイムアウトとしてしか届かないのは惜しいなと感じました。

日本語で書いた記憶が、半分しか1位に来ない

本題はここからです。自分が使うなら記憶は日本語で入ってくるので、同じ内容の5件を日本語と英語で別々のバンクに入れて、6問ずつ聞いてみました。最後の1問だけは、本文にそのまま入っている単語(ミケ / Mike)で引いています。

質問(日本語バンク)正解の順位質問(英語バンク)正解の順位
田中さんの勤務先は?1位Where does Tanaka work?1位
歯医者はいつ?1位When is the dentist?1位
猫の名前は?1位What is the cat’s name?1位
好物は何?2位(1位は歯医者の予約)What is my favorite food?1位
どこに住んでいる?5位(1位は田中さんの勤務先)Where do I live?1位
ミケ2位(1位はカレーライス)Mike1位

英語は6問とも正解が1位、日本語は3問だけでした。いちばん引っかかったのは「ミケ」です。「実家の猫の名前はミケだ。」という文にそのまま入っている単語なのに、1位は「好きな食べ物はカレーライスだ。」でした。

READMEには、recallは4つの検索を並列で走らせて、そのうちの1つが「Keyword: BM25 exact matching」だと書いてあります。なので単語がそのまま入っていれば少なくともキーワード側では当たるはず、と思っていたんですが、実際やってみると日本語ではそこが効いていないように見えました。同梱のPostgreSQLに直接聞いてみた結果がこれです。

# t4.py(server._memory.db_url に psycopg2 で接続して実行)
[t4] tsvector: 実家の猫の名前はミケだ。 => '実家の猫の名前はミケだ':1
[t4] tsvector: The cat at my parents' house is named Mike. => 'cat':2 'hous':6 'mike':9 'name':8 'parent':5
[t4] match(ミケ): False
[t4] extensions: ['pg_trgm', 'plpgsql', 'vector']
[t4] cjk-capable ext available: 0

設定の既定値を見ると、全文検索は native(PostgreSQL標準)で、言語は english になっていました。英語の文は単語に分かれて語幹までそろえられるのに、日本語の文は句点の手前までが丸ごと1語です。これでは「ミケ」と一致しようがありません。設定上は pgroonga なども選べるようになっていますが、組み込みのpg0にはその手の拡張が入っていなかった(候補5種を探して0件)ので、今回は試していません。

埋め込みを替えても、順位が1つも動かなかった

次に、既定の埋め込みモデルが BAAI/bge-small-en-v1.5 なので、多言語の intfloat/multilingual-e5-small に替えてみました。環境変数 HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL を渡すだけです。

これで直るだろうと思っていたら、結果は3/6のまま。6問それぞれの順位も1位の文も、既定のときと完全に同じでした。モデルのダウンロードは実際に走っていたので、設定が無視されたわけではなさそうです。

正直なところ、ここで一回手が止まりました。思い当たったのは、検索の最後に掛かる並べ替え(リランカー)です。READMEにも、結果は「reciprocal rank fusion and a cross-encoder reranking model」で並べると書いてあります。既定のリランカーは cross-encoder/ms-marco-MiniLM-L-6-v2 です。記憶が5件しかないので、どの質問でも5件前後がそのまま返ってきていて、最後の並びはこのリランカー次第になっているのでは、と考えました。そこで、埋め込みとリランカーを1つずつ替えて、1設定1プロセスで流し直しています。

埋め込みリランカー日本語で正解が1位メモ
bge-small-en-v1.5(既定)ms-marco-MiniLM-L-6-v2(既定)3/6上の表のとおり
multilingual-e5-smallms-marco-MiniLM-L-6-v2(既定)3/66問とも既定と同じ順位・同じ1位
bge-small-en-v1.5(既定)rrf(並べ替えをしない)3/6外れ方が変わる。「歯医者はいつ?」が5位に落ち、「ミケ」は1位に
multilingual-e5-smallrrf(並べ替えをしない)6/6全問1位
multilingual-e5-smallhotchpotch/japanese-reranker-cross-encoder-xsmall-v16/6全問1位

埋め込みだけ替えても何も起きず、リランカーを外すと並びが変わり、両方を日本語に通るものにして初めて6/6になりました。少なくともこの件数では、最後の並びを決めているのはリランカーだと読めます。ただ、コードの中で候補がどう絞られているかまでは追っていないので、ここは結果からの推測です。

# 日本語で 6/6 になった2つの組み合わせ(一般ユーザーで、1設定1プロセスで実行)
export HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL=intfloat/multilingual-e5-small
export HINDSIGHT_API_RERANKER_PROVIDER=rrf      # ソース上の説明は "passthrough, does no work"
#   -> [t5] jp_e5_rrf: top1 correct 6/6   rc=0 total_ms=20105

export HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL=intfloat/multilingual-e5-small
export HINDSIGHT_API_RERANKER_PROVIDER=local
export HINDSIGHT_API_RERANKER_LOCAL_MODEL=hotchpotch/japanese-reranker-cross-encoder-xsmall-v1
#   -> [t5] jp_e5_jarr: top1 correct 6/6  rc=0 total_ms=32252(モデルのダウンロード込み)

ここは改善の余地がありそうというか、自分の使い方次第なところです。試したのは5件・6問だけなので、記憶が数百件になったときにrrfで足りるのか、日本語用のリランカーを挟むべきなのかは分かりません。それに、全文検索が日本語の文を1語として扱う点は、この2つの設定でもそのまま残っています。キーワードで引ける前提の使い方をするなら、別途手当てが要りそうです。

ちなみにREADMEには「Multilingual by default」という項目もあって、事実を元の言語のまま保つと書かれています。ただ、読む限りこれはLLMで事実を抽出するretainの話なので、LLMを呼ばない今回の試し方とは前提が違います。LLMを繋いだときに検索側の既定がどう効くかは、今回は確かめていません。

試してみての所感

LLMを呼ばないモードでも、書いて・引く部分は速くて素直でした。起動7秒、retainとrecallは数十〜百数十ミリ秒で、APIキーも要りません。記憶の置き場として試しに組み込むぶんには、かなり気軽です。

気をつけたいのは2つです。pipの道で動かすならrootを避けること。READMEの例のままだと、30秒待ったうえで原因の見えないタイムアウトになります。それから、日本語で使うなら埋め込みだけでなくリランカーまで見直すこと。既定のままだと、自分の試した範囲では半分しか1位に来ませんでした。

hindsightの本来の売りである、LLMで事実を抜き出すretainやreflectは今回試せていないので、そこは様子見です。次はローカルのLLMを繋いで、日本語の会話ログがどういう事実に分解されるかを見てみるつもりです。日本語の記憶をうまく引かせる設定について、もっと良い方法があったら教えてください。