パソナについて
記事検索

【GraphRAG】langchain x Ollama x Neo4jを使って、グラフデータベースを活用したRAGを実装してみる

【GraphRAG】langchain x Ollama x Neo4jを使って、グラフデータベースを活用したRAGを実装してみる記事です。
この記事はQiita パソナX-TECH Advent Calendar 2024に記載されている内容と同様の内容です。

【GraphRAG】langchain x Ollama x Neo4jを使って、グラフデータベースを活用したRAGを実装してみる

【GraphRAG】langchain x Ollama x Neo4jを使って、グラフデータベースを活用したRAGを実装してみる記事です。
この記事はQiita パソナX-TECH Advent Calendar 2024に記載されている内容と同様の内容です。

知識・情報

2025/02/14 UP

はじめに

普段はLLMエンジニア的な業務を多くやっているのですが、RAGに関する知見をより深めたいと思い、自分の中で描いていたRAGを実装してみることにしました。


実行環境は以下です。
mac mini M2 RAM:16GB
rye=0.34.0(uvを使用)
jupyter notebook(python=3.11.9)

やりたいこと

sample.png
※本当は画像の要約を含めたマルチモーダルRAGの実装をしてみたかったのですが、Ollamaで利用できる画像対応のモデルであるllavaでは日本語の性能がイマイチだったため、今回は見送りました..。ChatGPTなどのLLMを利用すれば解決できると思います。


(やりたいことの経緯を少し)
RAGにおいて、検索対象となるリソース情報はチャンク分割する必要があります。
このとき、チャンク分割によって文章の前後の情報が失われる可能性があります。例えば、チャンク分割を実施するサイズを200文字として、情報源となり得そうなリソースが500文字くらいあったとします。このとき、100文字程度の情報が落ちてしまう可能性があります。具体的には、この100文字にせっかく重要な情報が含まれていたとしても、チャンク分割されているテキストの残る100文字が無関係すぎて検索に引っかからない可能性があります。
こういう情報落ちを減らすためにどうしようか考えていたところ、グラフデータベースという存在を知ったので、とりあえずやってみることにしました。

PDFデータを分解する

今回使用するサンプルのPDFデータは「安全なウェブサイトの作り方 改訂第6版」です。

まずは必要なライブラリや変数を定義します。

01_pdfファイルを分解する.ipynb
 
import base64
from collections import defaultdict
import io
import os
import pickle
import re

from PIL import Image
from tqdm.notebook import tqdm
from unstructured.partition.pdf import partition_pdf


# 定数定義
DATA_PAR_PATH = os.path.join('..','..','data')
INPUT_DATA_PATH = os.path.join(DATA_PAR_PATH,'安全なウェブサイトの作り方.pdf')
OUTPUT_DATA_PATH = os.path.join(DATA_PAR_PATH,'output.pkl')

次に、unstructuredライブラリを用いて、PDFファイルを分解します。
実行結果としてはテキストのOCR結果と、画像(図と表)のテキストOCR結果および画像データが取得できます。
画像のテキストOCR結果はなかなか精度が良くない点と、画像データの端っこが若干切り取られてしまう可能性がある点に注意が必要です。

(余談)
PDFファイルから画像を綺麗に抽出するのであればpymupdf等が候補にありましたが、pymupdf等のライブラリはいずれも画像のみを抽出することが可能なライブラリでした。
つまり画像のみを抽出してしまうということは、その画像がどのテキスト間に出てきたのかという脈略が失われてしまいます。一方でunstructuredであれば、ページの上から順番にテキストと画像をそれぞれ抽出してくれるため、脈略が失われないという大きなメリットがあります。
テキスト単体と画像単体とで、それぞれにRAGを実施してみても面白そうですが、脈略を保ちたいという今回の経緯とは異なるので、今回pymupdfは見送りました。

01_pdfファイルを分解する.ipynb
 
%%time

raw_pdf_elements = partition_pdf(
    languages=['jpn'],
    filename=INPUT_DATA_PATH,
    infer_table_structure=True,
    strategy='hi_res',
    extract_images_in_pdf=True,
    extract_image_block_types=['Image', 'Table'],
    extract_image_block_to_payload=True
)

PDFファイルをテキストと画像に分解できたら、それらの情報で必要な情報を抽出しておきます。
画像であればカテゴリ名と画像データ(=バイナリ形式)、テキストであればカテゴリ名とテキスト原文、そして詳細なカテゴリの3つを抽出しています(下コードの次で説明)。

01_pdfファイルを分解する.ipynb
 
res = defaultdict(list)

