🐈

ナレッジグラフでRAGを構築させてみた

2024/12/12に公開

!注意!

はじめに

本記事では、Neo4jGoogle Colab、そして OpenAI API を活用し、Wikipediaの記事情報を用いた RAG (Retrieval-Augmented Generation) 構成の環境設定から、具体的なコード例までを詳細に解説します。RAGは、LLM(大規模言語モデル)に外部知識を組み込むための人気手法であり、特定のドメイン知識に基づく質問応答などに有用です。

この記事の対象読者は以下のような方を想定しています。

  • LLMや自然言語処理分野に興味があり、外部の知識ベース(Wikipedia、データベースなど)を統合した高度な質問応答システムを作りたいエンジニア
  • Neo4jを用いたナレッジグラフ構築に興味がある技術者や学習者
  • OpenAIのAPIやLangChainなどを用いた高度な自然言語処理ワークフローを学びたい初学者

本記事を読むことで、以下のポイントが理解できます。

  • Google Colab上での環境構築(Neo4j、OpenAIキー設定、MeCab、日本語Wikipedia処理)
  • Wikipedia記事からエンティティ(人物、組織名など)抽出を行い、そのテキストを分割・構造化した上でNeo4jのグラフDBに格納する手順
  • Neo4jとLangChain、OpenAI Embeddings、Vector Indexなどを用いたハイブリッド検索
  • RAG構成で自然言語質問に対して文脈に基づいた回答を行うためのチェーン構築方法

RAGとは

RAG (Retrieval-Augmented Generation) とは、LLMから回答を生成する際に、外部データベースや文書コーパスから関連文書を検索し、その情報を文脈としてモデルに提示する手法です。これにより、LLMはより正確でアップデートされた情報に基づいて回答を生成できます。RAGは、単純にLLMのみで回答する場合に比べ、情報の正確性・網羅性を高めることが可能です。

本記事では、Wikipedia記事をNeo4jグラフに変換し、外部知識ベースとして活用します。検索部分をNeo4jの持つフルテキスト検索機能+ベクトル検索機能(OpenAIのEmbeddingsを用いた)によって実現し、LangChainの仕組みを使ってLLMとのやりとりを便利にします。

必要な環境・前提条件

  • Google Colab:ブラウザ上でPython実行環境が利用可能な無料サービス。
  • Neo4j AuraDB あるいはお手元のNeo4jサーバー:Neo4jはグラフデータベースで、ノード・エッジ構造のデータモデリングに強みを持ちます。本記事中では、Neo4jの接続設定(URI、USERNAME、PASSWORD)を事前に行っていると仮定します。
  • OpenAI APIキー:OpenAIのモデル(gpt-4-turboなど)を利用するためのAPIキー。
  • MeCab:日本語形態素解析ツール。Wikipedia記事から固有表現抽出のために利用。

以下では、Neo4j AuraOpenAI、および Google Colab上でのSecret設定 について、具体的なアカウント作成やインスタンス、APIキーの取得、ユーザー名・パスワードの確認方法、さらにColab内でのSecret登録方法を詳細に説明します。

Neo4j Auraへのアカウント・インスタンスの作り方と接続情報確認方法

Neo4j Auraとは

Neo4j Aura は、Neo4jが提供するマネージド型のクラウド版Neo4jデータベースサービスです。Web上でGUIを通じて簡単にデータベースのインスタンスを作成・管理できます。

手順

  1. アカウント作成

    • Neo4j Auraの公式ページ へアクセスします。
    • 「Start for Free」や「Sign Up」などのボタンがあるのでそれをクリックします。
    • 必要な情報(メールアドレス、パスワード、氏名など)を入力してアカウントを作成します。
    • メール確認の手順がある場合は、登録メールアドレス宛に届く確認メールのリンクをクリックしてアカウントを有効化します。
  2. インスタンスの作成

    • アカウント作成後、Neo4j AuraのWebコンソール(ダッシュボード)にログインします。
    • ダッシュボード上に「Create Instance」や「New Instance」などのボタンがあるのでクリックします。
    • インスタンス作成画面で以下のような項目を指定します。
      • Region(リージョン): 近いリージョンを選択(日本からであればAsia系リージョンなど)。
      • Database Name(データベース名): 任意の名前を付ける(例:my-neo4j-db)。
      • インスタンス作成時に「Free Tier」や有償プランが選択できます。目的に応じて選択してください(学習用であればFree Tierで十分なことが多いです)。
    • 「Create」や「Launch」ボタンを押すと、数分待機するとインスタンスが起動します。
  3. 接続情報(URI, username, password)の確認
    インスタンスが準備完了したら、Auraダッシュボード上に作成したインスタンスが表示されます。
    インスタンスを選択すると、接続用の情報が表示されます。

    • URI: bolt://... あるいは neo4j+s://... の形式で示された接続用アドレスが表示されます。
    • Username: 通常、初期ユーザー名は neo4j がデフォルトになっています。
    • Password: インスタンス作成時に自動的に生成されるか、もしくは設定段階で指定したパスワードがあります。Auraダッシュボードで「Connection Details」や「Manage」画面などを開くとパスワードが表示またはリセット可能なボタンがあるので、そこからパスワードを確認できます。
    • もしパスワードが分からなくなった場合は「Reset Password」ボタンで新たなパスワードを設定できます。
  4. 取得した情報の保管

    • 最終的に、NEO4J_URI (接続URI)、NEO4J_USERNAME (ユーザー名、通常neo4j)、NEO4J_PASSWORD (パスワード) をメモ帳や安全な場所に保管します。

