Langchain.js + Neo4jでナレッジグラフを作ってみる

天京祐輔
天京祐輔

2024年 09月 18日 12時 50分

今回は今話題のナレッジグラフをLnagchainとNeo4jを使って構築してみようという記事です

結果

入力として、細川ガラシャさんのwikiの内容を要約したマークダウンを入力してみましょう(SHOGUN流行ってるからね、仕方ないね)

## 細川ガラシャ
### 概要
細川ガラシャ(伽羅奢、迦羅奢、Gracia)は、戦国時代から安土桃山時代にかけての女性です。明智光秀の三女で、細川忠興の正室でした。実名は「たま」(玉/珠)または「玉子」(たまこ)で、法名は秀林院です。キリスト教徒(キリシタン)であり、洗礼名はガラシャでした。
### 生涯
 *永禄6年(1563年)** 越前国で明智光秀と妻・煕子の間に三女として誕生します。
 *天正6年(1578年)** 父の主君・織田信長の意向により、細川藤孝(幽斎)の嫡男・忠興に嫁ぎます。
 *天正10年(1582年)** 本能寺の変が発生し、父・光秀が討たれます。明智家は「謀反人の一族」として追討され、珠も連座を免れませんでした。しかし、忠興は彼女と離縁せず、天正12年(1584年)まで丹後国の味土野に幽閉しました。
 *天正12年(1584年)** 豊臣秀吉の取り成しにより、忠興は珠を細川家の大坂屋敷に戻し、監視しました。
 *天正14年(1586年)** 三男・忠利が誕生します。
 *天正15年(1587年)** 侍女に囲まれ、密かに教会を訪れ、洗礼を受け、ガラシャと名乗ります。
 *慶長5年(1600年)** 夫・忠興が上杉征伐に出陣している間に、西軍の石田三成が細川屋敷を包囲し、ガラシャを人質に取ろうとします。ガラシャはそれを拒絶し、家老の小笠原秀清がガラシャを介錯し、屋敷に爆薬を仕掛け自刃しました。
 *慶長6年(1601年)** 忠興はガラシャの遺骨を大坂の崇禅寺へ改葬しました。
### 人物像
ガラシャは気位が高く激しい性格の持ち主でしたが、キリストの教えを知ってからは謙虚で忍耐強く穏やかになったと言われています。夫・忠興に対しては、気遣いや愛情を示す一方で、強い意志を持つ女性であったことが伺えます。
### 逸話
* ガラシャは、夫・忠興が家臣を手討ちにした際、その刀の血を自分の小袖で拭っても動ずることなく、数日間も着替えないので、結局は忠興が詫びて着替えてもらったという逸話が残っています。
* ガラシャの辞世の句として「散りぬべき 時知りてこそ 世の中の 花も花なれ 人も人なれ」は有名です。
### 現代におけるガラシャ
ガラシャは、戦国時代を代表する女性として、多くの作品で取り上げられています。小説、戯曲、オペラ、アニメなど、様々な作品でガラシャの姿を見ることができます。また、長岡京ガラシャ祭など、ガラシャを顕彰するイベントも開催されています。
### 注釈
1. 忠興は家臣2名に珠を昼も夜も見張らせた。珠は近親者以外からの伝言は受け取れず、近親者からのものであっても2人の検閲を受ける必要があった。また、家を訪問してきた者と外出した女性を全て記録して書面で提出させ、外出した女性については誰が許可したのか、どこへ行ったのかまで記録させた。珠も含めた屋敷内の女性は各自が許可された部屋にしか行くことができず、領域を接していない人間と会話することはできなかった。
2. その場にいたグレゴリオ・セスペデス司祭は、彼女が秀吉の側室ではないかと疑った。
3. 忠隆子孫はのちに細川一門家臣・長岡内膳家〔別名:細川内膳家〕となり、明治期に細川姓へ復している。
4. ガラシャ―忠隆―徳(西園寺実晴室)―公満―女(久我通名室)―広幡豊忠―女(正親町実連室)―公明―実光―正親町雅子―孝明天皇―明治天皇―大正天皇―昭和天皇―上皇―今上天皇
### 参考文献
* 村井益男 著「キリシタンの女たち」、笠原一男 編『彼岸に生きる中世の女』評論社〈日本女性史3〉、1976年。
* 宮本義己「細川幽斎・忠興と本能寺の変」米原正義編『細川幽斎・忠興のすべて』新人物往来社、2000年
* 宮本義己「史料紹介&ドキュメント「霜女覚書」が語るガラシァの最後」『歴史読本』45巻12号、2000年。
* 田端泰子 著「戦国期の『家』と女性-細川ガラシャの役割-」、京都橘女子大学女性歴史文化研究所 編『京都の女性史』思文閣出版、2002年。
* 角田文衞『日本の女性名 歴史と展望』国書刊行会、2006年、235頁

こんな感じのものを作ります

上記のナレッジグラフだけではなく、一度の実行で下記のような複数のナレッジグラフが同時に作られます