for elem in tqdm(raw_pdf_elements):
    page_no = elem.metadata.page_number
    cat = elem.category
    print(f"{page_no = }")
    
    if cat in ['Image', 'Table']:
        image_base64 = elem.metadata.image_base64
        decoded_image = base64.b64decode(image_base64)
        binary_image = io.BytesIO(decoded_image)
        image = Image.open(binary_image)
        display(image)

        res[page_no].append(
            {
                'category': 'image',
                'data': decoded_image  # バイナリ形式で出力させる
            }
        )
    else:
        text = elem.text
        cleaned_text = text.replace(' ', '')

        # URLは除去
        url_pattern = 'https?://[\w/:%#\$&\?\(\)~\.=\+\-]+'
        text_without_url = re.sub(url_pattern, '', cleaned_text)
        
        print(text_without_url)

        res[page_no].append(
            {
                'category': 'text',
                'detail': cat,
                'data': text_without_url
            }
        )

詳細なカテゴリという曖昧な表現で済ませてしまいましたが、今回得られたテキストのカテゴリは以下のようになっています。

 
defaultdict(int,
            {'Title': 512,
             'UncategorizedText': 116,
             'FigureCaption': 15,
             'NarrativeText': 629,
             'Header': 100,
             'Footer': 53,
             'ListItem': 118})

テキストの中でもこれだけの種類に分類してくれるのがunstructuredの特徴です。正確な情報はわからないですが、おそらくOCR時のテキストサイズやフォント等で判別してるのかと思っています。
この得られたテキストの詳細なカテゴリの中でも、今回はHeaderカテゴリを後々トピックとして使います。


分解したPDFファイルの情報は、pickle形式のファイルとして出力しておきます。
pickle形式で出力せずに以降のRAGを実装する作業を実施しても全く問題ないですが、PDFファイルを分解する作業に僕の場合は約14分かかりました。
そのため途中で作業を中断したり、notebookのカーネルがプツンと落ちてしまうと、このファイルを再び分解することになるので、それを避けるために一旦出力しています。

01_pdfファイルを分解する.ipynb
 
with open(OUTPUT_DATA_PATH, 'wb') as wf:
    pickle.dump(res, wf)

Ollamaの設定

今回Ollamaを使うにあたって、使用するPDFファイルが日本語である点を留意する必要があります。
Ollamaを用いてローカル環境で使えるLLMには限りがあり、日本語に強いモデルは見当たりません。そのため、今回はElyzaを使うことにします。
こちらの丁寧な解説記事を参考にして、OllamaでElyza:8bを使えるように設定を行います。

  1. Elyza:8bのモデル(=拡張子が.ggufのファイルのみ)をダウンロードします
  2. Modelfileファイルを新規作成して、後述の内容をModelfileに記述します
  3. ollama create elyza:8b -f Modelfileを実行します
  4. ollama listと実行して → elyza:8bと表示されれば設定完了です
Modelfile
 
FROM [ダウンロードしたElyza-8Bのパス名]
TEMPLATE "{{ if .System }}<|start_header_id|>system<|end_header_id|>

{{ .System }}<|eot_id|>{{ end }}{{ if .Prompt }}<|start_header_id|>user<|end_header_id|>

{{ .Prompt }}<|eot_id|>{{ end }}<|start_header_id|>assistant<|end_header_id|>

{{ .Response }}<|eot_id|>"
PARAMETER stop <|start_header_id|>
PARAMETER stop <|end_header_id|>
PARAMETER stop <|eot_id|>
PARAMETER num_keep 24

ここで設定したOllamaのElyza:8bは、埋め込み時と回答生成時に使用します。

テキスト情報の埋め込み

先ほどの作業とは別のnotebookを使用しています。

02_RAGを実装する.ipynb
 
from collections import defaultdict
import io
import os
import pickle

from langchain_community.vectorstores.neo4j_vector import Neo4jVector
from langchain_ollama import OllamaEmbeddings, ChatOllama
from PIL import Image
from tqdm.notebook import tqdm

import settings as sets


# 定数定義
DATA_PAR_PATH = os.path.join('..','..','data')
INPUT_DATA_PATH = os.path.join(DATA_PAR_PATH,'output.pkl')

NEO4J_URI: str = sets.NEO4J_URI
NEO4J_USER: str = sets.NEO4J_USER
NEO4J_PASSWORD: str = sets.NEO4J_PASSWORD

MODEL_NAME = 'elyza:8b'
CHUNK_SIZE = 500

import settings as setsでは、Neo4jのインスタンスへ接続するために必要な変数を取得するようにしています。

settings.ipynb
 
import os

from dotenv import load_dotenv


env_file_path: str = os.path.join('.','.env')
load_dotenv(env_file_path)


