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, '');
得られた教訓
n8nはGUIで組めるが、「状態を持つ処理の同時実行を疑う」「失敗を握りつぶさない」「実データで検証する」というエンジニアリングの原則はそのまま必要だ。ツールが変わっても設計の考え方は変わらない。
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も、入れたものしか返せない。
参考資料