やってみた 2026年10月20日

graphifyでFastAPIをオフラインのまま知識グラフにしてみた。READMEの例は曖昧一致で止まり、日本語の質問は0件だった

XECIN Python知識グラフ静的解析tree-sitter

GitHubのトレンドを眺めていたら、Graphify-Labs/graphify が7位に入っていました。説明には「Turn any codebase, with its docs, SQL schemas, configs, and PDFs, into a queryable knowledge graph.」とあります。確認した時点でスターは 129,524 です。

恥ずかしながら、僕は新しいコードベースに入るたびに、最初の数日を「どこに何があるか」を探すだけで使ってしまいます。grep で関数名を追いかけて、気づいたら同じファイルを何度も開いている、というやつです。コードの地図を作ってくれるなら欲しい。そう思って触ってみました。

README には「Code is parsed with tree-sitter AST: deterministic, no LLM, nothing leaves your machine.」とあり、Privacy の節には「A code-only corpus requires no API key: graphify extract runs fully offline.」とも書いてあります。なので今回は、本当にネットを切っても動くのか、そして README に載っている FastAPI の実例がそのまま再現するのか、の2点を確かめることにしました。

試した環境

  • Docker の使い捨てコンテナ(python:3.12-bookworm、Debian 12、メモリ4GB・CPU2に制限)
  • Python 3.12.13 / pip 25.0.1
  • graphify 0.9.92(PyPI のパッケージ名は y が2つの graphifyy)
  • 地図を作る対象: FastAPI の main(コミット 993899cc20、2026-10-20)。.py が 1,176 ファイル、全体で 3,192 ファイル

FastAPI を選んだのは、README の「See it in action」がまさに FastAPI で graphify explain と graphify path を叩いた出力を載せていたからです。同じことをして同じ結果が出るかを見れば、ツールの癖も分かるかなと思いました。

最初の extract は1.6秒で止まった

インストールは pip install graphifyy==0.9.92 で 6.4 秒、30 パッケージでした。ここは拍子抜けするくらい軽かったです。

ところが、README を斜め読みしたまま FastAPI のディレクトリで graphify extract . を叩いたら、1.6 秒で終了コード 1 が返ってきました。

# インストール(コンテナ内)
$ pip install --no-cache-dir graphifyy==0.9.92   # real=6.446 / 30 パッケージ
$ graphify --version
graphify 0.9.92

# FastAPI のディレクトリで、キー無し・フラグ無し
$ graphify extract .        # real=1.625 / 終了コード 1
error: no LLM API key found (1997 doc/paper/image file(s) need semantic extraction). Set GEMINI_API_KEY or GOOGLE_API_KEY (gemini), ...(中略)... A code-only corpus needs no key. Or pass --code-only to index just the code (local AST, no key) and skip the non-code files.
[graphify extract] scanning /work/fastapi
[graphify extract] found 1186 code, 1753 docs, 0 papers, 244 images

# ホスト側で docker network disconnect bridge してから
$ curl -sS -o /dev/null https://pypi.org
curl: (6) Could not resolve host: pypi.org

$ graphify extract . --code-only    # real=15.475 / 終了コード 0
[graphify extract] --code-only: skipping 1997 non-code file(s) (1753 docs, 0 papers, 244 images) — no LLM extraction
[graphify extract] AST extraction on 1186 code files...
  AST extraction: 1186/1186 uncached files (100%) [8 workers]
  warning: 34 code file(s) yielded no symbols (0.02 MB) — a data file, or source this extractor models no symbols for: ...
[graphify] Deduplicated 15 node(s) (7 exact, 8 fuzzy).
[graphify extract] wrote /work/fastapi/graphify-out/graph.json: 9572 nodes, 20954 edges, 687 communities

# 3回作って graph.json の sha256 を比べる(先頭16桁)
1回目                 0cc5df59f43dbe18
2回目(キャッシュあり)  0cc5df59f43dbe18   real=5.724
3回目(出力を消して)    0cc5df59f43dbe18   real=15.245

FastAPI のリポジトリはコード(1,186 件)より多くのドキュメントを抱えていて、graphify はコード以外を 1,753 件の docs と 244 件の画像として数えていました。コード以外は LLM で意味を抜き出す前提なので、キーが無いと止まる。エラー文にそのまま「--code-only を付ければキー無しでコードだけ索引できる」と書いてあったので、正直ここはエラー文に助けられました。

そこで、コンテナのネットワークを docker network disconnect で外して、pypi.org の名前解決もできない状態にしてから --code-only で作り直しました。15.5 秒で 9,572 ノード、20,954 本のエッジ、687 個のコミュニティができました。graph.json は 9,379,130 バイトです。ネットを切っていても、少なくとも extract は最後まで通りました。

