はじめに

普段 AI エージェントに作業を任せるとき、コンテキストを AGENTS.md などの .md ファイルに書いておく場合が多いかと思います。ビルドやテストの流し方、コーディングの方針、命名規則、踏みやすい落とし穴。このあたりは書けば効きます。

一方で、書かない方がいいとされるものもあります。モデルが既に知っていること、機密情報、それと実際には守っていない規約や、古くなった記述。古い記述はかえって邪魔になるので、更新し続けられないなら置かない方がいい、という考え方です。

業務の取り決めは、この後ろ側に近いと思っています。「この指標は A の定義が正で、B は去年やめた」「兼務者の費用は主務側に寄せる」。書いた時点では正しくても、決まりの方が変わりますし、放っておくと増えます。それに、書いてあっても守られるとは限りません(4つのモデルで330回試した検証が公開されていて、情報は拾われるが遵守は保証されない、と報告されています)。

だとすると、置き場所を変えた方がよさそうです。手順や規則は AGENTS.md のままでよくて、守らせたい取り決めだけ別の形で持つ。そこが出発点でした。

別の形として選んだのがRDF形式の知識グラフでした。何が存在して、どう関係するかを先に決めておいて(この決めごとがオントロジーと呼ばれるものです。標準としては W3C 勧告の OWL 2 があり、クラス・プロパティ・個体を定義するもの、と説明されています)、その定義に沿って事実を1件ずつ書いていく。

ただ、既存のものはどれも、私の用途に対して大きく感じました。RDF のグラフデータベースを入れると、SPARQL エンドポイントを立てて運用する話になります。欲しかったのは、数百件の取り決めが入って、リポジトリに置いておけるくらいのものでした。

そこで、作ってみました。Python のライブラリで、YAML ファイル1つをそのまま知識グラフとして扱う形にして、引くのは SPARQL 1.1、エージェントには MCP サーバとして繋ぐようにしました。。

trikedb-ui

書き出した HTML を開くとこうなります。入っているのは Freebase の一般知識ですが、社内の取り決めを入れても出てくる形は同じです。

まずは動かしてみます

pip install trikedb

使ってよい述語を先に決めます。ここがオントロジーにあたる部分です。あとは、その語彙に当てはめて事実を溜めていきます。

from trikedb import TrikeDB

# 使ってよい述語を先に宣言する
db = TrikeDB("pipeline.yaml", ontology={
    "PROVIDES":    "SaaSベンダー -> 取り込みジョブ",
    "INGESTS_TO":  "取り込みジョブ -> ウェアハウスのテーブル",
    "MIGRATED_TO": "廃止したテーブル -> その後継",
})

db.add("salesflow-crm", "PROVIDES", "crm-sync-job")
db.add("crm-sync-job", "INGESTS_TO", "RAW_CRM_CONTACTS", schedule="hourly")
db.add("LEGACY_CONTACTS_DUMP", "MIGRATED_TO", "RAW_CRM_CONTACTS",
       deprecated=True, since="2026-04", prov="移行メモ #1284")

# ノードには好きなだけプロパティを付けられる
db.set_node("crm-sync-job", type="job", schedule="hourly")
db.set_node("RAW_CRM_CONTACTS", type="table", schema="ACME_RAW", pii=True)

add() した時点でファイルに反映されます。できあがった pipeline.yaml はこれで全部です。YAML のまま読み書きできるので、エディタで開いて直せますし、変更は git の差分に出ます。

ontology:
  predicates: {PROVIDES: SaaSベンダー -> 取り込みジョブ, INGESTS_TO: 取り込みジョブ -> ウェアハウスのテーブル, MIGRATED_TO: 廃止したテーブル -> その後継}
nodes:
  crm-sync-job: {type: job, schedule: hourly}
  RAW_CRM_CONTACTS: {type: table, schema: ACME_RAW, pii: true}
triples:
- {s: salesflow-crm, p: PROVIDES, o: crm-sync-job}
- {s: crm-sync-job, p: INGESTS_TO, o: RAW_CRM_CONTACTS, schedule: hourly}
- {s: LEGACY_CONTACTS_DUMP, p: MIGRATED_TO, o: RAW_CRM_CONTACTS, deprecated: true, since: 2026-04, prov: '移行メモ #1284'}