NEO4J_URI: str = os.environ['NEO4J_URI']
NEO4J_USER: str = os.environ['NEO4J_USER']
NEO4J_PASSWORD: str = os.environ['NEO4J_PASSWORD']

先ほど出力したPDFファイルの情報を読み込み、軽く中身を確認します。

02_RAGを実装する.ipynb
 
with open(INPUT_DATA_PATH, 'rb') as rf:
    elements = pickle.load(rf)
02_RAGを実装する.ipynb
 
# データを確認してみる
page_no = 1
for elem in elements[page_no]:
    cat = elem['category']
    data = elem['data']
    print(cat)
    
    if cat == 'image':
        binary_image = io.BytesIO(data)
        image = Image.open(binary_image)
        display(image)
    else:
        print(data)




先ほど設定したOllamaを使って、取得してきたテキスト情報を埋め込みます。

02_RAGを実装する.ipynb
 
embeddings = OllamaEmbeddings(model=MODEL_NAME)
02_RAGを実装する.ipynb
 
total = len(elements.keys())
embed_info = []
text = ''
reference_text = ''

def add_embed_info(text):
    text = text.rstrip('\n')
    embed_text = embeddings.embed_query(text)  # str -> vector(dim=4096)
    embed_info.append(
        {
            'text': text,
            'reference_text': reference_text,
            'embedding': embed_text
        }
    )

for page_no in tqdm(elements.keys(), total=total):    
    for elem in elements[page_no]:
        cat = elem['category']
        data = elem['data']
        
        if cat == 'text':
            detail_cat = elem['detail']
            
            if detail_cat == 'Header':
                if len(text) == 0:
                    continue
                
                add_embed_info(text)
                
                text = data
                reference_text = data
            elif len(text) < CHUNK_SIZE:
                text += data + '\n'
            else:
                add_embed_info(text)
                
                text = data

if len(text) > 0:
    add_embed_info(text)

ここでの処理を簡単に説明すると、

  • 指定したチャンクサイズ(=500)を超えるまでは、テキストを結合
  • チャンクサイズを上回ると、その手前までのテキストを埋め込む
  • あるいは、トピックが切り替わるタイミングで、その手前までのテキストを埋め込む
    • トピックは、詳細なカテゴリで出てきたHeaderをトピックとしています

軽くデータを確認してみます(とても出力が長くなるので、出力結果はカット)。
このデータの中身は、テキストトピックテキストベクトルの3つです。

02_RAGを実装する.ipynb
# データの確認
embed_info[1]

Neo4jへアップロード

02_RAGを実装する.ipynb
 
%%time

text_embeddings = [(e['text'], e['embedding']) for e in embed_info]
metadatas = [{'reference_text': e['reference_text']} for e in embed_info]

graph_db = Neo4jVector.from_embeddings(
    text_embeddings=text_embeddings,
    embedding=embeddings,
    metadatas=metadatas,
    url=NEO4J_URI,
    username=NEO4J_USER,
    password=NEO4J_PASSWORD
)

Neo4jへデータをアップロードできたことを確認するために、Neo4jのインスタンスに移動して確認します。

 
MATCH (n) RETURN n

image.png
細かいピンクの点(=ノード)1つ1つが、アップロードしたデータになります。これだと流石に見づらいので、拡大してみます。
image.png
拡大してみると、トピック名がノード内に記述されています。適当なノードを1つクリックしてみると、そのノード内にあるデータ(=プロパティ)が確認できます。<id>idといった情報は、Neo4j側が付与したデータになります。

現状、このノード間には特に繋がりがありません。念のため、このノード間にリレーションが張られているかを確認してみます。

 
MATCH (n)-[r]-(m) RETURN n,r,m

image.png
ノード間で繋がりがないことを確認できました。


PDFファイルを分解して得られたデータのアップロードができたので、次にトピックのデータをアップロードします。

02_RAGを実装する.ipynb
 
headers = set([e['reference_text'] for e in embed_info])
headers