OpenAIへのアカウント・APIキー作成方法と確認方法

OpenAIとは

OpenAI はGPT-4やGPT-3.5などの大規模言語モデル(LLM)を提供している企業です。APIを利用するには、OpenAIアカウントを作成し、APIキー(Secret Key)を取得します。

手順

  1. アカウント作成

    • OpenAI公式サイトへアクセスし、右上などにある「Sign Up」ボタンをクリックします。
    • メールアドレス・パスワードを入力、またはGoogle/Microsoftアカウントでシングルサインオンします。
    • 電話番号確認などの手順に従い、アカウントを有効化します。
  2. APIキーの取得

    • アカウントにログイン後、OpenAI Platformダッシュボード に移動します。
    • 左側メニューから「API Keys」を選択します。
    • 「Create new secret key」ボタンをクリックし、新しいAPIキーを発行します。
    • 発行時に表示される**Secret Key(APIキー文字列)**をメモしてください。このキーは後で再表示ができないため、このタイミングで安全な場所へ保管してください。
  3. UsernameとPasswordについて
    OpenAIのAPI利用では、通常「username/password」という概念は直接使いません。代わりにAPIキーで認証します。

    • OPENAI_API_KEY は上記で取得したSecret Keyを利用します。
    • Username/password形式の認証はOpenAI APIでは使用しないため、「username」「password」は存在しません。
    • つまり、OpenAIへの接続は専らOPENAI_API_KEYを環境変数等に設定することで行います。
  4. 取得した情報の保管

    • OPENAI_API_KEY(例:sk-xxxxx...)を安全な場所に保管します。

Google ColabでのSecret設定方法

Google Colabでは、環境変数や秘密情報(APIキーなど)を安全に扱うためにgoogle.colab.userdataモジュールや「Googleドライブのマウント」などを利用できます。また、google.colab.userdata は内部的にColabのセッションで秘密情報を扱う仕組みです。
※2023年時点では、userdataはColab Pro+など一部環境で使用可能な機能です。一般的にはColabのノートブック上で手動入力やos.environへの直接代入で管理します。

Secret登録方法の一般的なパターン

  1. Colabノートブックから環境変数を設定する方法

    import os
    os.environ["OPENAI_API_KEY"] = "sk-自分のAPIキー"
    os.environ["NEO4J_URI"] = "neo4j+s://xxxxxxxx.databases.neo4j.io"
    os.environ["NEO4J_USERNAME"] = "neo4j"
    os.environ["NEO4J_PASSWORD"] = "自分のパスワード"
    

    この方法はノートブックにAPIキーが直接記載されてしまうため、GitHub等で公開する際は漏えいに注意が必要です。

  2. Google ColabのUIで「secrets」機能を使う方法(userdata.get()を用いる方法)
    userdataを使うと、ノートブック内でuserdata.get('キー名')で秘密情報を取り出せます。

    • Colabの左側ペインの「User secrets」や「Variables」などの項目から秘密情報を設定できるUIが提供されている場合があります。
    • そのUI上で OPENAI_API_KEYNEO4J_URINEO4J_USERNAMENEO4J_PASSWORD をセットします。
    • これを行った後、以下のコードでuserdata.get('キー名')を使って値を取得できます。

    例:

    from google.colab import userdata
    
    os.environ["OPENAI_API_KEY"] = userdata.get('OPENAI_API_KEY')
    os.environ["NEO4J_URI"] = userdata.get('NEO4J_URI')
    os.environ["NEO4J_USERNAME"] = userdata.get('NEO4J_USERNAME')
    os.environ["NEO4J_PASSWORD"] = userdata.get('NEO4J_PASSWORD')
    

    この場合、実行前にColab側でユーザーシークレットを設定しておく必要があります。
    ※Colabのユーザーインタフェースは変更される可能性があるため、最新のColab UI上で「Add a secret」や「Edit secrets」等を探して下さい。

  3. Googleドライブマウントによるファイルから読み込む方法
    別の方法として、Googleドライブ上の秘匿ファイル(.envファイルなど)にAPIキーやURIを保存しておき、Colab上でGoogleドライブをマウントしてファイル読み込みする方法があります。

    from google.colab import drive
    drive.mount('/content/drive')
    
    # 例えば /content/drive/MyDrive/secrets.env に秘密情報を保存しておいた場合
    with open('/content/drive/MyDrive/secrets.env') as f:
        for line in f:
            key, value = line.strip().split('=')
            os.environ[key] = value
    

    これによりソースコード上では直接キーを書かずに済みます。ただし、secrets.envファイルを安全な場所に保管し、外部に公開しないようにします。

コード実装

環境変数の設定ブロック

import os
from google.colab import userdata

os.environ["OPENAI_API_KEY"] = userdata.get('OPENAI_API_KEY')
os.environ["NEO4J_URI"] = userdata.get('NEO4J_URI')
os.environ["NEO4J_USERNAME"] = userdata.get('NEO4J_USERNAME')
os.environ["NEO4J_PASSWORD"] = userdata.get('NEO4J_PASSWORD')

userdata.get()でGoogle Colabに事前登録した秘密情報(APIキー、Neo4jの接続情報)を環境変数に読み込んでいます。これにより、コード中でos.environを通じて各種キーやパスワードを参照可能になります。

  • OPENAI_API_KEY:OpenAIのAPIキー
  • NEO4J_URI:Neo4jデータベース接続URI
  • NEO4J_USERNAME / NEO4J_PASSWORD:Neo4j接続時の認証情報

