やってみた 2026年8月11日

「LLM不要」と書かれたSemanticaをコンテナで動かしたら、クイックスタートの問い合わせが全部空を返した話

XECIN Pythonナレッジグラフベクトル検索AIエージェント

GitHubのトレンドで semantica-agi/semantica が2番目に上がっていて、説明文の「Graph-Native Infrastructure for Context and Accountable AI Systems」より、その下に置かれた一行のほうが目に留まりました。「The Open Source Palantir for AI Agents」。

正直なところ、私はこの手の売り文句にはあまり反応しなくなっています。ただREADMEを読み進めると、こう書いてありました。「Semantica sits underneath your LLM, vector store, and agent framework as a deterministic infrastructure layer: no LLM required for graph construction, reasoning, or provenance」。グラフ構築も推論もプロヴェナンスもLLM抜きで決定的に動く、と。

エージェントの判断根拠を後から説明できる形で残しておきたい、という要求は私も何度か受けたことがあります。そのときいつも引っかかるのが「結局その説明もLLMに書かせるなら、説明になっていないのでは」という点でした。だからこの「決定的」という言葉が本当なのかどうかは、動かして確かめる価値があると思ったんですよね。

試した環境

いつもどおり、ホストには何も入れず使い捨てのコンテナの中だけで触っています。

  • ベースイメージ: python:3.12-bookworm(Debian GNU/Linux 12 / x86_64 / Python 3.12.13)
  • 対象: semantica-agi/semantica commit 7bf7474(2026-08-11時点)、PyPI 版 semantica 0.6.0、MIT ライセンス
  • インストール: README のとおり pip install semantica

「pip install 一発」の中身を測っておく

クイックスタートは pip install semantica の一行です。実際に流すと109秒で終わりました。一行で終わるのは確かに気持ちがいい。

ただ、依存の一覧が流れていく途中で手が止まりました。入ってきたのが torchtransformersspacysentence-transformersfaiss-cpu、それから nvidia-cublasnvidia-cudnn といったCUDA一式だったからです。「LLM不要・決定的」を前面に出したライブラリの既定インストールとしては、少し話が違う気がして、サイズを測りました。

$ du -sh /usr/local/lib/python3.12/site-packages
6.2G

