アーキテクチャ·設計

DifyのRAGは内部で何をやっているのか — 自前実装して初めて見えたこと

DifyでFAQボットを作り、チャンクサイズを変えながら検索精度を上げようとしていた。「500から200に変えたら精度が上がった」――だが、なぜ上がったのかが分からなかった。Difyのナレッジベース画面ではチャンクの中身は見える。ただ、埋め込みベクトルが何を計算しているのか、検索スコアがどう算出されているのかは、画面の向こうで完結している。

NotebookLMも同様だ。PDFを投げれば高精度の応答が返る。便利だが、精度が落ちたときに何を直せばいいのかが見えない。「使う」だけでは理解が追いつかない、と感じ始めた。同じパイプラインを手で実装すれば、Difyが内部でやっていることを1ステップずつ追える。そう判断して実装を始めた。


なぜ自前実装したのか

Difyは優れたローコードツールだ。ファイルをアップロードすれば、チャンク分割・埋め込み生成・ベクトルインデックス作成が自動で完了する。ただし、その「自動」の中身は設定画面からは見えない。

精度チューニングの現場では「埋め込みモデルを切り替えると結果が変わった」という事実は分かっても、「なぜ変わったのか」が分からない。4096次元のベクトルと1536次元のベクトルでは何が違うのか。コサイン類似度でなくL2距離を使ったら何が変わるのか。設定値と結果の因果が理解できなければ、チューニングは感覚的な試行錯誤になる。

Zoteroに溜めた論文コレクション(約300件)を自分のナレッジベースに取り込む個人プロジェクトを立ち上げ、そこでRAGパイプライン全体を自前実装することにした。DifyのRAGが内部でやっていることを、自分でトレースするために。


全体アーキテクチャ

実装したパイプラインは以下の構成だ。

📚
Zotero
論文収集・ライブラリ管理。WebAPI でコレクションを取得する
↓ Schedule Trigger(n8n)
⚙️
n8n(オーケストレーション)
3つのワークフローに分割。差分検知・PDF取得・チャンク埋め込みを担当
↓ PDF → テキスト抽出
📄
MinerU
学術PDF特化のテキスト抽出ツール。LaTeX数式・図表キャプションを除去してプレーンテキスト化
↓ 埋め込みベクトル生成(4096次元)
🤖
Ollama + Qwen3-Embedding-8B
ローカル実行の埋め込みモデル。チャンクごとに4096次元ベクトルを生成。API料金ゼロ
↓ ベクトル保存・近傍検索
🗄️
PostgreSQL + pgvector
ベクトル検索拡張。ivfflat インデックスでコサイン類似度検索。チャンクとメタデータを同一テーブルで管理

n8nのワークフローは3分割した。単一の巨大ワークフローにすると「どこで失敗したか」の追跡が難しくなるからだ。

WORKFLOW 1
Zotero論文同期
ZoteroライブラリとDBの差分を検知してINSERT / DELETE。未取り込みをpendingとして登録
WORKFLOW 2
取り込みリカバリー
pending / failed な論文を1件ずつ処理。3回連続失敗でpermanently_failedとしてLINE通知
WORKFLOW 3(サブ)
チャンク埋め込み
1論文分のチャンクを順番にOllamaへ送り、ベクトルをDBへ挿入。Workflow 2から呼び出す

Difyとの対比 Difyはこの一連の処理をファイルアップロード1回で完結させる。チャンク・埋め込み・インデックスが「裏側で自動生成」される。自前実装ではそれを1ステップずつ手動確認しながら行う。このプロセスを追うことで「Difyが内部で何をやっているか」が初めて具体的に見えてきた。


踏んだ落とし穴8つ

実装中に発生した問題を「症状 → 原因 → 対処」の形式で記録する。問題6と7は発見のプロセスに特に時間がかかったため、詳しく書く。

問題1: 部分成功なのに completed になる

症状 — 一部チャンクが欠落してもステータスがcompleted

原因:n8nは「エラーが発生しなかった」ことを成功と判断する。「全チャンクがDBに入ったか」は確認していない。