ライブラリインストールブロック

%%capture
%pip install --upgrade --quiet  wikipedia neo4j langchain langchain-community langchain-openai langchain-experimental tiktoken yfiles_jupyter_graphs
#MeCabのインストール
!apt-get -q -y install sudo file mecab libmecab-dev mecab-ipadic-utf8 git curl python-mecab > /dev/null
!pip install mecab-python3 unidic-lite > /dev/null

必要なPythonパッケージをGoogle Colab環境にインストールします。

1. wikipedia(Pythonライブラリ)

役割:Wikipediaの記事に簡単にアクセスし、指定した記事の本文テキストを取得するためのライブラリです。
初心者向けイメージ:Wikipediaを手でブラウザで開いて記事を読む代わりに、プログラムでWikipediaを自動的に検索・取得してくれる「自動Wikipedia検索ツール」。
詳細記事(公式リポジトリ)
https://github.com/goldsmith/Wikipedia

2. neo4j(Neo4jドライバ)

役割:Neo4jデータベース(グラフデータベース)にPythonから接続し、データの読み書きやクエリ実行を可能にするドライバです。
初心者向けイメージ:ノードとエッジでつながった「ネットワーク図書館」に自由に出入りできるための「鍵」のようなもの。
詳細記事(公式ドキュメント)
https://neo4j.com/docs/api/python-driver/

3. langchain, langchain-community, langchain-openai, langchain-experimental

役割:LLM(大規模言語モデル)を扱うための強力なフレームワーク「LangChain」の周辺ツール群です。

  • langchain: LLMと外部データソース、検索機能等を組み合わせるための基本フレームワーク
  • langchain-community: LangChainのコミュニティ製エクステンション集
  • langchain-openai: OpenAIのモデル(GPT-4など)とLangChainをスムーズに連携するためのコンポーネント
  • langchain-experimental: 新機能や実験的機能が含まれるモジュール
    初心者向けイメージ:LLM(賢いAI)に「情報源を渡したり、検索させたり」するための便利な「接続アダプター&ツール箱」。
    詳細記事(公式サイト)
    https://python.langchain.com/

4. tiktoken

役割:テキストをトークン(モデルが扱いやすい単語やサブワードの単位)に分解するためのツールです。
初心者向けイメージ:「テキストを1文字ずつ細かく分解し、AIにとって消化しやすい一口サイズに切り分けてあげる包丁」のようなもの。
詳細記事(OpenAI公式ツール)
https://github.com/openai/tiktoken

5. yfiles_jupyter_graphs

役割:Jupyter Notebook上でグラフ構造データ(ノードとエッジ)を視覚的に表示・操作するためのウィジェットを提供します。
初心者向けイメージ:グラフデータを「地図のような見た目」で画面に表示するための「絵描きツール」。
詳細記事(yFiles for HTML + Jupyter)
https://www.yworks.com/products/yfiles-for-html

6. MeCab関連パッケージ

役割:日本語テキストを形態素解析する(文章を単語に区切り、その品詞を判定する)ためのツール群。
初心者向けイメージ:「長い日本語の文を、名詞や動詞といった単語単位に切り分け、その意味や役割をラベル付けしてくれる日本語専用のはさみ&分類器」。
詳細記事(MeCab公式サイト)
http://taku910.github.io/mecab/

メインコード読み込みブロック


import os
import re
import wikipedia
import MeCab
from typing import List, Tuple
from google.colab import userdata
from neo4j import GraphDatabase
from yfiles_jupyter_graphs import GraphWidget
from langchain_core.runnables import RunnableLambda, RunnableParallel, RunnablePassthrough
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.pydantic_v1 import BaseModel, Field
from langchain_core.output_parsers import StrOutputParser
from langchain.output_parsers import PydanticOutputParser
from langchain_community.graphs import Neo4jGraph
from langchain.text_splitter import TokenTextSplitter
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_experimental.graph_transformers import LLMGraphTransformer
from langchain_community.vectorstores import Neo4jVector
from langchain_community.vectorstores.neo4j_vector import remove_lucene_chars
from langchain.schema import Document

try:
    import google.colab
    from google.colab import output
    output.enable_custom_widget_manager()
except:
    pass

以下は、このコード部分でインポートしているライブラリやモジュールごとの役割を簡単にまとめたものです。
それぞれの用途をイメージしやすくするために、初心者向けの説明と公式情報へのリンクも付けています。(自分での復習用も兼ねているので冗長です。)

テキスト解析用関数定義ブロック

#MeCabを使用してテキストを形態素解析し、固有表現を抽出する関数を定義
def tokenize_and_extract_entities(text: str) -> Tuple[List[str], List[str]]:
    """
    テキストを形態素解析し、トークンと固有表現を抽出する
    """
    tagger = MeCab.Tagger()
    parsed = tagger.parse(text)

    tokens = []
    entities = []

    for line in parsed.split('\n'):
        if line == 'EOS':
            break

        parts = line.split('\t')
        if len(parts) < 2:
            continue

        surface = parts[0]
        feature = parts[1]
        features = feature.split(',')

        tokens.append(surface)

        # 固有名詞抽出ロジック
        if len(features) > 1 and features[0] == '名詞' and features[1] in ['固有名詞', '人名', '組織', '地名']:
            entities.append(surface)

    return tokens, entities