あとは、これを引きます。

db.query(["?vendor PROVIDES ?job", "?job INGESTS_TO ?table"])
# → [{'vendor': 'salesflow-crm', 'job': 'crm-sync-job', 'table': 'RAW_CRM_CONTACTS'}]

# SPARQL 1.1 でも引けます(FILTER も OPTIONAL も集約も通ります)
db.sparql('SELECT ?t WHERE { ?t t:type "table" ; t:pii true }')
# → [{'t': 'RAW_CRM_CONTACTS'}]

検索の入口は性質の違うものを複数用意しています(意味で拾う、パターンで取る、SPARQL で絞る。中身は後の節に書きます)。そのうちよく使うのが find で、意味で候補を広げてからノードのプロパティで絞ります。名前を正確に知らなくても辿り着けます。

db.find("CRMを同期しているのは何?", where={"type": "job"})
# → [{'node': 'crm-sync-job',
#     'props': {'type': 'job', 'schedule': 'hourly'},
#     'facts': [['INGESTS_TO', 'RAW_CRM_CONTACTS']]}]

返ってくるのは、ノード名・そのプロパティ・そこから出ている事実の3点セットです。

入れているのは、データ周りの情報だけではありません。手続きの入口も同じグラフに置いています。たとえば脆弱性診断のような社内手続きについて、どのフォームから申請するのか、窓口はどこか、どれくらいかかるのかを、ノードのプロパティに持たせています。

nodes:
  脆弱性診断: {type: 手続き, form: "https://intra.example/forms/vuln",
              owner: セキュリティ室, sla: 5営業日}

triples:
  - {s: 外部公開API, p: 必要, o: 脆弱性診断}

こうしておくと、「このAPIを外に出すには何が要るか」と聞かれたエージェントが、必要な手続きと申請フォームの URL まで一度に答えます。人に聞かないと分からなかった導線を、辿れる形にしておきたかったからです。

数万件をまとめて入れるときは with db.batch(): で囲みます。1件ごとにファイル全体を書き直すので、囲むかどうかで時間が全然違います。測ってみると、素のままは 800件で3.1秒、1,600件で12.6秒、3,200件で50.5秒。件数に対して二次なので、28,000件なら1時間前後になる計算です。同じ28,000件を batch() で囲むと0.3秒でした。

何が「軽量」なのか

  • サーバがありません。デーモンもデプロイもなく、ライブラリとして import します
  • 保存先は YAML ファイル1つ。グラフ1つ = ファイル1つで、git に入ります
  • 依存は3つ。PyYAML、rdflib、pyoxigraph
  • クエリ言語は SPARQL 1.1 そのもの。自作サブセットではなく、Rust 実装の Oxigraph が実行します

軽くしたのは実装の側だけで、インターフェースは大きなシステムのものに合わせました。あとで本格的なトリプルストアに移りたくなったときに、書き直しではなくエクスポートで済むようにしておきたかったからです。

取り決めどうしが繋がる

取り決めは1件ずつ「主語・述語・目的語」の3つ組で書きます。使ってよい述語は先に宣言しておきます。

ontology:
  predicates:
    APPLIES_TO: "上限ルール -> 適用先の組織"
    BELONGS_TO: "人 -> 所属組織"
    USED_BY:    "アカウント -> 利用者"

triples:
  - {s: 上限ルール2026Q1, p: APPLIES_TO, o: 事業部B, 上限: 月3万円, prov: 運用ルール v4}
  - {s: 担当A,            p: BELONGS_TO, o: 事業部B}
  - {s: アカウントA,      p: USED_BY,    o: 担当A}

3件は別々の人が別のタイミングで書いたものでも、同じ名前のところで繋がります。「このアカウントにはどの上限ルールが効くのか」が、辿って出せる問いになります。

db.query(["?account USED_BY ?person",
          "?person BELONGS_TO ?dept",
          "?rule APPLIES_TO ?dept"])
# → [{'account': 'アカウントA', 'person': '担当A',
#     'dept': '事業部B', 'rule': '上限ルール2026Q1'}]