対処:SELECT COUNT(*) FROM knowledge_chunks WHERE document_id = $1 で実際の挿入件数を取得し、期待件数と照合してからステータスを確定する設計に変更した。n8nの「成功」を信用するな。実データで検証せよ。

問題2: queryReplacement が “Variable $1 out of range” を返す

症状 — Postgresノードの配列パラメータ方式でエラー

調査:Postgresサーバーのログには当該クエリが届いていない。n8nクライアント側での解析エラーだった。

対処:配列パラメータ方式を廃止し、クエリ文字列への直接埋め込み方式に統一した。

問題3: 自由テキストのSQL埋め込みで構文エラーが連発する

症状 — LaTeX記法を含む本文をSQLに埋め込むと構文エラー

原因:シングルクォートエスケープでもドル引用符でも、LaTeX記法や特殊文字と衝突し続けた。

対処:Base64エンコード方式に統一した。Base64は英数字と +/= のみで構成されるため特殊文字と衝突しない。SQL側で decode(content_b64, ‘base64’)::text として復元する。

問題4: ReadWriteFileノードがファイル不在時に「静かに」0件出力する

症状 — ファイル不在でもエラーにならず、正常終了する

原因:onError設定をしていても、指定パスにファイルがないと成功・失敗どちらの分岐も0件になり、ワークフローが「何もせず正常終了」する。

対処:alwaysOutputData を設定して必ず1件出力させ、後続のIfノードで mimeType フィールドの有無を見て「本当に読めたか」を判定する構成にした。

問題5: 複数配線を1ノードにつないでもn8nは1回の実行にまとめない

症状 — 複数ソース合流のつもりが、別々の実行になった(2回踏んだ)

1回目:2つのHTTPリクエスト結果を突き合わせるCodeノードで、片方の接続だけが実際の入力になっていた。もう片方は $(‘ノード名’).all() で明示参照する必要があった。

2回目:DB確定判定用のPostgresノードが3本の配線を直接受けており、大きい論文で一部処理が失敗すると、全チャンク処理完了より前に確定判定が走る競合状態が発生した。Mergeノード(mode: append)で「全入力が揃うまで待つ」構成に変更して解決した。

問題6: クロスノード参照を関数の引数に渡すとnullになる

この問題は、原因に気づくまでに最も時間がかかった問題のひとつだ。

症状 — 素の参照は動く。関数で包んだ途端にnullになる

あるSetノードで {{$(‘NodeX’).item.json.content}} は正常に値を返す。しかし同じ参照を {{Buffer.from($(‘NodeX’).item.json.content, ‘utf8’).toString(‘base64’)}} で包んだ途端、結果がnullになった。btoa() に変えても同じ壊れ方をした。

発見のプロセス:「Base64エンコードの実装が間違っているのか」「文字列型の問題か」と、エンコード関数を3種類試した。どれも同じ結果だった。次に「content フィールドの中身が本当にあるのか」と確認すると、素の参照では値が返る。おかしい。エンコード関数の引数として渡すとnullになるのに、引数なしで参照するだけなら値が返る。

ここで気づいた。壊れているのはエンコードではなく、「クロスノード参照と関数呼び出しの組み合わせ」だ。

根本原因:n8nの式評価エンジンは、クロスノード参照($(‘NodeName’).item)を別ノードの実行コンテキストで解決する。この参照を関数の引数として評価しようとすると、コンテキストが不整合になりnullが返る。n8nの既知の挙動だ。

対処:クロスノード参照と計算を分離する。Base64計算はデータを生成しているCodeノード側で完結させ、後続ノードは {{$(‘CodeNode’).item.json.content_b64}} という素のフィールド参照だけを使う設計に変更した。

✕
NG — Setノードの式欄
Buffer.from($(‘NodeX’).item.json.content, ‘utf8’).toString(‘base64’)
→
OK — Codeノード側で計算を完結
items[0].json.content_b64 = Buffer.from(text, ‘utf8’).toString(‘base64’);

問題7: ネストしたSplit in Batchesで2件目以降が処理されない

この問題は、「エラーがない」のに「処理されていない」という最も発見しにくい形で現れた。

症状 — 1件目の論文は処理される。2件目以降のチャンクが0件になる