# テキスト分割関数
def split_text(text: str, max_length: int = 1000) -> List[str]:
    """
    テキストを指定長で分割
    """
    return [text[i:i+max_length] for i in range(0, len(text), max_length)]

概要

このブロックでは、MeCab(日本語形態素解析ツール)を用いてテキストから「トークン(単語)」と「固有表現(人名・地名・組織名など)」を抽出する関数、及び長いテキストを一定の長さで分割する関数を定義しています。
これにより、後続の処理で扱いやすい形にテキストを前処理します。

主な機能

  1. tokenize_and_extract_entities(text: str) -> Tuple[List[str], List[str]]
    この関数は、入力された日本語テキストをMeCabで形態素解析して、以下の2つを取得します。

    • tokens:テキストを形態素(単語)単位に分解したリスト。
    • entities:固有名詞など特定の品詞に該当する単語のリスト(人名、地名、組織名、固有名詞が対象)。

    これにより、後ほどエンティティ情報をメタデータとして利用できます。

  2. split_text(text: str, max_length: int = 1000) -> List[str]
    長いテキストをmax_length文字ごとに分割する関数です。
    例えば、max_length=1000と指定すると、テキストを1000文字ずつのブロックに分解してリストで返します。
    もしmax_lengthを500に変えると、より細かく分割され、処理は細かくなる一方、後続処理で扱うチャンク数が増えるため、計算量が増える可能性があります。

パラメータ変更の影響

  • max_lengthを大きくすると:
    チャンク(分割テキスト)あたりのサイズが大きくなり、分割数が減ります。処理は少し高速になり得ますが、1回のチャンクが大きいため、特定箇所を細かく扱いにくくなります。
  • max_lengthを小さくすると:
    より細かく分割されるため、後続の処理が細かい文脈を扱いやすくなりますが、チャンク数が増えて計算コストが増えます。

Wikipedia記事取得ブロック

article = "Fate/Grand_Orderの登場キャラクター"
# Wikipediaの日本語記事を用いる
wikipedia.set_lang("ja")
page = wikipedia.page(article, auto_suggest=False)
content = re.sub('(.+?)', '', page.content)  # ふりがな等の除去

# テキスト分割
chunks = split_text(content)

all_tokens = []
all_entities = []

for chunk in chunks:
    try:
        tokens, entities = tokenize_and_extract_entities(chunk)
        all_tokens.extend(tokens)
        all_entities.extend(entities)
    except Exception as e:
        print(f"チャンク処理中にエラーが発生しました: {e}")
        continue

# トークンとエンティティを元にDocumentオブジェクト作成
processed_content = " ".join(all_tokens)
metadata = {"entities": list(set(all_entities))}

raw_documents = [Document(page_content=processed_content, metadata=metadata)]

# テキストをLLMで扱いやすいように分割
text_splitter = TokenTextSplitter(chunk_size=1024, chunk_overlap=64)
documents = text_splitter.split_documents(raw_documents)

概要

Wikipediaから指定の記事(例:「Fate/Grand_Orderの登場キャラクター」)の内容を取得し、テキストを前処理(不要情報の削除など)した上で、先ほどの「テキスト解析用関数」を用いてトークン化・固有名詞抽出を行います。

