導入
先週、ある化学メーカーの情シス担当から連絡があった。「うちの MSDS、SharePoint と Box と共有ドライブに 4 バージョン散らばっていて、社内 ChatGPT に聞くと 3 年前の手順を返してくるんです」——正直、この話は今年に入って 5 社目だ。
RAG は入れた。埋め込みも張った。それでも「業務」は動かない。文書は引けても、誰がどの製品にどの規制を適用すべきか、という「関係」が引けないからだ。
今回から 3 回に分けて、社内データを LLM に接続する設計を実装ノートとして残す。第 1 回はオントロジーで業務構造を扱う話、第 2 回はセキュリティ、第 3 回は継続運用を予定している。

単純 RAG がどこで崩れるか
ベクトル検索は「意味的に近い」を返すが、「今、正しい」は返してくれない。同じ製品の MSDS が SharePoint に v2、Box に v3、共有ドライブに社内翻訳版が別途——という状態は、日本の中堅メーカーだと珍しくない。埋め込み空間ではどれも近いので、上位 5 件に旧版が普通に混ざる。
もう 1 つ、単純 RAG が扱えないのが「関係の完全性」だ。「この製品に適用される規制は?」という問いは、文書検索ではなく関係辿りの問題である。文書 A に「REACH」と書かれていても、それが当該製品に適用されるかは別の判定を要する。RAG は距離を返すが、判定はしない。
与信·コンプライアンス·顧客横断照会——「関係が抜けていたら答えとして成立しない」業務は、ここでほぼ確実にぶつかる。筆者が今回関わった案件も、まさにここで詰まっていた。
設計方針: 薄いオントロジー + ベクトル検索
やることは 2 つ。業務ドメインの「概念·関係·権限構造」をグラフでモデル化する。そのグラフを retrieval のフィルタ層として使う。ベクトル検索は捨てない。「近さ」の判断は今も強力だ。ただ、その手前でグラフに「正しい対象範囲」を決めてもらう。
ここで欲張らないのがコツで、全社ビッグバンで概念モデルを作ろうとすると高い確率で失敗する (前職でも何度か見た)。1 部署·1 業務の「薄切り (Thin Slice) オントロジー」から始める。今回は「化学製品と MSDS と規制」の 3 概念だけを扱う。
スキーマ (概念モデル)
ノードは Product / Document / Regulation / Department。関係は HAS_MSDS, SUPERSEDES, SUBJECT_TO, OWNS。4 概念 4 関係で、たった 8 個の要素だが、これだけで「最新版の判定」と「規制の適用」は表現できる。
実装
Python 3.11 + Neo4j 5 (Docker) + OpenAI API で組む。ベクトルストアは Neo4j 自身のベクトルインデックスを使うと、後で運用が楽になる (今回のサンプルではベクトル部分は割愛して、グラフ側の骨格に集中する)。
環境準備
docker run -d --name neo4j-lab \
-p 7474:7474 -p 7687:7687 \
-e NEO4J_AUTH=neo4j/YOUR_PASSWORD \
neo4j:5
pip install neo4j openai
オントロジーの初期化
from neo4j import GraphDatabase
driver = GraphDatabase.driver(
"bolt://localhost:7687",
auth=("neo4j", "YOUR_PASSWORD"),
)
def bootstrap(tx):
tx.run("CREATE CONSTRAINT product_id IF NOT EXISTS "
"FOR (p:Product) REQUIRE p.id IS UNIQUE")
tx.run("CREATE CONSTRAINT doc_id IF NOT EXISTS "
"FOR (d:Document) REQUIRE d.id IS UNIQUE")
tx.run("""
MERGE (p:Product {id: 'PRD-1023', name: '有機溶剤A'})
MERGE (dep:Department {id: 'DEP-CHEM', name: '化成品事業部'})
MERGE (d3:Document {id: 'MSDS-1023-v3', version: 3,
effective_date: date('2026-01-15'),
source: 'sharepoint'})
MERGE (d2:Document {id: 'MSDS-1023-v2', version: 2,
effective_date: date('2024-06-01'),
source: 'box'})
MERGE (r:Regulation {id: 'REACH-XVII', name: 'REACH 制限物質'})
MERGE (dep)-[:OWNS]->(p)
MERGE (p)-[:HAS_MSDS]->(d3)
MERGE (p)-[:HAS_MSDS]->(d2)
MERGE (d3)-[:SUPERSEDES]->(d2)
MERGE (p)-[:SUBJECT_TO]->(r)
""")
with driver.session() as session:
session.execute_write(bootstrap)
ここで 1 つハマりポイントを共有する。SUPERSEDES の方向は「新→旧」で張るほうが、後の Cypher クエリが素直に書ける。逆向きに張って 30 分溶かした。「最新は誰にも置き換えられていないもの」という判定は下で頻繁に出るので、方向を統一しておく意味がある。
最新版だけを返す retrieval
def get_current_msds(tx, product_id: str):
result = tx.run("""
MATCH (p:Product {id: $pid})-[:HAS_MSDS]->(d:Document)
WHERE NOT ()-[:SUPERSEDES]->(d)
RETURN d.id AS doc_id,
d.version AS version,
d.effective_date AS eff
ORDER BY d.effective_date DESC
LIMIT 1
""", pid=product_id)
rec = result.single()
return dict(rec) if rec else None
def get_applied_regulations(tx, product_id: str):
result = tx.run("""
MATCH (:Product {id: $pid})-[:SUBJECT_TO]->(r:Regulation)
RETURN r.name AS name
""", pid=product_id)
return [r["name"] for r in result]
「まだ SUPERSEDES を張られていないノード」= 最新版、という定義になる。旧版もグラフには残しておく。物理削除しない。監査 (「なぜあの時、旧版を参照したのか」) で必ず問われるからだ。
質問応答
from openai import OpenAI
client = OpenAI(api_key="YOUR_API_KEY")
def fetch_chunks_by_doc(doc_id: str, question: str) -> list[str]:
# 実運用では Neo4j のベクトルインデックス or 別ストアで
# doc_id をメタデータフィルタに掛けて chunk を取る
return ["(該当 doc の chunk がここに入る)"]
def answer(question: str, product_id: str) -> str:
with driver.session() as session:
current = session.execute_read(get_current_msds, product_id)
regs = session.execute_read(get_applied_regulations, product_id)
if current is None:
return f"製品 {product_id} の MSDS が登録されていない"
chunks = fetch_chunks_by_doc(current["doc_id"], question)
context = "\n\n".join(chunks)
reg_lines = "\n".join(f"- {r}" for r in regs)
prompt = f"""以下は製品 {product_id} の最新 MSDS
(バージョン {current['version']}, 発効 {current['eff']}) からの抜粋である。
適用規制:
{reg_lines}
資料:
{context}
質問: {question}
資料に根拠のない推測はしないこと。"""
resp = client.chat.completions.create(
model="gpt-4-turbo",
messages=[{"role": "user", "content": prompt}],
temperature=0.1,
)
return resp.choices[0].message.content
動作確認
>>> print(answer("有機溶剤A の廃棄手順を教えてください", "PRD-1023"))
有機溶剤A (製品 ID: PRD-1023, MSDS v3, 発効 2026-01-15) の廃棄
手順について、資料に基づき回答します。
廃液は指定容器 (耐溶剤仕様) に一次貯留してください
産業廃棄物処理業者への引き渡し前に、REACH 制限物質
(Annex XVII) 該当項目のラベル表示を確認してください
...
ここで効いているのは 2 点。旧版 (v2) は retrieval の時点で構造的に除外されているので、モデルが古い手順を混ぜて答えるリスクが減る。もう 1 点、規制の列挙はモデルに任せず、グラフから確定的に文字列として渡している。ここは絶対に幻覚させたくない情報なので、LLM の生成に触らせない。判定はグラフ、文章化は LLM、という役割分担だ。
応用
金融の与信 (顧客 - 保証人 - 担保 - 規制) や、医療の患者 - 処方 - アレルギー - 相互作用の判定にほぼそのまま応用できる骨格である
権限構造を
Department -[:CAN_READ]-> Documentでグラフに埋めておくと、retrieval の時点で「見せてよい文書」を絞れる。RBAC を retrieval 層で解く発想で、第 2 回で扱う予定グラフに
Personノードと社内組織の関係を持たせると、「この案件、誰に相談すべきか」を LLM に答えさせる社内 Q&A も同じ骨格で組める
まとめ
単純 RAG が返すのは「近い文書」であって「今の業務における正解」ではない。オントロジーは大げさな概念に聞こえるが、実装としては 4 ノード 4 関係から始まる。全社の概念モデルを描こうとせず、1 業務ドメインに絞る。それだけで LLM の出力品質はかなり跳ねる。
今回扱わなかった論点として、PII マスキング·権限分離·監査ログ·専有 LLM の構成がある。特に日本の中堅企業では「社内データを外部 API に送れない」という制約が最初に出てくることが多く、次回はそこを掘り下げる予定だ。
筆者は 5years+ で日本·韓国の中堅企業向けに AI/LLM の業務応用を担当している。今回のオントロジー設計も、社内の実案件で行き詰まった箇所を整理したメモが元になっている。同じ問題で 30 分でもハマる人が減れば幸いだ。