以下のような出力が得られると思います。
空文字が含まれていますが、これはシンプルにミスりました..。空文字がトピックとして紐づいているデータはPDFファイルの冒頭一部だけなので、今回は大目に見ました。

 
{'',
 '1.1SQLインジェクション',
 '1.2OSコマンド・インジェクション',
 '1.3パス名パラメータの未チェック/ディレクトリラバーサル',
 '1.4セッション管理の不備',
 '1.5クロスサイト・スクリプティング',
 '1.6CSRF',
 '1.7HTTPヘッダ・インジェクション',
 '1.8メールヘッダ・インジェクション',
 '1.9アクセス制御や認可制御の欠落',
 '2.1ウェブサーバのセキュリティ対策',
 '2.2DNS情報の設定不備',
 '2.3ネットワーク盗聴への対策',
 '2.5フィッシング詐欺を助長しないための対策',
 '2.6WAFによるウェブアプリケーションの保護',
 '2.7携帯ウェブ向けのサイトにおける注意点',
 '3.1失敗例(SQLインジェクション)',
 '3.2失敗例(OSコマンド・インジェクション)',
 '3.3失敗例(パス名パラメータの未チェック)',
 '3.4失敗例(不適切なセッション管理)',
 '3.5失敗例(クロスサイト・スクリプティング)',
 '3.6失敗例(CSRF)',
 '3.7失敗例(HTTPヘッダ・インジェクション',
 '3.7失敗例(HTTPヘッダ・インジェクション)',
 '3.8失敗例(メールヘッダ・インジェクション)',
 'CWE対応表',
 'おわりに',
 'はじめに',
 'チェックリスト',
 '参考資料',
 '用語集'}

トピックデータをアップロードするために必要なCypherクエリを作成します。ノード名はTopicにします。

02_RAGを実装する.ipynb
 
create_node_query = 'CREATE' + '\n'

for header in headers:
   query_elem = f"(:Topic {{topic: '{header}'}}),"
   
   create_node_query += query_elem + '\n'

create_node_query = create_node_query.rstrip(',\n')
print(create_node_query)
 
CREATE
(:Topic {topic: ''}),
(:Topic {topic: '1.6CSRF'}),
(:Topic {topic: '3.4失敗例(不適切なセッション管理)'}),
(:Topic {topic: '1.1SQLインジェクション'}),
(:Topic {topic: '1.3パス名パラメータの未チェック/ディレクトリラバーサル'}),
(:Topic {topic: 'はじめに'}),
(:Topic {topic: '2.5フィッシング詐欺を助長しないための対策'}),
(:Topic {topic: '用語集'}),
(:Topic {topic: '3.8失敗例(メールヘッダ・インジェクション)'}),
(:Topic {topic: '1.2OSコマンド・インジェクション'}),
(:Topic {topic: '2.1ウェブサーバのセキュリティ対策'}),
(:Topic {topic: '1.7HTTPヘッダ・インジェクション'}),
(:Topic {topic: '3.5失敗例(クロスサイト・スクリプティング)'}),
(:Topic {topic: '3.3失敗例(パス名パラメータの未チェック)'}),
(:Topic {topic: '2.3ネットワーク盗聴への対策'}),
(:Topic {topic: '2.6WAFによるウェブアプリケーションの保護'}),
(:Topic {topic: '3.2失敗例(OSコマンド・インジェクション)'}),
(:Topic {topic: '2.7携帯ウェブ向けのサイトにおける注意点'}),
(:Topic {topic: '3.1失敗例(SQLインジェクション)'}),
(:Topic {topic: '3.6失敗例(CSRF)'}),
(:Topic {topic: '1.4セッション管理の不備'}),
(:Topic {topic: '1.8メールヘッダ・インジェクション'}),
(:Topic {topic: '1.9アクセス制御や認可制御の欠落'}),
(:Topic {topic: 'チェックリスト'}),
(:Topic {topic: '3.7失敗例(HTTPヘッダ・インジェクション'}),
(:Topic {topic: 'CWE対応表'}),
(:Topic {topic: '3.7失敗例(HTTPヘッダ・インジェクション)'}),
(:Topic {topic: '参考資料'}),
(:Topic {topic: '1.5クロスサイト・スクリプティング'}),
(:Topic {topic: 'おわりに'}),
(:Topic {topic: '2.2DNS情報の設定不備'})

作成したCypherクエリを実行します。

02_RAGを実装する.ipynb
 
graph_db.query(create_node_query)

再びNeo4jに戻って、再度データのアップロードができているかを確認します。

 
MATCH (n) RETURN n

image.png
画像右側にTopicというノード名が増えていることが確認できます。色が似ていて見づらいですが、ピンク色とベージュ色でノードが2種類あります。


ここまでで、PDFファイルのテキスト情報と抽出したトピックに関する、2種類のノードが作成できました。
続いて、この2種類のノード間にリレーションを貼ります。

02_RAGを実装する.ipynb
 
create_relation_query = '''
MATCH (c:Chunk), (t:Topic)
WHERE c.reference_text = t.topic
CREATE (t)-[:reference]->(c)'''

ここでリレーションを張るために、Chunkノード側にもトピックの情報を持たせていました。
上のCypherクエリを実行します。

02_RAGを実装する.ipynb
 
graph_db.query(create_relation_query)

ChunkノードとTopicノード間にリレーションが張られていることを確認します。

 
MATCH (n)-[r]->(m) RETURN n,r,m