主な機能

  1. wikipedia.set_lang("ja")
    Wikipedia APIの言語を日本語に設定します。英語の記事を扱いたい場合は"en"に変更するなど言語を切り替え可能です。

  2. page = wikipedia.page(article, auto_suggest=False)
    指定した記事名articleでWikipediaから記事を取得します。auto_suggest=Falseを付けることで自動補完提案を無効化しています。
    記事名を別の文字列にすれば異なるWikipediaページを取得できます。

  3. テキストクリーニング(re.sub('(.+?)', '', page.content)
    re.subを使い、丸括弧内のルビ(ふりがな)など不要情報を削除して、よりクリーンなテキストに整えます。

  4. split_texttokenize_and_extract_entitiesの適用
    Wikipedia記事本文をsplit_textで分割し、各チャンクに対してtokenize_and_extract_entitiesを実行します。
    これにより、分割されたテキストごとにtokensとentitiesを取得し、最後にそれらを統合しています。

  5. Documentオブジェクトの作成
    LangChainではテキストをDocumentクラスで扱うことが多いです。取得したテキスト(トークン結合後)と、抽出したエンティティをメタデータに加えてDocumentを作成します。

  6. TokenTextSplitterによる再分割
    TokenTextSplitterはトークンベースでの分割を行うため、さらにモデルに適した形でドキュメントを分解します。
    chunk_sizechunk_overlapを変えることで、モデルが扱いやすいテキストブロック単位を調整できます。

パラメータ変更の影響

  • chunk_sizechunk_overlapの値を変えると、生成される分割文書の大きさや重なりが変わります。
    例えば chunk_size=1024chunk_overlap=64chunk_size=512chunk_overlap=32 に変更すると、より小さなブロックで分割され、処理は細かくなりますがチャンク数が増えます。

ドキュメントをグラフ化しNeo4jへ格納するブロック

llm=ChatOpenAI(temperature=0, model_name="gpt-4-turbo")
llm_transformer = LLMGraphTransformer(llm=llm)

#ドキュメントからグラフドキュメントへ変換(一部サンプル)
graph_documents = llm_transformer.convert_to_graph_documents(documents[0:10])

graph = Neo4jGraph()

#Neo4jへドキュメントを追加
graph.add_graph_documents(
    graph_documents,
    baseEntityLabel=True,
    include_source=True
)

概要

このブロックでは、テキストで表現されたDocumentをLLM(大規模言語モデル)の助けを借りて「グラフ(ノードとエッジ)」の形へと変換し、Neo4jデータベースに格納します。

主な機能

  1. llm_transformer = LLMGraphTransformer(llm=llm)
    LLMGraphTransformerは、LLMを活用してテキストドキュメントからグラフ構造(概念の関連性など)を抽出するツールです。

  2. graph_documents = llm_transformer.convert_to_graph_documents(documents[0:10])
    documents[0:10]は最初の10個のドキュメントだけをグラフ化します。数値を増やすとより多くの文書をグラフ化しますが、処理負荷やデータベースサイズも大きくなります。

  3. graph = Neo4jGraph()
    Neo4jとの接続を抽象化したNeo4jGraphオブジェクトを作成します。

  4. graph.add_graph_documents(...)
    先ほど生成したグラフドキュメントを実際にNeo4jデータベースへ登録します。

    • baseEntityLabel=True:ドキュメントに基づくエンティティを基本的なラベルとして付与。
    • include_source=True:元テキストソースへのリンクなどを保持する設定です。

パラメータ変更の影響

  • documents[0:10]の範囲を[0:100]などに拡大すると、より多くのデータをグラフ化できますが、その分処理時間やNeo4jのリソース消費が増えます。

グラフ可視化用関数

#指定されたCypherクエリ結果を可視化
default_cypher = "MATCH (s)-[r:!MENTIONS]->(t) RETURN s,r,t LIMIT 100"

def showGraph(cypher: str = default_cypher):
    driver = GraphDatabase.driver(
        uri = os.environ["NEO4J_URI"],
        auth = (os.environ["NEO4J_USERNAME"],
                os.environ["NEO4J_PASSWORD"]))
    session = driver.session()

    widget = GraphWidget(graph = session.run(cypher).graph())
    widget.node_label_mapping = 'id'
    return widget

showGraph()

概要

Neo4j上にあるデータをJupyter Notebook上でインタラクティブに可視化するための関数です。
グラフデータベースに格納されたノードとエッジを視覚的に確認でき、構造理解が容易になります。

主な機能

  1. showGraph(cypher: str = default_cypher)
    引数で受け取ったCypherクエリを実行し、その結果得られたサブグラフをGraphWidgetで可視化します。
    default_cypher"MATCH (s)-[r:!MENTIONS]->(t) RETURN s,r,t LIMIT 100"というクエリで、!MENTIONSリレーションを持つノードを最大100件表示しています。

パラメータ変更の影響

  • LIMIT 100LIMIT 10に変えると、表示件数が減るため描画が軽くなりますが、表示されるグラフが小規模になります。
  • !MENTIONS以外のリレーションを指定すれば、別の関係性を可視化できます。

ベクトルインデックス作成ブロック

vector_index = Neo4jVector.from_existing_graph(
    OpenAIEmbeddings(),  
    search_type="hybrid",
    node_label="Document",
    text_node_properties=["text"],
    embedding_node_property="embedding"
)

graph.query("CREATE FULLTEXT INDEX entity IF NOT EXISTS FOR (e:__Entity__) ON EACH [e.id]")

概要

テキストをOpenAIの埋め込みモデルでベクトル化し、Neo4jにベクトルインデックスを構築することで、キーワード検索+ベクトル検索(意味的類似度検索)のハイブリッド検索が可能になります。

主な機能

  1. Neo4jVector.from_existing_graph(...)
    この関数でNeo4j内のドキュメント(node_label="Document")からテキストを取得し、OpenAIEmbeddings()モデルでベクトル変換します。そのベクトルをNeo4j上に格納することで、後で意味検索が可能になります。

  2. search_type="hybrid"
    ベクトル検索とキーワード検索を組み合わせるモードです。
    search_typesimilarityにすると純粋な類似度ベクトル検索になりますが、言葉そのもののマッチングが弱くなる場合があります。

  3. graph.query("CREATE FULLTEXT INDEX entity ...")
    エンティティ用のフルテキストインデックスを作成し、より柔軟なテキスト検索が可能にします。

パラメータ変更の影響

  • search_typehybridからsimilaritykeywordに変えると、検索スタイルが変わります。
  • ラベルやプロパティ名を変えると、別の種類のデータを対象にできます。

エンティティ抽出・検索クエリ生成ブロック

class Entities(BaseModel):
    names: List[str] = Field(..., description="テキスト内に出現する全ての人物、組織エンティティ")

prompt = ChatPromptTemplate.from_messages([
    ("system", "You are extracting organization and person entities from the text. Output should be in JSON format with a 'names' key containing a list of extracted entities."),
    ("human", "Extract entities from the following input: {question}")
])

parser = PydanticOutputParser(pydantic_object=Entities)
entity_chain = prompt | llm | parser

def generate_full_text_query(input: str) -> str:
    tagger = MeCab.Tagger()
    nodes = tagger.parseToNode(input)

    important_words = []
    while nodes:
        if nodes.feature.split(',')[0] in ['名詞', '動詞', '形容詞']:
            important_words.append(nodes.surface)
        nodes = nodes.next

    if not important_words:
        return input

    return ' OR '.join(f'"{word}"' for word in important_words)

def structured_retriever(question: str) -> str:
    try:
        entities = entity_chain.invoke({"question": question})
        if not entities.names:
            return "質問に関連するエンティティが見つかりませんでした。"

        result = ""
        for entity in entities.names:
            query = generate_full_text_query(entity)
            if query:
                try:
                    response = graph.query(
                        """CALL db.index.fulltext.queryNodes('entity', $query, {limit:20})
                        YIELD node,score
                        CALL {
                          WITH node
                          MATCH (node)-[r:!MENTIONS]->(neighbor)
                          RETURN node.id + ' - ' + type(r) + ' -> ' + neighbor.id AS output
                          UNION ALL
                          WITH node
                          MATCH (node)<-[r:!MENTIONS]-(neighbor)
                          RETURN neighbor.id + ' - ' + type(r) + ' -> ' +  node.id AS output
                        }
                        RETURN output LIMIT 1000
                        """,
                        {"query": query},
                    )
                    result += "\n".join([el['output'] for el in response])
                except Exception as e:
                    print(f"クエリ実行中にエラーが発生しました: {e}")

        return result if result else "関連情報が見つかりませんでした。"
    except Exception as e:
        print(f"エンティティ抽出中にエラーが発生しました: {e}")
        return "エンティティの抽出中にエラーが発生しました。"

エンティティ抽出概要

LLM(大規模言語モデル)を活用して、任意のテキストからエンティティ(人物名や組織名)を抽出するチェーンを用意します。
これにより、ユーザー質問から関連するエンティティを抽出し、そのエンティティに関連する情報をNeo4jから引き出すことができます。

主な機能

  1. Entities Pydanticモデル
    抽出結果をJSON形式でnamesというキーの下にリストでまとめる出力形式を定義します。

  2. prompt = ChatPromptTemplate.from_messages(...)
    システム・ユーザープロンプトを定義して、LLMに対して「与えられたテキストからエンティティを抽出し、JSONで出力して」という指示を与えます。

  3. entity_chain = prompt | llm | parser
    このパイプライン(チェーン)で、プロンプト→LLM→パーサーと連結し、最終的にEntities形式で結果を取得します。

パラメータ変更の影響

  • JSON出力形式を変えると、抽出結果のフォーマットも変わりますが、それに合わせてコード側でのパース処理や後続処理も変更する必要があります。

検索クエリ概要

エンティティ抽出結果を用いてNeo4jのフルテキスト検索クエリを生成・実行する部分です。
これにより、質問中に出てきた固有名詞に関連するノードやリレーションをNeo4jから検索します。

主な機能

  1. generate_full_text_query
    MeCabで入力文から重要な単語(名詞、動詞、形容詞など)を抽出し、その単語群を"OR"で繋いだクエリを生成します。
    これにより、フルテキストインデックスに対して柔軟な検索を行えます。

  2. structured_retriever(question: str)
    質問テキストからエンティティを抽出し、そこからクエリを生成し、Neo4jでのフルテキスト検索を実行します。

    • フルテキスト検索にヒットしたノードと関連する!MENTIONSリレーションを取得して、結果として返します。

パラメータ変更の影響

  • 重要な単語抽出条件(品詞条件)を変更すると、より広範な単語が検索に使われるか、逆に絞り込まれるかします。
  • limit:20limit:1000など検索結果の上限を変更すると、取得するデータの範囲や量が変わります。

結果統合と最終的なQAチェーン

def retriever(question: str):
    print(f"Search query: {question}")
    structured_data = structured_retriever(question)
    unstructured_data = [el.page_content for el in vector_index.similarity_search(question)]
    final_data = f"""Structured data:
    {structured_data}
    Unstructured data:
    {"#Document ". join(unstructured_data)}
    """
    return final_data

_search_query = RunnableLambda(lambda x: x["question"])

template = """あなたは優秀なAIです。下記のコンテキストを利用してユーザーの質問に丁寧に答えてください。
必ず文脈からわかる情報のみを使用して回答を生成してください。
コンテキストに関連情報がない場合は、その旨を述べた上で一般的な回答を提供してください。

コンテキスト:
{context}

ユーザーの質問: {question}"""

prompt = ChatPromptTemplate.from_template(template)

chain = (
    RunnableParallel(
        {
            "context": _search_query | retriever,
            "question": RunnablePassthrough(),
        }
    )
    | prompt
    | llm
    | StrOutputParser()
)

概要

最終的にユーザーの質問に対し、

  1. エンティティ抽出による構造的情報検索(Neo4jフルテキスト検索結果)
  2. ベクトルインデックスを使った意味的類似度検索(非構造的ドキュメント)
    を組み合わせたコンテキストを生成し、それをLLMに渡して回答を生成する部分です。

主な機能

  1. retriever(question: str)関数

    • structured_retriever(question)で構造的検索結果を取得
    • vector_index.similarity_search(question)で類似ドキュメントを取得
    • 両方をまとめたfinal_dataを作成し、これを回答生成のコンテキストとして返します。
  2. _search_query = RunnableLambda(lambda x: x["question"])
    質問を受け取って、そのまま検索クエリとして渡す単純なランダム関数です。

  3. templateprompt
    システムに「指定コンテキストのみから回答し、不足する場合は一般情報を返す」などの方針を示すプロンプトです。

  4. chain = (...)
    LangChainのRunnableParallelなどを用いてcontextquestionを並行して処理し、promptに埋め込んでLLMから最終回答を得るチェーンです。

パラメータ変更の影響

  • テンプレート内の指示文を変えると、回答スタイルやポリシーを自由に調整できます。
  • similarity_searchの際のヒット数や類似度スコアの閾値を変更すれば、取得するコンテキスト量を調節できます。

実行例

chain.invoke({"question": "マシュの好きな人は?"})
chain.invoke({"question":"藤丸は何故マスターになったのですか?"})
chain.invoke({"question": "ランスロットの息子と取引した人は誰?"})
chain.invoke({"question": "マーリンについて教えてください"})
chain.invoke({"question": "日本で一番高い山は?"})
#申し訳ありませんが、提供された情報からは日本で一番高い山に関する情報は得られませんでした。一般的に知られている情報として、日本で一番高い山は富士山です。

chain.invokeでユーザー質問を投げると、RAGパイプラインが動作し、Wikipedia由来のコンテキストから回答を生成します。
最後の例「日本で一番高い山は?」には該当記事のコンテキストが無いため、一般的回答として「富士山」が提示される流れが確認できます。

それぞれのRAG条件での比較

ナレッジグラフ

条件1
graph_documents = llm_transformer.convert_to_graph_documents(documents[0:10])
default_cypher = "MATCH (s)-[r:!MENTIONS]->(t) RETURN s,r,t LIMIT 100"
RETURN output LIMIT 1000

条件2
graph_documents = llm_transformer.convert_to_graph_documents(documents[0:100])
default_cypher = "MATCH (s)-[r:!MENTIONS]->(t) RETURN s,r,t LIMIT 100"
RETURN output LIMIT 1000

条件3
graph_documents = llm_transformer.convert_to_graph_documents(documents[0:100])
default_cypher = "MATCH (s)-[r:!MENTIONS]->(t) RETURN s,r,t LIMIT 1000"
RETURN output LIMIT 1000

条件4
graph_documents = llm_transformer.convert_to_graph_documents(documents[0:100])
default_cypher = "MATCH (s)-[r:!MENTIONS]->(t) RETURN s,r,t LIMIT 1000"
RETURN output LIMIT 3000

やはり、条件が大きくするほど、様々な関係性が視認できます。ナレッジグラフを作るだけなら、条件は大きければ大きいほど良いと言えるでしょう。
今回はRAGが主題なので、このグラフを用いて解答精度がどの程度かを判別していきます。

質問1

  • 条件1
    • マシュ・キリエライトは主人公に好意を抱いており、異性として積極的なアプローチを行うサーヴァントに対して嫉妬を見せることもあるとの記述があります。したがって、マシュの好きな人は主人公です。
  • 条件2
    • マシュ・キリエライトは主人公に好意を抱いており、異性として積極的なアプローチを行うサーヴァントに対して嫉妬を見せることもあるとのことです。したがって、マシュの好きな人は主人公です。
  • 条件3
    • マシュは主人公に好意を抱いており、異性として積極的なアプローチを行うサーヴァントに対して嫉妬を見せることもあると記述されています。したがって、マシュの好きな人は主人公です。
  • 条件4
    • マシュは主人公に好意を抱いており、異性として積極的なアプローチを行うサーヴァントに対して嫉妬を見せることもあるようです。したがって、マシュの好きな人は主人公です。
      そこまで具体的な変化は見て取れないですね。この辺は、最初の条件から回答が変わっていないです。

質問2

  • 条件1
    • 藤丸立香は、『Fate/Grand Order』において、48人のマスター候補の中の10人の一般人枠のひとりとしてスカウトされました。彼は魔術回路を起動したことさえない完全な「素人」であり、配属前の訓練も数時間しか受けていませんでした。しかし、カルデアへの破壊工作による爆発を偶然にも免れ、ただ一人の活動可能なマスター候補となり、その結果、マシュと契約を結び共に人類史を巡る旅を始めることになりました。
  • 条件2
    • 藤丸立香は、『Fate/Grand Order』において、カルデアの48人のマスター候補の一人としてスカウトされました。彼は完全な「素人」であり、魔術回路を起動したことさえなく、配属前の訓練も数時間しか受けていませんでした。しかし、カルデアへ仕掛けられた破壊工作を偶然にも免れたため、ただ一人の活動可能なマスター候補となり、マシュと契約を結び共に人類史を巡る旅を始めることになりました。
  • 条件3
    • 藤丸立香は、『Fate/Grand Order』において、カルデアの48人のマスター候補の一人としてスカウトされました。彼は完全な「素人」であり、魔術回路を起動したことさえなかったにも関わらず、レイシフト実験のために選ばれました。レフが「2015年で霊子ダイブが可能な適性者全てを集めた」と述べていることから、彼の選出は数合わせとされていましたが、実際には霊子ダイブが可能な適性を持っていたためです。また、レイシフト実験の際に偶然にもカルデアへ仕掛けられた破壊工作を免れ、ただ一人の活動可能なマスター候補となり、マシュと契約を結び共に人類史を巡る旅を始めることになりました。
  • 条件4
    • 藤丸立香は、『Fate/Grand Order』において、カルデアの48人のマスター候補の中の一般人枠の一人としてスカウトされました。彼は魔術回路を起動したことさえない完全な「素人」であり、配属前の訓練も数時間しか受けていませんでした。しかし、カルデアのレイシフト実験のための説明会の際に居眠りしてしまい、その結果レイシフト実験から外れることになりました。それによってカルデアへ仕掛けられた破壊工作を偶然にも免れ、ただ一人の活動可能なマスター候補となり、マシュと契約を結び共に人類史を巡る旅を始めることになりました。
      全体的に正しい記述です。ここは、回答のブレだけだと思います。

質問3

  • 条件
    • ランスロットの息子であるギャラハッドと取引を交わしたのは、マシュ・キリエライトです。彼女はギャラハッドとの取引により「デミ・サーヴァント」となり、主人公のサーヴァントとして契約を結びました。ランスロットの息子であるギャラハッドは、マシュ・キリエライトと取引を交わし、彼女がデミ・サーヴァントとなるために自身の能力を引き渡しました。
  • 条件2
    • ランスロットの息子であるギャラハッドと取引を交わしたのは、デミ・サーヴァントとなった人物です。ギャラハッドは、『Fate/Grand Order』においてマシュ・キリエライトの中に宿る英霊です。藤丸立香はこのゲームの主人公で、マシュと契約を結び共に戦うパートナーです。ギャラハッド自体と藤丸立香の直接的な関係は少ないですが、ギャラハッドがマシュの力の源であるため、間接的に藤丸の戦闘能力を支える重要な役割を果たしています。特に、ギャラハッドはマシュの生命維持を優先しており、彼女が致命傷を負った際にはその力を発揮して彼女を救っています。これにより、藤丸とマシュは共に多くの戦いを乗り越えることができました。
  • 条件3
    • ランスロットの息子、ギャラハッドと取引を交わしたのは、デミ・サーヴァントとなるために人間とサーヴァントの融合体となった主人公のサーヴァントです。
  • 条件4
    • ギャラハットは、『Fate/Grand Order』においてマシュ・キリエライトの中の英霊として存在しています。マシュはゲームの主要キャラクターであり、彼女の契約者である藤丸立香と共に物語を進めます。したがって、ギャラハットと藤丸の関係は間接的ですが、ギャラハットがマシュの能力に影響を与えることで、藤丸との冒険において重要な役割を果たしています。
      条件3の回答が一番短くなっています。1と4が一番正しい回答です。ナレッジグラフを作成する際に、よく見ると無駄な記述もたくさんあるので、その点が回答の精度を下げているのかもしれません。単純なグラフの方が正しい回答をしている点は面白いですね。

質問4

  • 条件1
    • コンテキストにはマーリンについての直接的な詳細は記載されていませんが、フォウの正体に関連する部分でマーリンの名前が出ています。フォウはマーリンによってアヴァロンの塔の上から放り出されたとされており、マーリンはフォウの本当の飼い主であるとされています。しかし、フォウはマーリンを飼い主とは思っておらず、よく罵倒しているとのことです。これ以外の情報はコンテキストからは読み取れませんので、マーリンについての詳細な情報は他の資料を参照する必要があります。
  • 条件2
    • マーリンについての具体的な情報は提供されたコンテキストには含まれていません。しかし、一般的にマーリンはアーサー王伝説において重要な魔術師であり、アーサー王の助言者として知られています。彼は多くの物語で賢く、強力な魔法の力を持つキャラクターとして描かれています。もし、特定の作品やコンテキストにおけるマーリンの役割や特性についての質問があれば、もう少し具体的な情報を提供していただけると、より詳細な回答が可能です。
  • 条件3
    • コンテキストにはマーリンについての直接的な記述は見当たりませんが、一般的にマーリンはアーサー王伝説に登場する魔術師であり、アーサー王の育ての親であり助言者です。彼は多くの物語で賢く、強力な魔法を使うキャラクターとして描かれています。また、予言者としての役割も持ち、アーサー王の運命に大きく関わる人物とされています。
  • 条件4
    • コンテキストにはマーリンについての直接的な詳細は記載されていませんが、彼に関連する情報として、彼が言及されている部分があります。マーリンは、魔術王として知られる人物の死によって神秘の衰退が加速したと述べています。これはマーリンが魔術に関して深い知識を持つキャラクターであることを示唆しています。また、彼は歴史的な背景や魔術の影響についての洞察も持っているようです。しかし、その他の具体的な情報はコンテキストからは読み取れませんので、マーリンについての詳細な情報は他の資料を参照する必要があります。
      詳しく回答されていない部分もどこまでグラフ化されているか見たくて質問をしました。2,3については、記述がなくなっています。関係性などの保管が足りないという見方もできますが、やはりグラフの精度や読み取り上限によって回答にブレが出ると思っていいと思います。一番関係性が示されてかつ、読み取り条件が高い条件4は詳しく回答されています。大規模になるほど読み取りの上限を上げることが重要ですね。

質問5

  • 条件1
    • コンテキストには日本の山に関する情報は含まれていませんが、一般的な知識として、日本で一番高い山は富士山です。富士山の標高は約3,776メートルです。
  • 条件2
    • コンテキストには日本の一番高い山に関する情報は含まれていませんが、一般的な知識として、日本で一番高い山は富士山です。富士山の標高は約3,776メートルです。
  • 条件3
    • 日本で一番高い山は富士山です。
  • 条件4
    • 日本で一番高い山は富士山です。
      おまけです。グラフにない状態の制度です。これはChatGPTの知識次第ですね。なぜか条件3・4が端的な回答なのが個人的にツボです。

最後に

本当は自分の環境内で、サーバーを立ててグラフ作成から読み取り、回答までできる状態にしたかったのですが、失敗し続けました。
Neo4jは様々な機能がありますが、重要なところは課金要素です。また、サーバーを建てる系のサービスも触りましたが、boltのURLとの相性が悪く、課金になることが多かったです。
もう少しだけ無料で遊んでナレッジグラフを学びたいと思っています。

Discussion