追加は1件ずつでよく、結合は後からできるので、全体設計を先に決めなくても始められます。繋がる条件は「言葉が揃っていること」で、片方が BELONGS_TO、もう片方が MEMBER_OF だと繋がりません。語彙の定義(オントロジー)が先にあって、事実(知識グラフ)が後に来る、という順番になるのはそのためです。

RDF とプロパティグラフ

グラフのデータベースは、データモデルで見ると RDF とプロパティグラフ(LPG)の2つだけではありません。キーバリューやドキュメントを土台にしたものもありますし、複数のモデルを1つの製品で扱うものもあります。ただ、この記事で並べるのはこの2つです。断っておくと、対等な2択ではありません。オントロジーとして公開され使い回されているもの(BFO、PROV-O、SKOS、Schema.org)は、ほぼ RDF/OWL です。プロパティグラフ側にもスキーマ定義はありますが(Cypher や GSQL の DDL、PG-Schema)、標準として合意されたものはまだありません。

  • RDF グラフ — 主語・述語・目的語(S・P・O)の3つ組が基本単位
  • プロパティグラフ(LPG) — ノード・関係・ノード。点にも線にも直接プロパティを持てる

意味を揃えたいなら RDF、何段も辿りたいなら LPG、という使い分けになると理解しています。用語の統一や他組織との相互運用は RDF が得意で、最短経路や中心性のようなアルゴリズムは LPG の土俵です。

ファイルに持っている形は、トリプル+点のプロパティ+線の属性です。点にも線にもプロパティが付くので、形としては LPG に近いものです。そこから RDF にもプロパティグラフにも投影します。

db.to_rdflib()      # RDF / SPARQL のビュー
db.to_networkx()    # プロパティグラフのビュー

RDF に出すときは、ノードも述語も URI になります。URL と違って場所を指すとは限らないので、担当A<urn:trikedb:担当A> という「開けないが一意に指せる」名前として扱われ、他のグラフや外部の語彙と突き合わせられます。日本語がそのまま入るのは、URI を拡張して非 ASCII を許した IRI の仕様によるものです。おかげで SELECT ?s WHERE { ?s t:担当 t:担当A } と書けます。線の属性は標準の具体化(reification)に載せたので、属性も SPARQL で引けるようにしました(属性1つ付いたトリプルは、RDF では6トリプルに展開されます)。

どちらか一方を選ばせたくなかった、というのが理由です。

規則(OWL)と検査(SHACL)

RDF・SPARQL・OWL・SHACL はどれも W3C 勧告で、規則と検査の仕組みまで仕様に入っています。役割が違うので並べておきます。

OWL は規則で、書いていないことを埋めます。

① 規則を1本書く       「SaaS契約には、必ず請求先がある」
② 事実はこれだけ       Slack契約・Figma契約・Zoom契約 は SaaS契約の一種
③ 機械が自動で導く     3つとも「請求先がある」が効く(1件ずつ書かない)

SHACL は検査で、書き忘れを見つけます。

① 決まりを1本書く     「すべてのSaaS契約に、契約単位が入っている」
② 実データを全部見る   Slack ○ Figma ○ Zoom ○ Notion …
③ 抜けを名指しで出す   「Notion契約に契約単位が入っていない」

どちらも、ルールを1本書けば全体に効きます。1件ずつ書かずに済むのと、書き忘れを機械が見つけてくれるのが利点です。どちらもオプションの追加パッケージとして載せてあります。

設計で決めたこと

書き込みは全部オントロジーの関門を通ってから1つの文書に落ち、読み取りはその文書から投影を作って返します。投影は保存しません。

trikedb-architecture

1. 保存の単位は「文書1つ」

ストレージ層は常に文書を丸ごと1つ動かします。行でもデルタでもページでもありません。そのためインターフェースは4つだけです(read_text / write_text / exists / version)。

これを守ると、置き場所の差し替えが4関数の実装で済みます。ローカルファイル、S3、Snowflake の1行。どれも「文書を1つ出し入れする」以上のことを要求されません。

代償もはっきりしています。部分更新ができません。1件足すたびに全体を書き直すので、実用上の天井が数GBではなく数MBになります。

2. 翻訳ではなく、投影

保存するのは1つの文書だけにして、RDF にもプロパティグラフにも投影する形にしました(前の節に書いたものです)。どちらかに変換して持ち直すのではなく、同じ事実の見え方を変えているだけです。