ひとつ気になったのは [8 workers] という表示です。コンテナは CPU2 に制限していたのに、nproc が 8 を返す環境だったので、help にある既定値「cpu_count」がそのまま 8 になったようです。CI の小さいランナーで回すなら --max-workers を見ておいたほうがよさそうだと思いました(今回は試していません)。

同じ入力で3回作ったときの graph.json は、3回とも sha256 の先頭が 0cc5df59f43dbe18 で一致しました。キャッシュが効いた2回目は 5.7 秒、出力を消して作り直した3回目は 15.2 秒です。README の「deterministic」は、少なくともこの範囲では言葉どおりでした。

READMEの例をそのまま打ってみる

次に、README と同じコマンドを叩きました。先に結果を並べます。

コマンド・項目README の出力例手元(FastAPI 993899cc20)
graphify explain “APIRouter” の場所routing.py L2210fastapi/routing.py L2327
同じく Degree(つながりの数)47160
graphify path “FastAPI” “ModelField”3 hops(FastAPI → DefaultPlaceholder → get_request_handler() → ModelField。向きは省略)終了コード 3(Ambiguous: FastAPI が2つ)
名前を絞って path を再実行―3 hops(FastAPI → applications.py → openapi/utils.py → ModelField。向きは省略)
$ graphify explain "APIRouter"
Node: APIRouter
  ID:        fastapi_routing_apirouter
  Source:    fastapi/routing.py L2327
  Type:      code
  Community: 4
  Degree:    160

Connections (160):
  <-- fastapi/__init__.py [imports] [EXTRACTED] fastapi/__init__.py:L23
  <-- routing.py [contains] [EXTRACTED] fastapi/routing.py:L2327
  <-- test_frontend.py [imports] [EXTRACTED] tests/test_frontend.py:L11
  ...
  --> FastAPIError [uses] [INFERRED] fastapi/routing.py:L3398
  ... and 140 more (--all lists all 160)

$ graphify path "FastAPI" "ModelField"      # 終了コード 3
Ambiguous: 'FastAPI' matches 2 nodes in different files.
  fastapi/applications.py
    id: fastapi_applications_fastapi
  pyproject.toml
    id: pkg_fastapi
Retry with path::symbol using one of the paths above (e.g. <path>::FastAPI) or the full node id.

$ graphify path "fastapi/applications.py::FastAPI" "ModelField"   # 終了コード 0
Shortest path (3 hops):
  FastAPI <--contains [EXTRACTED]-- applications.py --imports_from [EXTRACTED]--> openapi/utils.py --imports [EXTRACTED]--> ModelField

$ graphify explain "NoSuchThing123"         # 終了コード 4
No node matching 'NoSuchThing123' found.

explain のほうは、行番号もつながりの数も README と違いました。出力例の近くには、どの時点の FastAPI で取ったものかまでは書かれていなかったので、差の理由までは分かりません。手元の 160 本を見ると、tests/ 配下のテストファイルからの imports がずらっと並んでいました。

びっくりしたのは path のほうです。FastAPI という名前のノードが2つあって、ひとつは fastapi/applications.py のクラス、もうひとつは pyproject.toml から作られた pkg_fastapi というパッケージのノードでした。graphify は推測で片方を選ばず、候補を並べて終了コード 3 で止まります。help にも「query, path and explain exit 0 with an answer, 3 when a name is ambiguous」「4 when no node matches」とあって、存在しない名前を渡したときは本当に 4 が返りました。スクリプトから使うなら、この3つの値で分岐できるのはありがたいです。

fastapi/applications.py::FastAPI と書いて絞ると答えが返りましたが、経路は README と別物でした。ノード ID を渡して --undirected を付けると、今度はテストファイル test_compat.py を経由する経路になります。最初は全然分からなくて「README と違う、壊れてる?」と思ったんですが、先輩に聞いたら「README の出力例は、その時点のコードで撮ったスナップショットだと思って読むといいよ」と言われて腑に落ちました。最短経路は1本とは限らないし、コードが変われば変わる。言われてみればそうなんですよね。

日本語で聞くと0件だった

graphify query は、質問の文から関係するノードを集めてくれるコマンドです。日本語でも聞けたら嬉しいなと思って、同じ内容を4通りで聞いてみました。

質問終了コード起点になったノード見つかった数 / 表示された数
how are dependencies resolved0dependencies.py(docs_src のサンプル)、dependencies/utils.py、dependencies/models.py、_get_resolved_absolute_path()442 / 78
依存関係はどう解決されるか4なし(No matching nodes found.)0
Depends はどう解決される0Depends、Depends()1,491 / 63
solve_dependencies0solve_dependencies()、._solve_dependencies()、dependencies/utils.py462 / 82

