社内文書検索AIを作って学んだこと──RAGの「詰まりどころ」を全部書く
対象読者: RAGに興味があるが実装経験が少ない方、PDFをナレッジベースにしようとしている方
作ったもの
社内規則・経費マニュアル・リモートワーク規定などの PDF を取り込み、自然言語で質問すると該当箇所を引用しながら回答する 社内文書検索 AI を作りました。
技術スタック:
| レイヤー | 使用技術 |
|---|---|
| Embedding | OpenAI text-embedding-3-small
|
| ベクトルDB | Supabase pgvector |
| 回答生成 | Anthropic claude-sonnet-4-6(ストリーミング) |
| フロントエンド | Next.js 16 App Router + SSE |
| 認証 | Supabase Auth(マジックリンク、ドメイン制限) |
GitHubリポジトリ: https://github.com/nobunori47/rag-search-ai
デプロイURL: https://rag-search-ai.vercel.app
詰まったポイントと解決方法
1. PDFパース時の「Unicode互換漢字」問題
最初は類似度が 0.45 前後と低く、閾値 0.5 でほとんどヒットしませんでした。原因を調べると、PDFから抽出したテキストに Unicode互換漢字(互換文字)が混入していました。
たとえば「入社」が ⼊社(U+2F09)、「日本」が ⽇本(U+2F4F)として保存されており、Embedding モデルが「入」と「⼊」を別の文字として扱っていたのです。
解決策: 取り込み時に NFKC 正規化を1行追加しました。
text.normalize("NFKC").replace(/\s+/g, " ").trim()
これで互換漢字が標準漢字に統一され、类似度が若干改善しました。ただし、これだけでは不十分で、根本的な問題は別にありました(次節参照)。
2. チャンキング戦略の失敗と改善
類似度が低い本当の原因はチャンクの粒度が粗すぎることでした。
最初の実装は「1ページ = 1チャンク」でした。しかし、就業規則のような文書は1ページに「有給休暇」「時間外労働」「慶弔休暇」など複数のトピックが混在します。これを1チャンクにまとめると、クエリとの類似度が分散し、どの質問にもピンポイントでマッチしなくなります。
ベクトル検索で確認すると、類似度は 0.46 程度。閾値を下げれば拾えますが、それは「精度を犠牲にしてノイズを増やす」対症療法にすぎません。
根本解決: 章単位チャンキングへの変更
// 「第◯章」や「1. 」などの見出しパターンで分割
function splitByChapters(text: string): string[] {
const chapterRe = /第[0-90-9一二三四五六七八九十百]+章/g;
if (chapterRe.test(text)) return splitByHeadingPattern(text, chapterRe);
// numbered headings fallback...
}
章単位に分割し直してデータを全件再取り込みしたところ、類似度が 0.62 まで向上。閾値 0.5 で正しく検索できるようになりました。
学び: 検索精度が低いとき、最初に疑うべきはモデルや閾値ではなくチャンク設計。
3. ハルシネーション対策のプロンプト修正
初期プロンプトでは「文書にない場合は推測しないでください」程度の指示しかありませんでした。しかし実際に動かすと、タクシー利用の質問に対して「経理部にご確認ください」という文書に書かれていないアドバイスが回答に混じりました。
修正したプロンプト(抜粋):
- 文書に記載のない情報については、推測・補足・提案・一般論を一切含めず、
「提供された文書には、該当する情報の記載が見つかりませんでした」とだけ回答すること
- 「〜と思われます」「〜にご確認ください」などの推測や誘導を含む表現は使用しないこと
「使ってはいけない表現のパターン」を具体例で列挙することで、ハルシネーションを大幅に抑制できました。
工夫した点
「縦切りで一周させてからPDFに移行」という開発順序
最初は TXT ファイル2本だけで ingest → search → answer の**一連の流れ(縦切り)**を完成させ、動作確認してから PDF 対応に着手しました。
PDF パース・NFKC正規化・章単位チャンキングは「拡張機能」として後から追加できたので、デバッグの切り分けが明確にできました。「全部同時に作って全部動かない」という状況を避けられたのが良かったです。
閾値を下げるのではなく根本原因を修正した判断
類似度が低いとき、手っ取り早い解決策は MATCH_THRESHOLD を 0.5 → 0.3 に下げることです。しかし、それをやるとノイズが増えてトークン消費が増加し、無関係な文書を参照した回答が生成されます。
「閾値を下げる前に、なぜ類似度が低いのかを確認する」という一手間が、精度を根本から改善できた決め手でした。
精度検証結果
12問の自動テストスクリプトを作成し、期待回答と比較しました。
| 指標 | 結果 |
|---|---|
| 正答数 | 11 / 12 問 |
| 正答率 | 92% |
| 平均応答時間 | 2,622ms |
| 5秒以内クリア | 12 / 12 問 |
不正解の1問(Q9)は it-onboarding.pdf の特定セクションの類似度が惜しくも閾値未満で、正確な情報を引き出せませんでした。チャンキング粒度の更なる改善が課題です。
学んだこと・次に活かすこと
-
RAGの精度はチャンク設計で決まる。Embeddingモデルの性能より、何をひとまとまりとして保存するかの方が影響が大きい。
-
ハルシネーション対策は「禁止ルールの具体例列挙」が効く。「推測しない」より「〜という表現を使わない」の方が LLM には伝わりやすい。
-
縦切り開発(一本道で最初から最後まで)→ 横拡張の順序は、RAGに限らず有効な進め方だと実感した。
-
テストスクリプトを早めに作る。12問の自動テストがあったことで、閾値変更やチャンク修正の効果を即座に数値で確認できた。
次は差分更新パイプライン(同一ファイルの再取り込み時の重複除去)と、管理画面(質問ログ・回答品質のモニタリング)を実装したいと思います。
使用ツール: Claude Code / Anthropic claude-sonnet-4-6 / Next.js 16 / Supabase / OpenAI