具体化(reification)のような標準の書き方に乗せたのは、あとで他のツールに持ち出せるようにしたかったからです。ここを独自形式にすると、持ち出した先で読めなくなります。

3. ガードは、書き込みの境界に置きました

ontology.predicates は飾りではなく、書き込みの関門にしてあります。宣言していない述語は書けません。

db.add("crm-sync-job", "OWNS", "x")
# → OntologyError: predicate 'OWNS' is not in the ontology
#                  (allowed: ['INGESTS_TO', 'MIGRATED_TO', 'PROVIDES'])

文章で「この言葉を使って」と指示する場合と違うのは、守られなかったときに書き込みが失敗することです。ただし守れるのはここまでで、書かれた事実が本当かどうかは別です。ファイルを直接いじればこの関門も通りません。

抽出はエージェントに任せて、検証は書き込み経路に置く。語彙だけ固定して、人間は差分をレビューする。検査の側(SHACL)も同じ考えで、db.validate(shapes) は全件を見て抜けを名指しで返します。CI に置けます。

4. 推論は、実体化してレビューできる形で

OWL の推論も載せています(declare で性質を宣言し、infer で導きます)。ただし導出された事実はファイルに書き込まれます。

db.declare("INGESTS_TO", "domain:job")   # INGESTS_TO の主語はジョブ
db.infer(apply=True)                     # 導いた事実を inferred: true 付きで追記

推論結果をメモリ上だけに置かず、inferred: true のタグを付けて YAML に落とします。実体化はスナップショットなので、含意を増やす事実を後から足したら再実行が必要です。自動的な鮮度よりも、導出された事実が差分に出てくる方を選びました。ルールが勝手に効いて、いつの間にか結論が変わっている状態を避けたかったからです。

5. エンジンは既存の実装に任せました

SPARQL は Oxigraph、推論は OWL-RL、検査は pySHACL、埋め込みは model2vec が実行します。私が書いたのは「YAMLのこの形は、RDFのこの形に対応する」という対応規則の部分で、クエリの評価も推論も既存の実装に任せています。

エージェントから使う(MCP)

既定は埋め込む形にしました。エージェントから見ると MCP over stdio で、サーバを立てずにそのまま繋がります。設定はこれだけです。

{
  "mcpServers": {
    "kg": {
      "command": "uvx",
      "args": ["--from", "trikedb[mcp]", "trikedb", "mcp", "/absolute/path/to/graph.yaml"]
    }
  }
}

エージェントに渡るツールは11個です。読み取りが sparql / match / search / find / get_node / ontology / stats、書き込みが add_triple / set_node / remove_triples / import_source

手元ではなく、チームで共有しているグラフに繋ぎたいときのために、HTTP でも出せるようにしました。

trike serve graph.yaml                          # UI + REST + MCP
trike serve graph.yaml --oauth-issuer https://idp.example.com/ \
                       --public-url https://kg.example.com

--oauth-issuer を渡すと OAuth 2.1 で保護されます。トークンは自前で発行せず、社内の IdP が出したものを JWKS で検証する形です。claude.ai や ChatGPT の UI からコネクタとして繋ぐときは、この形が必要になります(どちらからも実際に接続を確認しました)。手元で試すだけなら --token で固定のトークンを1つ置く方が早いです。

入口を1つに絞らなかったのは、エージェンティックサーチが成立するからです。取得の道筋をこちら側で設計するやり方(GraphRAG もここに含まれます)もありますが、いまはエージェント自身がクエリを組み立て、結果を評価し、足りなければ当て方を変えて引き直します。だとすると経路を1本に決めるより、当て方の違う道具を並べて選ばせた方が届きやすいと考えました。1回目で外したときに、次の手が残っているかどうかの差です。

どちらか一方という話ではありません。AWS の Context Ontology Accelerator は、確実な順に3段(定義済みの指標 → 構造化クエリ → 意味検索)を用意して、当たらなければ次に落とす形です。取り決めが数百件なら、速い順を先に決めておく価値より、外したときに別の当て方が残っている価値の方が大きいと考えました。

当て方が違うので、中身も違います。