image.png
なんだか可愛いお花のような模様が出てきました。こちらを拡大してみます。
image.png
ベージュ色のTopicノードから、ピンク色のChunkノードに対して、矢印が伸びていることが確認できます。これでリレーションが張れました。
ここまでやって、やっと僕がやってみたい状態が作れました。

質問してみる

再びnotebookを分けています。

03_対話してみる.ipynb
 
from langchain.chains import GraphCypherQAChain
from langchain_community.graphs import Neo4jGraph
from langchain_community.vectorstores.neo4j_vector import Neo4jVector
from langchain_ollama import ChatOllama, OllamaEmbeddings

import settings as sets


# 定数定義
NEO4J_URI: str = sets.NEO4J_URI
NEO4J_USER: str = sets.NEO4J_USER
NEO4J_PASSWORD: str = sets.NEO4J_PASSWORD

MODEL_NAME = 'elyza:8b'

ベクトルデータベース

まずは質問をする前に、グラフデータベースを使わずに、単なるベクトルデータベースとしてRAGによる検索を実施してみた場合を試してみます。

03_対話してみる.ipynb
 
user_query = 'SQLインジェクションについて教えて'
03_対話してみる.ipynb
 
%%time

# グラフDB(リレーション含まない、すなわちただのベクトルDBと同等)
exist_db = Neo4jVector.from_existing_graph(
    embedding=OllamaEmbeddings(model=MODEL_NAME),
    node_label='Chunk',
    url=NEO4J_URI,
    username=NEO4J_USER,
    password=NEO4J_PASSWORD,
    text_node_properties=['text'],
    embedding_node_property='embedding'
)
03_対話してみる.ipynb
 
exist_db.similarity_search_with_relevance_scores(user_query)

以下のような検索結果が返ってきました。
metadataテキストコサイン類似度の3つが含まれており、SQLインジェクションに関連のあるリソースはうまく取れていない模様です..。

 
[(Document(metadata={'reference_text': '3.4失敗例(不適切なセッション管理)'}, page_content='\ntext: 「093a2031a79cb4904b1466ee7ad5faaa3afe7b787db66712f407326b213cc2a4」等です。このプログラムではセッションIDにハッシュ値を使っているため、一見、安全そうに見えるかもしれません。しかし、セッションIDの生成方法が第三者に知られた場合光には、第三者がセッションIDを推測できる余地があります。\n罠のウェブサイトへの誘導等によって、攻撃者は利用者の接続元IPアドレスを入手できますぎ。一方、ポート番号は攻撃者が知り得えない情報です。しかし、接続元ポート番号の範囲は1024から65535であり、利用者のネットワーク環境によってはこの範囲が2万通り程度に限定される場合があります。\nこのプログラムがオープンソースで開発されている場合、またはソースコードが漏えいしてしまった場合等を想定できます。5ウェブサイトに到達するまでのネットワーク経路によっては、特定のipアドレスとならない場合があります\n。\nな'),
  0.7720184326171875),
 (Document(metadata={'reference_text': '2.7携帯ウェブ向けのサイトにおける注意点'}, page_content='\ntext: 4ここで使用するセッションIDは、第三者に推測されない必要があります。詳しくは18ページの根本的解決4-()を参照してください。60\nな'),
  0.7718048095703125),
 (Document(metadata={'reference_text': '1.5クロスサイト・スクリプティング'}, page_content='\ntext: 1.5クロスサイト・スクリプティング2)に該当するウェブアプリケーションの例には、自由度の高い掲示板やブログ等が挙げられます。たとえば、利用者が入力文字の色やサイズを指定できる機能等を実装するために、HTMLテキストの入力を許可する場合があるかもしれません。\n3)は、1、2)の両者のウェブアプリケーションに共通して必要な対策です。\n1.5.1HTMLテキストの入力を許可しない場合の対策\n園根本的解決\n5-(|\nウェブページに出力する全ての要素に対して、エスケープ処理を施す。\nウェブページを構成する要素として、ウェブページの本文やHTMLタグの属性値等に相当する全ての出力要素にエスケープ処理を行います。エスケープ処理には、ウェブページの表示に影響する特別な記号文字(「く<」、「>」、「&」等)を、HTMLエンティティ(「&It:」、「&gt:」、「&amp:」等)に置換する方法があります。また、HTMLタグを出力する場合は、その属性値を必ず「"」(ダブルクォート)で括るようにします。そして、「"」で括られた属性値に含まれる「"」を、HTMLエンティティ「&quot:\'」にエスケープします'),
  0.7661819458007812),
 (Document(metadata={'reference_text': '3.6失敗例(CSRF)'}, page_content='\ntext: 3.6失敗例(CSRF)3.6CSRF(クロスサイト・リクエスト・フォージェリ)うの例\nCSRF(クロスサイト・リクエスト・フォージェリ)の脆弱性を考慮できていない例として、登録情報編集画面のプログラムを紹介します。\n園PHPによる登録情報編集機能\n【脆弱な実装】\n下図は、会員制ウェブサイトにおける、ユーザの会員登録情報を変更する機能の、典型的な画面皿移の例です。ここでは、ユーザが住所を東京都から大阪府に変更するときの操作を例にしています。\n画面1\n画面2\nこのサイトの構成では、まず画面1(view.php)で登録情報を確認し、編集の必要があれば編集ボタンを押して画面2(edit.php)へ進み、画面2で必要な情報を入力して、さらにパスワードを入力した上で、画面3(confirm.php)へ進むようになっています。画面3で入力した情報を確認し、実行ボタンを押して画面4(commit.php)に進むと、ここで登録情報の更新処理が実行されて、その旨が表示されます。\n画面2で、本人確認のためにパスワードを入力するようになっており、パスワードが正しい場合にしか画面3に進めないようになっています。'),
  0.7476806640625)]