日本語の文だけで聞くと、1件も当たりませんでした。英語の文でも、起点に選ばれたのは docs_src のサンプルファイルや、「resolved」という単語にひっかかったらしい _get_resolved_absolute_path() で、肝心の solve_dependencies() は起点に入っていません。結果を見た限りでは、意味で探しているというより、質問の単語とノード名の一致で起点を決めている動きに見えます(コードまでは追っていないので、ここは推測です)。

逆に「Depends はどう解決される」のように識別子を1つ混ぜると、日本語の部分は関係なく Depends から辿ってくれました。どれも既定の約2,000トークンに収まるよう切り詰められていて、「raise the token budget to 12160」のように全部出すのに必要な予算まで教えてくれます。日本語で質問を書くより、関数名やクラス名で聞くほうが確実、というのが今回の結論です。

INFERRED の線は一度は疑う

graphify cluster-only . でレポートも作ってみました(これもネットを切ったまま、5.0 秒)。GRAPH_REPORT.md の Summary には「95% EXTRACTED · 5% INFERRED」「Token cost: 0 input · 0 output」とあり、LLM を一度も呼ばずにここまで作れています。God Nodes の1位は FastAPI(1,025 本)で、2位の post()(278 本)を大きく引き離していました。

そのレポートの「Surprising Connections (you probably didn’t know these)」に、ライブラリ本体の _get_openapi_operation_parameters() からテストファイルの example() へ indirect_call の線が引かれていました。本体がテストの関数を呼ぶのは変だなと思って、graph.json の中身と元のコードを見比べました。

# GRAPH_REPORT.md の Surprising Connections から
- `_get_openapi_operation_parameters()` --indirect_call--> `example()`  [INFERRED]
  fastapi/openapi/utils.py → tests/test_schema_extra_examples.py

# graph.json の該当エッジ(必要なキーだけ抜き出し)
{'source': 'fastapi_openapi_utils_get_openapi_operation_parameters',
 'target': 'tests_test_schema_extra_examples_create_app_example',
 'relation': 'indirect_call', 'confidence': 'INFERRED',
 'confidence_score': 0.85, 'context': 'getattr', 'source_location': 'L220'}

# fastapi/openapi/utils.py 219〜220 行目
            openapi_examples = getattr(field_info, "openapi_examples", None)
            example = getattr(field_info, "example", None)
# ↑ 属性を読んでいるだけの行が「呼び出し」扱いになっている。ここは改善の余地がありそう

# tests/test_schema_extra_examples.py 26 行目(create_app の中で定義された関数)
        def example(item: Item = Body(example={"data": "Data in Body example"})):

220 行目は getattr(field_info, "example", None) で、属性を読んでいるだけです。それが、テストの中にある example という名前の関数への呼び出しとして解決されていました。確信度は 0.85 です。ちゃんと INFERRED と書いてあるのでツールが嘘をついているわけではないんですが、「意外なつながり」として真っ先に目に入る場所に出てくるので、根拠の行を開かずに信じるのは危ないなと思いました。ここはツール側に改善の余地がありそうですし、読む側の使い方次第でもあります。

クエリログは README と実装で既定が違った

README の Privacy の節には「every graphify query, graphify path, graphify explain, and MCP query_graph call is logged to ~/.cache/graphify-queries.log」とあります。ところが、ここまで何度も query や path を叩いたのに、そのファイルはどこにもありませんでした。