search — ベクトル検索。 トリプルとノードを1件1文にしてベクトル化し、クエリとのコサイン類似度で並べます。埋め込みは model2vec の静的埋め込み(potion-multilingual-128M、256次元)で、torch も GPU も要らず CPU だけで動きます。静的埋め込みなので文脈は見ませんが、そのぶん軽く、綴りの揺れや言い換え、日本語と英語をまたぐ一致に強いのが利点です。ベクトルは文単位でキャッシュしてあるので、1件足しても再計算はその1文だけです。

match — パターンマッチ。 (s, p, o) の一部を指定して、残りをワイルドカードで取ります。* / ? を含めると glob 照合になり、属性は完全一致で絞ります。インデックスを引くだけなので、名前が分かっているときはこれが最短です。

sparql — SPARQL 1.1。 SELECT と ASK は Rust 実装の Oxigraph が実行します(書き込みと、グラフを返す CONSTRUCT / DESCRIBE は rdflib 側)。FILTER、OPTIONAL、集約、プロパティパスまで通るので、「複数条件で絞る」「辿った先を数える」はここです。

find — 上の2つを繋いだもの。 search で候補を広く集めてから、ノードのプロパティで完全一致フィルタをかけます(where に dict か関数を渡します。冒頭で使ったのがこれです)。ベクトル検索のスコアは校正されていない(「該当なし」と言ってくれない)ので、拾うのは埋め込みに任せて、正しいかどうかは構造側で見る、という分担です。

ontology — 語彙の確認。 宣言済みの述語と説明を返すだけのツールです。書き込む前にこれを引けば、弾かれる述語を使わずに済みます。

5つ並べましたが、日常で一番手が伸びるのは find です。

セマンティックレイヤーとの関係

このグラフに、指標や集計の定義そのものは入れていません。売上や利用実績のような集計はセマンティックレイヤーの担当です。

取り決めを置くコンテキストレイヤーと、数字の定義を出すセマンティックレイヤー。この2つをエージェントが動的に参照しながら、分析やデータエンジニアリングを進める。そこが目指している形です。 グラフで対象と制約を決め、セマンティックレイヤーから数字を取り、返ってきたものを見てまた引き直す。どちらをどの順で引くかは固定しません。

ちなみに Palantir の Ontology は、オブジェクト・プロパティ・リンクという意味の側(semantic elements)と、アクションや関数という動きの側(kinetic elements)を合わせたものだと説明されています。彼ら自身、ドキュメントで「オントロジーはセマンティックレイヤーではない」と書いていて、データ・ロジック・アクション・セキュリティの4つをまとめて扱うのだから薄いセマンティックレイヤーでは足りない、という言い方をしています。書き戻しまで含めて1つの層で持つ設計です。

こちらが受け持つのは、読ませることと、語彙を守らせる書き込み口までです。実行はその外側に置く、という切り分けにしました。

レビューと共有のための UI

見るだけなら1コマンドです。

trike ui                # そのディレクトリのグラフを開く(ファイル指定は省略できます)
trike ui graph.yaml

このコマンドは、HTML を生成してブラウザで開くところまでやります。中身は検索、ノードの詳細、型でのフィルタ、それとブラウザ内で動く SPARQL コンソール(Oxigraph の WASM ビルド)です。配るためのファイルが要るときは trike ui generate -o docs/index.html(Python なら db.to_html("graph.html"))で書き出します。

単一ファイルにしたのは、共有のためです。サーバも配信の準備も要らないので、ミーティングでそのまま開く、資料に埋め込む、GitHub Pages に置く、チャットに貼る、といったことが同じ1ファイルでできます。グラフの話は口頭だと伝わりにくいので、その場で開けるものがあるかどうかで会話の速さが変わりました。

実物はライブデモで触れます(Freebase の事実614件が入っていて、ブラウザ上で SPARQL を実行できます)。

この形は dbt docs serve から着想を得ました。普段データエンジニアとして dbt を使っていて、生成したドキュメントをそのまま配れるのが便利だったからです(trike uidocs servetrike ui generatedocs generate に当たります)。ソース(YAML)が正で、UI はそこから生成される派生物、という関係が同じです。違うのは、配った HTML の中で SPARQL がそのまま動くところと、生成物に content_hash() を埋めてあるところです。後者のおかげで、