グラフデータベース

今度は、グラフデータベースらしい検索をやってみます。
はじめに、Neo4jに接続します。

03_対話してみる.ipynb
 
# グラフDB(リレーション含む)
graph_db = Neo4jGraph(
    url=NEO4J_URI,
    username=NEO4J_USER,
    password=NEO4J_PASSWORD
)

次に、GraphCypherQAChainを使って、質問文を回答するために必要なリソースを持ってくる〜回答生成までを実施してくれるchainを作成します。
リソースを持ってくるときに、内部的にCypherクエリを作成して、良い感じにグラフデータベースを探索してくれます(後述)。

03_対話してみる.ipynb
 
graph_chain = GraphCypherQAChain.from_llm(
    ChatOllama(model=MODEL_NAME, temperature=0.0),
    graph=graph_db,
    verbose=True,
    allow_dangerous_requests=True
)

最後に、質問文を投げてみます。

03_対話してみる.ipynb
 
graph_chain.invoke(user_query)

すると、以下のように返ってきました(回答に使用するリソースが横に長すぎる..)。
先ほどの単なるベクトルデータベースに対する検索と比べると、得られた回答用のリソースはSQLインジェクションに関連がありそうな気がします。

 
> Entering new GraphCypherQAChain chain...
Generated Cypher:
MATCH (t:Topic)-[:reference]->(c:Chunk) RETURN c.text
Full Context:
[{'c.text': 'ウェブアプリケーションのセキュリティ実装とウェブサイトの安全性向上のための取り組み\nIDA独立行政法人情報処理推進機構セキュリティセンター\n2012年12月\n本書は、以下のURLからダウンロードできます。「安全なウェブサイトの作り方」\nおわりに..…参考資料..eoメールヘッダ・インジェクションの例'},
 {'c.text': "プレースホルダに実際の値を割り当てる処理をバインドと呼びます。バインドの方式には、プレースホルダのままSQL文をコンパイルしておき、データベースエンジン側で値を割り当てる方式(静的プレースホルダ)と、アプリケーション側のデータベース接続ライブラリ内で値をエスケーブプ処理してプレースホルダにはめ込む方式(動的プレースホルダ)があります。静的プレースホルダは、SQLのISO/JIS規格では、準備された文(PreparedStatement)と呼ばれます。どちらを用いてもSQLインジェクション脆弱性を解消できますが、原理的にSQLインジェクション脆弱性の可能性がなくなるという点で、静的プレースホルダの方が優ります。詳しくは本書別冊の「安全なSQLの呼び出し方」のプレースホルダの項(3.2節)を参照してください。\nごも\nSQL文の組み立てを文字列連結により行う場合は、エスケーブプ処理等を行うデータベースエンジンのAPIlを用いて、SQL文のリテラルを正しく構成する。\nSQL文の組み立てを文字列連結により行う場合は、SQL文中で可変となる値をリテラル(定数)の形で埋め込みます。値を文字列型として埋め込む場合は、値をシングルクォートで囲んで記述しますが、その際に文字列リテラル内で特別な意味を持つ記号文字をエスケープ処理します(たとえば、「'」つつ「'「」、「\\」つ「」等)。値を数値型として埋め込む場合は、数値リテラルであることを確実にする処"},
 {'c.text': '$最新情報は、下記URLを参照してください。\n脆弱性関連情報に関する届出状況:\n7\n|'}, 
 {'c.text': '1.1SQLインジェクション理(数値型へのキャスト等)を行います。\nこうした処理で具体的に何をすべきかは、データベースエンジンの種類や設定によって異なるため、それにあわせた実装が必要です。データベースエンジンによっては、リテラルを文字列として生成する専用のAPI\'を提供しているものがありますので、それを利用することをお勧めします。詳しくは、「安全なSQLの呼び出し方」の4.1節を参照してください。\nなお、この処理は、外部からの入力の影響を受ける値のみに限定して行うのではなく、SQL文を構成する全てのリテラル生成に対して行うべきです。\n|\n1-(ii)\nRn"ウェブアプリケーションに渡されるパラメータにSQL文を直接指定しない\n。\nこれは、いわば「論外」の実装ですが、hiddenパラメータ等にSQL文をそのまま指定するという事例の届出がありましたので、避けるべき実装として紹介します。\nウェブアプリケーションに渡されるパラメータにSQL文を直接指定する実装は、そのパラメータ値の改変により、データベースの不正利用につながる可能性があります。\n園保険的対策\n1-(iii)\nエラーメッツセージをそのままブラウザに表示しない。'},
 {'c.text': 'エラーメッセージの内容に、データベースの種類やエラーの原因、実行エラーを起こしたSQL文等の情報が含まれる場合、これらはSQLインジェクション攻撃につながる有用な情報となりえます。また、エラーメッセージは、攻撃の手がかりを与えるだけでなく、実際に攻撃された結果を表示する情報源として悪用される場合があります。データベースに関連するエラーメッセージは、利用者のブラウザ上に表示させないことをお勧めします。1-(iv)\n|\n1-(iv)hn"データベースアカウントに適切な権限を与える。\nウェブアプリケーションがデータベースに接続する際に使用するアカウントの権限が必要以上に高い場合、攻撃による被害が深刻化する恐れがあります。ウェブアプリケーションからデータベースに渡す命令文を洗い出し、その命令文の実行に必要な最小限の権限をデータベースアカウントに与えてください。\n以上の対策により、SQLインジェクション攻撃に対する安全性の向上が期待できます。データベースと連携したウェブアプリケーションの構築や、SQLインジェクションの脆弱性に関する情報については、次の資料も参考にしてください。\n7′実行環境によっては、エスケープ処理を適切に行わない脆弱性が指摘されているAPIもあります。その場合は修正パッチを適用するか、別の方法を検討して下さい。'},
 {'c.text': '8|\n|\n画参考URL\nIPA:安全なSQLの呼び出し方\nIPA:知っていますか?脆弱性(ぜいじゃくせい)「1.SQLインジェクション」\nIPA:セキュア・プログラミング講座「SQL注入:#実装における対策」\nA:セキュア・プログラミング講座「SQL注入:#2設定における対策」D://www.ipa.go.jp/security/awareness/vendor/programmingv2/contents/503.htmlき\nIPA:情報セキュリティ白書2009第2部「10大脅威攻撃手法の『多様化』が進む」\nIPA:情報セキュリティ白書2008第2部「10大脅威ますます進む『見えない化』」'},
 {'c.text': '1.1SQLインジェクション'},
 {'c.text': '1.1SQLインジェクション1.ウェブアプリケーションのセキュリティ実装\n本章では、ウェブアプリケーションのセキュリティ実装として、下記の脆弱性を取り上げ*、発生しうる脅威、注意が必要なサイト、根本的解決および保険的対策を示します。\n1)SQLインジェクション\n2)0Sコマンド・インジェクション\n3)パス名パラメータの未チェックンディレクトリ・トラバーサル\n4)セッション管理の不備\n5)クロスサイト・スクリプティング\n6)CSRF(クロスサイト・リクエスト・フォージェリ)\n7)HTTPヘッダ・インジェクション\n8)メールヘッダ・インジェクション\n9)アクセス制御や認可制御の欠落\n*資料の構成上、脆弱性の深刻度や攻撃による影響を考慮して項番を割り当てていますが、これは対策の優先順位を示すものではありません。優先順位は運営するウェブサイトの状況に合わせてご検討ください。\n5'},
 {'c.text': '1.1SQLインジェクション1.1SQLインジェクション\nデータベースと連携したウェブアプリケーションの多くは、利用者からの入力情報を基にSQL文(データベースへの命令文)を組み立てています。ここで、SQL文の組み立て方法に問題がある場合、攻撃によってデータベースの不正利用をまねく可能性があります。このような問題を「SQLインジェクションの脆弱性」と呼び、問題を悪用した攻撃を、「SQLインジェクション攻撃」と呼びます。\n画発生しうる脅威\nSQLインジェクション攻撃により、発生しうる脅威は次のとおりです。\nデータベースに蓄積された非公開情報の閲覧\n個人情報の漏えい等\nデータベースに蓄積された情報の改ざん、消去\nウェブページの改ざん、パスワード変更、システム停止等\n認証回避による不正ログイン*\nログインした利用者に許可されている全ての操作を不正に行われる\nストアドプロシージャ等を利用した0Sコマンドの実行\nシステムの乗っ取り、他への攻撃の踏み台としての悪用等\n回注意が必要なウェブサイトの特徴\n運営主体やウェブサイトの性質を問わず、データベース?を利用するウェブアプリケーションを設置しているウェブサイトに存在しうる問題です。個人情報等の重要情報をデータベースに格納しているウェブサイトは、特に注意が必要です。'},
 {'c.text': '*後述「1.3セッション管理の不備」で解説する「発生しうる脅威」と同じ内容です。5代表的なデータベースエンジンには、MySQL,PostgreSQL,Oracle,MicrosoftSQLServer,DB2等が挙げられます。\n6'}]