複数論文を一括取り込みしたとき、DBに入ったのは1件目の論文のチャンクだけだった。2件目以降の論文はステータスが completed になっているのに、チャンクがDBに1件も存在しない。

発見のプロセス:まずDBのチャンク件数を確認した。論文Aは300チャンク、論文Bは0チャンク、論文Cは0チャンク。ステータスはすべて completed。「なぜ成功しているのにチャンクがないのか」が全く分からなかった。

次にn8nの実行ログを開き、各ノードの実行回数を地道に確認した。Embed Chunkノードの実行回数が、論文Aのチャンク数(300回)だけだった。論文B・Cのチャンク処理は一度も実行されていない。「2周目のループが回っていない」と確定した。

根本原因:n8nのSplit in Batches(ループ)を別のSplit in Batchesの内側にネストすると、外側ループの2周目以降で内側ループが正しくリセットされない。n8nの既知の制限だ。問題が分かれば解決策は明快だった。

対処:内側のループをサブワークフローに切り出す。外側ループのノードからExecute Workflow(同期呼び出し)で論文ごとに1回呼び出す構成に変更した。サブワークフロー自身から見れば「ただ1つのループ」なので、確実にリセットされる。

修正前(ネストで壊れる)
外側ループ(論文) → 内側ループ(チャンク) → Embed Chunk
↓
修正後(サブワークフローで分離)
外側ループ(論文) → Execute Workflow → [サブWF: チャンクループ → Embed Chunk]

問題8: n8nのエラーパネルが失敗理由を教えてくれない

症状 — エラー出力にクエリは表示されるが、失敗理由が空

対処:docker logs postgres-papers | grep -i error でPostgres側のログを直接確認した。

発見:invalid byte sequence for encoding “UTF8”: 0x00 — NUL文字が混入していた。PDFテキスト抽出ツール(MinerU)が稀にNUL文字(\x00)を生成する。PostgreSQLのTEXT型はNUL文字を格納できない。

対処:テキスト抽出直後にCodeノードで1行追加した。

// Codeノードに追加した1行
const cleaned = text.replace(/\x00/g, '');

得られた教訓

LESSON 1
「ローコード」でも設計原則は同じ

n8nはGUIで組めるが、「状態を持つ処理の同時実行を疑う」「失敗を握りつぶさない」「実データで検証する」というエンジニアリングの原則はそのまま必要だ。ツールが変わっても設計の考え方は変わらない。

LESSON 2
ログはn8nだけで完結しない

n8nの実行ログだけ見ていると「成功」に見える障害がある。Postgresのサーバーログ、Dockerのコンテナログ、実際のDBの中身——これらを合わせて初めて「何が起きているか」が見える。

LESSON 3
Difyが何をやっているか、ようやく見えた

自前実装を通じて、Difyがアップロード時に自動でやっていることの意味が分かった。チャンク分割の設定が精度に直結すること、埋め込みモデルの次元数が検索速度とのトレードオフであること。「使う」だけでは分からなかったことが「作る」ことで初めて見えた。

✅ 自前実装で分かったDifyのチャンク設定の意味 チャンクサイズ500と200で精度が変わる理由は、「1チャンクに含まれる情報の粒度」にある。大きすぎると無関係な情報がベクトルに混入し、小さすぎると文脈が途切れる。Difyのスライダーを動かす意味が、実装後に初めて具体的に分かった。


今後の展望

現在は論文専用のパイプラインだが、スキーマを汎用化して「Webページ・GitHubリポジトリ・自分のメモ・AIとの会話ログ」も取り込める汎用Knowledge Baseに拡張する予定だ。

🔍
ハイブリッド検索インターフェース
自然言語クエリ → ベクトル検索 + 全文検索(RRF)のハイブリッドで精度を上げる
🔄
Dify外部ナレッジベース連携
pgvectorをDifyの「外部ナレッジベース」APIエンドポイントとして接続し、Difyに知識を還流させる構成も検討中

この基盤を作って気づいたのは、RAGの精度はツールより「データの質とパイプラインの設計」で決まるということだ。DifyもNotebookLMも、入れたものしか返せない。


参考資料