$ du -sm /usr/local/lib/python3.12/site-packages/* | sort -rn | head -4
2724	.../nvidia
1127	.../torch
691	.../triton
173	.../llvmlite

$ du -sh /usr/local/lib/python3.12/site-packages/semantica
17M	.../semantica

# インストール直後の健康診断は素直に通った
$ semantica doctor          # 終了コード 0 / 700ms
  Python                ✓         3.12.13
  semantica             ✓         0.6.0
  Graph store           ✓         memory (always available)
  Vector store          ✓         faiss importable
  OpenAI                ⚠         OPENAI_API_KEY not set
  Config file           ⚠         using defaults (no --config)

  0 error(s)  4 warning(s)

semantica 本体は17MB、それを動かすために6.2GB。うち2.7GBがNVIDIA関連です。GPUの無いコンテナでもCUDAランタイム一式が降ってきます。

ここは使い方次第だと思っていて、後述するとおりベクトル検索側を使うなら埋め込みモデルは要るので、全部が無駄というわけではありません。ただ「決定的な推論とプロヴェナンスだけ欲しい」という入り方をする人にとっては、既定の依存が重すぎる。extras で分けてほしいところだな、というのが正直な感想です。

健康診断のほうは、APIキー系が警告になっているのは想定どおりで、キー無しでも 0 error でした。ここまでは順調です。

クイックスタートは通る。ただし返ってくるものが無い

READMEのクイックスタートをそのまま貼って実行しました。決定をひとつ記録して、因果の連鎖・類似の判例・影響範囲・ポリシー適合の4つを問い合わせる、という流れです。

終了コードは0、6.7秒で完走しました。落ちてはいません。ただ、出てきたものがこうでした。

decision_id = 2e603f81-dd92-4078-aee0-4065212b282d
---- chain : list
[]
---- similar : list
[]
---- impact : dict
{'decision_id': '2e60...', 'direct_influence': [], 'total_influenced': 0, 'max_influence_score': 0.0}
---- compliant : dict
{'compliant': False, 'violations': ['Confidence too low: 0', 'Invalid outcome: None',
 'Missing required field: decision_maker']}

chainsimilar が空配列、impact はゼロ、compliant は False。

決定が1件しか無いので chainimpact が空なのは当たり前です。腑に落ちなかったのは残りの2つでした。find_similar_decisions("cloud vendor") は、いま記録したばかりの「Choose cloud provider for HIPAA workload」という決定に当たってほしい問い合わせです。check_decision_rules に至っては False が返っている。

エラーが出ていれば調べようもあるのですが、終了コード0で静かに空が返るのがいちばん厄介です。こういうときは素直にソースを読むのが早い、というのは経験上わかっているので、site-packages に降りました。

空だった理由(1)類似検索は単語の集合演算だった

find_similar_decisionsfind_precedents_by_scenario に委譲していて、その中で呼ばれる内容類似度の実装がこれでした。

def _calculate_decision_content_similarity(self, scenario, decision):
    scenario_words = set(scenario.lower().split())
    decision_text = f"{decision['scenario']} {decision['reasoning']} {' '.join(decision['entities'])}"
    decision_words = set(decision_text.lower().split())
    intersection = scenario_words.intersection(decision_words)
    union = scenario_words.union(decision_words)
    return len(intersection) / len(union) if union else 0.0

# この引数が読まれているかを関数本体(context_graph.py の 2579〜2655行)で数える
$ grep -n "use_semantic_search"
7:        use_semantic_search: bool = True,                          <- シグネチャ
20:            use_semantic_search: Use vector embeddings for search  <- docstring

# 完全一致で引いたときのスコアの上限
perfect lexical match: content_sim=1.0000 structural_sim=0.0000 combined=0.7000
  min_similarity=0.71 -> 0 hit(s)
  min_similarity=0.70 -> 1 hit(s)

小文字化して空白で切って、集合のJaccard係数を返しています。埋め込みもベクトルストアも出てきません。

READMEの比較表で「Vector DB + RAG は Embedding similarity、Semantica は Graph traversal + semantic search」と対比されていた、その semantic search の実体がここでした。しかも find_precedents_by_scenariouse_semantic_search: bool = True という引数を持っていて、docstring には「Use vector embeddings for search」と書いてあります。

その引数を関数本体で数えたのが2つめのコマンドです。シグネチャとdocstringにしか現れません。本体では一度も参照されず、渡しても分岐しない引数でした。agent_context.py からもこの引数に値を渡している箇所があるのですが、受け側で使われていないので効きません。

空だった理由(2)自分自身を完全一致で引いても閾値に届かない

実装がJaccardだとわかったので、実際のスコアを測りました。記録した決定に対して問い合わせ文を変えながら、内容類似度と、既定の閾値(min_similarity=0.3)でのヒット数を並べたのがこれです。

問い合わせ文内容類似度合成スコア既定でのヒット
cloud vendor(READMEの例)0.06250.04370件
cloud provider(保存語に1語寄せた)0.13330.09330件
保存した scenario の文字列そのもの0.40000.28000件
選定したクラウドはどれか(日本語)0.00000.00000件

3行目が今回いちばん驚いたところです。保存されている scenario の文字列を一字一句そのまま問い合わせても、既定の閾値では0件でした。

理由は2つ重なっています。ひとつは、比較対象が scenario だけでなく reasoning と entities を連結したテキストなので、問い合わせ側に無い単語の分だけJaccardが下がること。もうひとつが合成スコアの式で、0.7 * 内容類似度 + 0.3 * 構造類似度 になっています。構造類似度はグラフ上の近傍から出すので、決定が1件しか無い状態では0.0です。

つまり完全一致でも上限が0.7に張り付きます。ひとつ前のコードブロックの後半がその実測で、内容類似度が1.0でも合成は0.7000、閾値を0.71にすると0件、0.70でようやく1件でした。

そして表の3行目の決定を引き当てるには、閾値を0.20以下まで下げる必要がありました。既定の0.3という数字は、単体では妥当に見えます。合成の重みと連結テキストの効き方を合わせて考えると、実質的に届きにくい位置に置かれている、というのが実測した感触です。

空だった理由(3)引数が「照会条件」ではなく「検証対象」だった

check_decision_rules({"category": "vendor_selection"}) が False を返した件も、シグネチャを見たら理解できました。

def check_decision_rules(self, decision_data, rules=None):
    """Args: decision_data: Decision to check"""

# ならば記録済みの決定をそのまま渡せばいい、と思って比べた結果
README の引数 : ['Confidence too low: 0', 'Invalid outcome: None', 'Missing required field: decision_maker']
記録した決定   : ['Invalid outcome: selected_aws', 'Missing required field: decision_maker']

第一引数は「この分類の決定を照会する」条件ではなく、「検証したい決定そのもの」でした。カテゴリだけの辞書を渡したので、confidence が無い・outcome が無い・decision_maker が無い、と正しく指摘されていたわけです。空が返っていたのではなく、渡したものがそのまま採点されていた。

そして記録済みの決定をそのまま渡しても、違反は残りました。既定のポリシーが許す outcome は approved / rejected / flagged の3つで、必須メタデータに decision_maker が含まれています。READMEのクイックスタートが記録している outcome="selected_aws" は、README同士で見ても既定のポリシーを通りません。outcomeapproved にして decision_maker を足したら compliant: True になったので、仕組み自体は素直に動いています。ここは例が噛み合っていないだけ、という話ですね。

ベクトル検索は別の入口にあり、既定値のままだと落ちる

ここまでで「ベクトル検索はどこへ行ったのか」が残ります。パッケージを探すと DecisionEmbeddingPipeline という、埋め込みを使う実装がちゃんとありました。ただ、それを取り込んでいるのは DecisionContextVectorStoreContextRetriever で、READMEのクイックスタートが使う ContextGraph からは参照されていません。

$ grep -n "DecisionEmbedding" context/context_graph.py
(no reference)

# 本来の入口(VectorStore + DecisionContext)を既定値で組み、同じ決定を1件記録した
File ".../vector_store/faiss_store.py", line 73, in add_vectors
    self.index.add(vectors.astype(np.float32))
File ".../faiss/class_wrappers.py", line 304, in replacement_add
    assert d == self.d
AssertionError

# 回避は索引側の次元を合わせるだけだった
vs = VectorStore(dimension=384)      # 決定パイプライン側の既定に合わせる
dc = DecisionContext(vector_store=vs)
did = dc.record_decision(category="vendor_selection", ...)
# -> RECORDED_OK: vec_0

# 決定を1件しか入れていない状態で limit=5 を指定したときの返り値
returned: 5 for 1 stored decision
[0] score=0.545281708240509      content='Scenario: '  metadata='{}'
[1] score=3.4028234663852886e+38 content='Scenario: '  metadata='{}'
[2] score=3.4028234663852886e+38 content='Scenario: '  metadata='{}'

機能が無いのではなく、入口が違う。それなら本来の入口で試そう、と VectorStoreDecisionContext を既定値で組み直したところ、1件目の記録で落ちました。メッセージ本文の無い AssertionError です。faiss まで降りているので、最初は自分の書き方を疑いました。原因は既定値の食い違いでした。

  • FAISSStore.__init__(self, dimension: int = 768)faiss_store.py:225
  • DecisionEmbeddingPipeline.__init__(..., embedding_dimension: int = 384)decision_embedding_pipeline.py:89

そして実際に生成される埋め込みは384次元です(ログにも Generated embedding (dim: 384) と出ます)。768次元で作った索引に384次元のベクトルを入れているので、faiss の次元チェックに引っかかります。どちらも既定値どうしなので、公式の入口をそのまま組むと1件目の記録で必ず止まる、ということになります。

回避は索引側の次元を合わせるだけで、これで通りました。そのうえで、さっきJaccardが0だった問い合わせを投げ直すと、今度はちゃんと当たりました。「which hosting company did we pick」も「選定したクラウドはどれか」も、保存した決定に対してスコアが出ます。売り文句どおりの挙動は、この入口の側にありました。

ひとつ補足しておくと、VectorStore() を作った時点でHugging Face Hubから5ファイル分の埋め込みモデルが降ってきます。初回は12.4秒かかりました。「LLM不要」は本当ですが、「モデルのダウンロードが起きない」とは別の話なので、閉じた環境で使うつもりなら先に確認しておいたほうがいいと思います。

返り値にも癖があります。上のログの末尾がそれで、決定を1件しか入れていない状態で limit=5 を指定すると5件返ってきました。

3.4028234663852886e+38 は float の最大値で、faiss が空きスロットに入れる番兵です。つまり実体は1件で、残り4件は詰め物でした。contentScenario: で本文が乗っておらず、metadata は空です。呼ぶ側でこの値を弾く前提の返り方なので、件数をそのままダッシュボードに出したりすると事故ります。あとスコアはL2距離のようで、小さいほうが近い並びでした。上の表で扱ったグラフ側の類似度は大きいほうが近いので、入口によって向きが逆になります。

試してみての所感

半日ほど触った結論としては、「決定を記録して後から説明できるようにする」という設計思想そのものは、私は悪くないと思っています。決定を一級のオブジェクトとして持ち、ポリシーで検証し、履歴をたどれるようにする。監査を求められる案件で毎回手作りしている部分に、名前と型がついているのは素直にありがたい。

一方で、いま採用するかと聞かれたら、私は様子見にします。理由は機能の不足ではなくて、READMEの入口と実装がまだ揃っていないからです。クイックスタートは終了コード0で通るのに問い合わせが空を返し、埋め込みを使う本来の入口は既定値どうしが噛み合わずに落ちる。この状態だと、動かない理由を毎回ソースで確かめることになります。0.6.0という版数を見れば、そういう時期のものだというのは納得できるところではありますが。

今回いちばん学びになったのは、そこではなく手前の話でした。終了コード0で空配列が返ってくるのは、例外が飛ぶより厄介だということです。今回も doctor0 error で、クイックスタートも正常終了していて、表面上はどこにも異常が出ていませんでした。導入の判断材料として「動きました」と報告してしまえた場面だったと思います。

新しいライブラリを評価するときは、正常終了したかどうかではなく、入れたものが本当に返ってくるかを1件で確かめる。しばらく忘れないでおこうと思います。