> Finished chain.
{'query': 'SQLインジェクションについて教えて',
 'result': "SQLインジェクションとは、Webアプリケーションの入力フォームなどを通じて、悪意のあるユーザーがデータベースに不正な命令を送信し、データを改ざんや漏えいさせる攻撃手法です。\n\n具体的には、Webアプリケーションがデータベースとの通信で使用するSQL文に、特別な文字列を埋め込むことで、意図的にデータベースの動作を変更したり、情報を取得したりすることができます。\n\n例えば、入力フォームに「' OR 1=1 --」などの特殊な文字列を入力し、Webアプリケーション安全等/tech/sqlinjection.html"}

上の結果で、上から3行目にCypherクエリがありますが、こちらが先ほど軽く言及した内容です。回答リソースを検索するために必要なCypherクエリを自動的に作成してくれます。それによって得られた回答用のリソースから、質問文に対する回答を生成してくれています。

ただ、回答の質に関して大正解とは言い難いですね..(回答(=result)の後ろらへんの例えば〜の件は、ドキュメント中のどこにも見当たらなかったです)。今回使用しているモデルのパラメータ数が少ないため、より大きいモデルを使用することで解決できると思っています。

グラフデータベースは万能ではなかった..!

質問文が1つうまくいった程度で満足するわけもなく、他にも質問をしてみました。