環境構築

自分がpythonよく分からんので今回はオールTSで作ります

docker-composeでneo4jを持ってきます

version: "3.8"
services:
  neo4j:
    image: neo4j:latest
    restart: always
    ports:
      - "${EXPOSE_NEO4J_HTTP_PORT:-7474}:7474"
      - "${EXPOSE_NEO4J_BOLT_PORT:-7687}:7687"
    environment:
      - NEO4J_AUTH=${NEO4J_AUTH:-neo4j/password}
      - NEO4J_dbms_memory_heap_initial__size=${NEO4J_HEAP_SIZE:-512m}
      - NEO4J_dbms_memory_heap_max__size=${NEO4J_HEAP_MAX_SIZE:-1G}
      - NEO4J_PLUGINS=["apoc"]
      - NEO4J_dbms_security_procedures_unrestricted=apoc.*
      - NEO4J_dbms_security_procedures_allowlist=apoc.*

環境変数はこんな感じ

OPENAI_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

NEO4J_URI=bolt://localhost:7687
EXPOSE_NEO4J_HTTP_PORT=7474
EXPOSE_NEO4J_BOLT_PORT=7687
NEO4J_USERNAME=neo4j
NEO4J_PASSWORD=password
NEO4J_AUTH=neo4j/password
NEO4J_HEAP_SIZE=512m
NEO4J_HEAP_MAX_SIZE=1G

package.json

neo4j-driverは必須です

{
  "name": "tutorial",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
      "dev": "ts-node src/main.ts",
    "format": "prettier --write ."
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
      "@langchain/community": "^0.3.1",
    "@langchain/openai": "^0.2.11",
    "dotenv": "^16.4.5",
    "langchain": "^0.2.16",
    "neo4j-driver": "^5.24.1"
  },
  "devDependencies": {
      "prettier": "^3.3.3",
    "ts-node": "^10.9.2",
    "typescript": "^5.5.4"
  }
}

ルートディレクトリにさっきの細川ガラシャのマークダウンをsample.txtとして置いてください

コード

import * as dotenv from "dotenv";
import { ChatOpenAI } from "@langchain/openai";
import fs from "fs";
import { Neo4jGraph } from "@langchain/community/graphs/neo4j_graph";
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
import { LLMGraphTransformer } from "@langchain/community/experimental/graph_transformers/llm";
dotenv.config();
const model = new ChatOpenAI({
    apiKey: process.env.OPENAI_API_KEY,
    model: "gpt-4o-mini",
    temperature: 0,
  
});
const llmGraphTransformer = new LLMGraphTransformer({
    llm: model,
  
});
const main = async () => {
    try {
      const file = fs.readFileSync("./sample.txt", "utf-8");
      const splitter = RecursiveCharacterTextSplitter.fromLanguage("markdown", {
        chunkSize: 1000,
        chunkOverlap: 200,
  
    });
    const chunkedDocs = await splitter.createDocuments([file]);
    const graph = await Neo4jGraph.initialize({
        url: process.env.NEO4J_URI || "",
        username: process.env.NEO4J_USERNAME || "",
        password: process.env.NEO4J_PASSWORD || "",
  
    });
    const graphDocuments =
      await llmGraphTransformer.convertToGraphDocuments(chunkedDocs);
    await graph.addGraphDocuments(graphDocuments);
  } catch (error) {
      console.error(error);
  
  } finally {
      process.exit(1);
  
  }
};
main();

実行方法

$ docker-compose up

してください

正常にneo4jが立ち上がると

http://localhost:7474/browser/

でneo4jのGUIクライアントが立ち上がっているはずです

で以下でmain.tsを実行

npm run dev

うまくいくとデータベースクライアントのサイドバーの Node labels とか `Relationship types

` をぽちぽちしたらナレッジグラフが出来てるはず

解説

参考にしたのは以下の記事です、というかほぼ一緒

https://js.langchain.com/v0.2/docs/how_to/graph_constructing/

RAGを作る時とあんまり変わらないです

まず、textSplitterで文字列の内容をchunkに分割します

 const splitter = RecursiveCharacterTextSplitter.fromLanguage("markdown", {
      chunkSize: 1000,
      chunkOverlap: 200,
    });
const chunkedDocs = await splitter.createDocuments([file]);

んで、chunkの内容自体はただの分割された文字列なのでグラフ表現にする必要があります

それをやってくれるのがllmGraphTransformer.convertToGraphDocumentsってわけです

const llmGraphTransformer = new LLMGraphTransformer({
  llm: model,
});
const graphDocuments = await llmGraphTransformer.convertToGraphDocuments(chunkedDocs);
await graph.addGraphDocuments(graphDocuments);

感想

たまに失敗します

この小規模なコードを実行するだけでもけっこう手を焼いたのでなかなかハードルが高い技術でした

最後に

株式会社ホコサキではLLMを使った開発案件を募集しています!

ご興味ありましたらぜひご相談くださいませ