trike check graph.yaml --html graph.html   # ズレていたら exit 1

で「YAML を更新したのに HTML を再生成し忘れている」状態を CI で拾えるようにしました。生成物をリポジトリに置いて配る前提なので、鮮度の検査が要ると思ったからです。

判断の基準を置く場所なので、人間が同じものを見られることは外せない条件だと思っています。レビューできないものにエージェントを書き込ませるのは、やはり怖いです。

グラフの保存先は選べるようにしました

ファイルが1つであることと、それがローカルにあることは別の話なので、置き場所は差し替えられるようにしました(設計1の4関数がここで効きます)。1つのグラフが住むのは、このうちのどれか1つだけです。

TrikeDB("graph.yaml")                            # ローカル
TrikeDB("s3://team-bucket/kg/graph.yaml")        # オブジェクトストレージ
TrikeDB("snowflake://DB.SCHEMA.KG/kg/graph")     # ウェアハウスの1行

ウェアハウスに置いた場合だけ、中身は YAML ではなく JSON になります。SQL には YAML パーサがないため、YAML 文字列を列に入れると trikedb 以外からは読めないグラフになってしまうからです。JSON なら SQL 側の JSON 関数でそのまま開けますし、ノード・エッジ・トリプルの投影ビューも一緒に張るようにしたので、既存の BI からも引けます。

置き場所を選べるのは、グラフの側が実データを持っていないからです。持っているのは取り決めと、それを指す名前だけなので、置き場所を変えても整合性を取り直す相手がいません。索引を持つ設計だと、置き場所は索引の実装に縛られますし、上流が変わったときの再構築も考えることになります。

同じグラフを、開発中はリポジトリのファイルで置いておいて、共有する段階でウェアハウスの1行に引っ越す、という使い方ができます。コピーを増やすのではなく、URL を書き換えて移すだけです。判断の基準を置く場所がベンダーのクラウドにしか置けないと、それ自体が次のサイロになってしまいます。

ワークスペースでプロジェクトやチームに分ける

グラフを1つの大きなファイルに足し続けると、そのうち誰が見るものなのか分からなくなります。なので、プロジェクトやチームごとに別の YAML にしておいて、必要なときだけ束ねる形にしました。

# workspace.yaml — メンバーはローカルとリモートを混ぜられます
graphs:
  finance:  finance.yaml
  platform: s3://team-bucket/kg/platform.yaml
  hr:       ../infra/ontology/hr.yaml
workspace.yaml ─┬─ finance.yaml     ← 会計チームがレビューする
                ├─ platform.yaml    ← 基盤チームがレビューする
                └─ hr.yaml          ← 人事チームがレビューする

読み取り  workspace.yaml に聞くと3つを横断する(束ねたビューは読み取り専用)
書き込み  それぞれのファイルに向かう

この workspace ファイルは、どのコマンドにもそのまま渡せるようにしました(trike sparql workspace.yaml ...trike ui workspace.yaml)。trike ui は引数を省略するとワークスペースを優先して選ぶので、メンバーが5つあるディレクトリでも打つのはこれだけです。

db = TrikeDB("workspace.yaml")
db.query(["?person OWNS_BUDGET ?proj", "?proj USES ?table"])
# → [{'person': '担当A', 'proj': 'project-atlas', 'table': 'ACME_DWH'}]
#     ↑ 1つ目は finance.yaml、2つ目は platform.yaml に書かれた事実

繋がるのは、RDF が名前で結合するからです。外部キーもスキーマの擦り合わせもなく、別々のチームが書いた事実が1本の経路になります。

ファイルを分けておく利点は、運用の側に出ます。

  • レビューと権限がファイル単位になります。finance のグラフは finance のチームが見る
  • プロジェクトが終わったら、そのファイルを workspace から外すだけです
  • 担当が変わるときは、ファイルごと引き継げます。異動やプロジェクトの移動が多いほど、この単位が効きます

束ねたときも、どの事実がどのグラフから来たのかは残ります。union の中では各トリプルに graph: というタグを付けるようにしたので、HTML では出所ごとに色分けしてフィルタできますし、SPARQL でも絞り込めます。

{s: 担当A, p: OWNS_BUDGET, o: project-atlas, graph: finance}