03_対話してみる.ipynb
 
user_query2 = 'WAFの効果について例を教えて'
graph_chain.invoke(user_query2)

結果は以下のようになりました。

 
> Entering new GraphCypherQAChain chain...
Generated Cypher:
MATCH (t:Topic {topic:"WAF"})-[:reference]->(c:Chunk) RETURN c.text
Full Context:
[]

> Finished chain.
{'query': 'WAFの効果について例を教えて',
 'result': 'WAFは、Web Application Firewallの略で、Webアプリケーション層での攻撃を防ぐためのセキュリティ対策です。'}

ん?先ほどの「SQLインジェクションについて教えて」のときと比べると、生成されたCypherクエリが異なります。
先ほどはグラフデータベースを全探索していましたが、今回は「WAF」という名称のTopicノードに限定して検索をかけるようにしたようです。その結果、当然「WAF」というTopicノード名は存在しないため回答用のリソースが得られず、LLMがすでに持っている知識で回答したみたいです。
今回作成したグラフデータベースのTopicノードだと、「2.6WAFによるウェブアプリケーションの保護」という名称でノードを作成していました。

対策としては、

・生成されるCypherクエリの質を高める
・グラフデータベースを作成時に、ノード名を工夫する
・プロンプトを工夫する

などが挙げられるでしょうか?前者に関しては使用するLLMによって高められるものなのでしょうか?
まだまだわからないことだらけなので、この辺りは別途お勉強します。

おわりに

今回は、前々から気になっていたグラフデータベースを利用したRAG(=GraphRAG)を簡易的ですが実装してみました。
単純なベクトルデータベースを使うよりも、グラフデータベースを使った方が回答用のリソースを検索する性能は良いのかもしれません。特に、今回のようなパラメータ数が少ないLLMを選択するのであれば、embeddingの性能もRAGに影響を与えるはずなので、グラフデータベースを使ってファイルの文書構造を維持させるのは個人的には面白い取り組みだったと思っています。

本記事を作成した際の引用元はこちら
Ollama https://ollama.com/
※Built with Meta Llama 3
安全なウェブサイトの作り方 改訂第6版
https://www.ipa.go.jp/security/vuln/websecurity/ug65p900000196e2-att/000044465.pdf

この記事を書いたメンバー

この記事を作成したメンバー画像

Web/AIチーム 多和田 真助