調べてみたところ、0.9.92 の querylog.py には「Opt-in only (#1797)」とコメントがあり、GRAPHIFY_QUERY_LOG_ENABLE=1 か GRAPHIFY_QUERY_LOG にパスを入れたときだけ記録する作りになっていました。有効にして叩くと、質問の文やグラフのパスが JSON Lines で残ります。

# 既定ではログファイルが作られない
$ ls -la ~/.cache/graphify-queries.log
ls: cannot access '/root/.cache/graphify-queries.log': No such file or directory

# querylog.py(0.9.92)のコメントから抜粋
    # Opt-in only (#1797). The log records every query/path/explain question and
    # ...
    #   GRAPHIFY_QUERY_LOG_ENABLE=1 log to ~/.cache/graphify-queries.log.

# GRAPHIFY_QUERY_LOG_ENABLE=1 で query と explain を1回ずつ叩いた後(1行目は途中まで)
$ cat ~/.cache/graphify-queries.log
{"ts": "2026-10-20T12:06:46.040075+00:00", "kind": "query", "question": "依存関係はどう解決されるか", "corpus": "/work/fastapi/graphify-out/graph.json", "nodes_returned": null, "result_chars": 24, "duration_ms": 379.33, ...
{"ts": "2026-10-20T12:06:46.559738+00:00", "kind": "explain", "question": "APIRouter", "corpus": "/work/fastapi/graphify-out/graph.json", "nodes_returned": 160}

# レポートと HTML(ネットは切ったまま)
$ graphify cluster-only .     # real=5.007 / 終了コード 0
Graph has 9572 nodes (above 5000 limit). Building aggregated community view...
graph.html written (aggregated: 687 community nodes, 2701 cross-community edges)
Done - 687 communities. GRAPH_REPORT.md, graph.json and graph.html updated.
$ grep -n unpkg graphify-out/graph.html
6:<script src="https://unpkg.com/vis-network@9.1.6/standalone/umd/vis-network.min.js"

既定で記録しないほうが、社内のコードに対して使うときは安心です。README の記述が古いまま残っているだけだと思いますが、ドキュメントを信じて「ログが残るから消しておこう」と探しても見つからない、ということは起きます。

書き出された graph.html を開いてみた

ひとつ前のコードブロックの最後の grep のとおり、graph.html の6行目は unpkg から vis-network を読み込む script タグでした。グラフ作りはオフラインで完結しても、この HTML はブラウザで開くときに外へライブラリを取りに行く書き方です。

どんな見た目なのかはやっぱり気になったので、あとから同じ版(graphify 0.9.92、FastAPI 993899cc20)で使い捨てコンテナを作り直して、graph.html を書き出し直しました。graph.json の sha256 の先頭は今回も 0cc5df59f43dbe18、graph.html は 898,233 バイトです。それをホストのブラウザではなく、コンテナの中の headless Chromium(Playwright 1.64.0)で開いて、1920x1080 の画面をまるごと撮りました。このときはネットワークをつないだままで、vis-network は unpkg から HTTP 200 で読み込まれています。

graphifyが書き出したgraph.htmlをheadless Chromiumで開いた画面全体(1920x1080)。黒い背景の中央に線で密につながった点の塊があり、その外側を点の輪が囲んでいる。右側のサイドバーには上からLight・Dark・Systemの切り替え、Search nodes...の検索欄、Click a node to inspect itと書かれたNODE INFO欄、testclient.py 218や.post 152などが数の多い順に並ぶCOMMUNITIESの一覧があり、最下部に687 nodes、2701 edges、687 communitiesと表示されている

開いてみると、9,572 個のノードがそのまま並ぶのではなく、687 個のコミュニティがそれぞれ1つの点になった図でした。cluster-only が出していた「aggregated: 687 community nodes, 2701 cross-community edges」のとおりで、右下にも「687 nodes · 2701 edges · 687 communities」と出ています。開き始めてから、配置の計算(物理演算)が止まったのを確認できるまで 1.1 秒ほどでした。

最初の表示では点と線だけで、名前は読めません。名前は右側の COMMUNITIES に、testclient.py(218)、fastapi/__init__.py(185)、.post(152)……と数の多い順に並んでいます。この数字はそのコミュニティに入っているノードの数で、687 個ぶん足すとちょうど 9,572 になりました。

いちばん目を引いたのは、中央の塊よりも、それをぐるっと囲む外周の点の輪です。何の輪なのか分からなかったので、HTML に埋め込まれたデータを数えてみると、687 個のうち 203 個は、ほかのコミュニティへの線が1本もないコミュニティでした。配置が止まった後の座標で中心からの距離を測ると、この 203 個はすべて、線のある 484 個のどれよりも外側にあります。輪の正体はこの 203 個でした。

オフラインの端末でこの HTML を開いたときの見え方は、今回も確かめていません。

今回学んだこと

  • パッケージ名は graphifyy(y が2つ)、コマンドは graphify
  • ドキュメントが混ざったリポジトリは、キーが無いと extract が止まる。--code-only を付ける
  • ネットを切っても extract --code-only と cluster-only は動いた。出力は3回とも同じ
  • README の出力例は今のコードでは再現しなかった。名前が被ったら path::symbol かノード ID で指定する
  • query は日本語の文だと0件。識別子で聞く
  • INFERRED の線は、根拠の行を開いてから信じる
  • クエリログは 0.9.92 では既定で無効(README は既定で記録すると書いている)

試してみての所感

ぶっちゃけ、最初は「AI アシスタント向けの skill」という説明を見て、エディタに AI が入っていないと使えないのかと思っていました。実際には、コードだけならキーもネットも要らず、CLI だけで地図が作れて、explain と path とレポートの God Nodes だけでもかなり遊べます。新しいリポジトリに入った初日にこれを眺めるのは、自分の使い方としてはアリだと思っています。

一方で、日本語の質問と INFERRED の線は、今の時点では様子見です。今回は AI アシスタントに skill として入れる使い方(graphify install)は試していないので、そちらで質問がどう扱われるかは分かりません。

次は自分のチームのリポジトリで graphify update . を回して、コミットごとにグラフがどれくらい変わるかを見てみたいです。