横断はしたいが、マージはしたくない。この状態のままでいられるのが、分けておく理由です。

効いているか測ってみました

WebQSP の300問で、検索の当たり方を測りました。KBQA の評価で使われる標準のデータセットで、対象もデータセットが用意しているサブグラフです。1問あたり平均4,312トリプルの中から、プロンプトに入れる250トリプルを選びます。その250件に正解が残っていたのは 89.3% でした。

選び方(250トリプルの予算) 正解が文脈に入った率
1ホップ + CVT 70.7%
意味検索のみ 88.7%
意味検索 + エンティティ 89.3%

同じ予算でも、選び方で18.6ポイント変わります。一方で、エンティティを軸にする工夫は意味検索だけとほぼ差がありません(89.3% 対 88.7%)。手を入れた分の見返りは薄かった、ということです。

表の3つは、どれもこのライブラリの中の検索方法です。他のツールの数字は入っていません。測定の詳細はこちらです

既存のものとの位置関係

グラフを扱う道具は、置き場所も守備範囲もかなり違います。オントロジーまで正面から扱うものもあれば、グラフを引く機能に絞ったものもあります。

データモデル 動かし方 得意なところ
Palantir Ontology / AIP 独自の型(オブジェクト・リンク・アクション プラットフォーム 操作まで型で持つので、書き戻しと権限と実行を1つの層で扱える
AWS Context Ontology Accelerator OWL / Turtle Neptune + AgentCore LLM が下書きしたオントロジーを人が承認してから出す。SHACL 制約と推論器で整合性を検査する
Stardog RDF サーバ / Stardog Cloud 仮想グラフで、データを取り込まずに外部を横断して引ける
Neo4j プロパティグラフ サーバ 経路探索や中心性といったグラフアルゴリズムの土俵。RDF や OWL も neosemantics プラグインで扱える
Microsoft Fabric IQ のオントロジ(プレビュー) 独自の型(エンティティ型・プロパティ・リレーションシップ) Fabric OneLake にある既存のデータに定義をバインドして、部門をまたいだ共通の語彙にする。自然言語から引く層(NL2Ontology)もある
BigQuery Graph プロパティグラフ ウェアハウスの機能 既存のテーブルの上にビューとして張るので、データを複製しないまま ISO GQL で辿れる
trikedb トリプル + 点と線のプロパティ(RDF とプロパティグラフの両方に投影) ライブラリ。置き場所はファイル / S3 / ウェアハウスの1行から1つ 語彙を書き込み口で守る。変更が git の差分に出るので、人がレビューしてから通せる

いずれも、業務データの規模に耐えることを前提にした設計です。こちらはそこには踏み込まず、意味だけを持って実データを持たない形にしました。だから1ファイルに収まります。

置き換えを狙って作ったものではありません。データが増えて、性能や権限まで面倒を見る必要が出てきたら、表の上のように動かす基盤を持っている側の方が合っています。

できないこと・向かないこと

  • 数百万トリプル:載りません。全部メモリ上に置き、部分更新をしない設計なので、実用上の天井は数MB程度です
  • 大きなコーパスの意味検索:3万文あたりが目安です(構造検索は10万トリプルまで速いので、先に頭打ちになるのは意味検索の側です)
  • PDF はグラフになりません:抽出パイプラインではありません。抽出はエージェントの仕事で、こちらは検証付きの書き込み口という役割分担です
  • フルの OWL-DL 推論、名前付きグラフ、マルチテナントのガバナンス:このあたりが必要なら、エンタープライズのセマンティックプラットフォームの方が合っています

おわりに

エージェントに任せる範囲を広げるほど、判断の基準を先に決めて、機械的に辿れる形に置いておく必要が出てきます。文章で書いておくだけだと、どれが今有効なのかを決めるのは結局人になります。

グラフが git の中にあると、その基準の変更が差分で届きます。差分で届くならレビューできて、レビューできるならエージェントに書き込ませられる。この順番を保ちたくて、ファイル1つという形にしました。

限られた用途に合わせて作ったものなので、そのまま誰にでも合うとは思っていません。ただ、同じように「.md に書き足していくのは、そろそろ限界だな」と感じている人がいたら、こういう置き方もある、くらいの参考になれば幸いです。

参考