Neo4jの自前構築に挑戦 - 無償版期限切れの対応

## 背景・目的
以前投稿した記事では、グラフ描画に**Neo4j Aura**を使用していましたが、無償版の有効期限が切れてしまい、サービスを利用できなくなりました。そこで今回は、**Neo4j**を自前で構築し、運用することにしました。また、以前の記事では実現できなかった、作成したグラフに対してChainlitで質問を行う機能にも挑戦してみました。

参考にした記事はこちらです：

https://qiita.com/ogi_kimura/items/1e03b29bb245b553be26

https://qiita.com/ogi_kimura/items/5e51dfbf31ef4f117a9a

https://qiita.com/ogi_kimura/items/715fffd87a636565d256

これらの記事では、**Neo4j**の環境構築からPDFファイルをグラフ化する方法、そして**Chainlit**を利用した質問応答システムの設定まで、ステップごとに解説しています。

## 参考資料
今回は、日本オラクルの@ssfujita さんの記事を参考にさせていただきました。記事は非常に分かりやすく、簡潔に書かれており、大変助かりました。フジタさん、ありがとうございます！

https://qiita.com/ssfujita/items/65a952f299190f4c1e6a

ただ、オラクル社のクラウドサービス（OCI）は個人的に利用する財力がないため、今回は**OpenAI**の仕様に変更させていただきました。オラクル社には申し訳ありませんが、その点ご了承ください。

## フォルダ構成
Neo4jのzipファイルを解凍し、「C:\」直下に配置しました。以下はそのツリー構造です。zipファイルのダウンロード方法については次のセクションで詳しく説明します。

```
C:\
├── bin        # 実行ファイルを集めたフォルダ
├── conf       # 設定情報を入力したconfファイルを集めたフォルダ
├── plugins    # 利用するプラグインのライブラリを補完するフォルダ
├── labs       # 実験的なコードや新しい技術、試作的な機能などを格納するフォルダ
└── ・・・・・
```

## サーバ側 環境設定

### 1. Neo4jのダウンロード
公式サイトからNeo4jのzipファイルをダウンロードします。使用したバージョンは`neo4j-community-5.24.1-windows.zip`です。

https://neo4j.com/deployment-center/

このような画面が表示されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/1decb3a1-f876-272d-e620-fa5cc73c40e8.png)

画面を下にスクロールして「Graph Database Self-Managed」というセクションに進み、赤枠で囲まれた部分からWindows用にダウンロードします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/059045b2-433c-cb30-5f59-4ac5481cb058.png)


### 2. 解凍と配置
ダウンロードしたzipファイルを解凍し、任意の場所に配置します。私の場合はNeo4jを「C:\」直下に置きました。


### 3. 環境変数の設定
以下の環境変数を設定しました。
- `NEO4J_HOME`：Neo4jのインストールパス
- `PATH`：Neo4jのbinフォルダへのパスを追加

以下は、環境変数ダイアログで`NEO4J_HOME`を確認しているところを表しています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/bfb5770d-e0d0-c928-7b77-8f36bc7ceaf3.png)


### 4. 認証設定
初回起動時にはライセンス認証が必要です。以下のコマンドを使用して評価版ライセンスを承諾します。
```bash
bin/neo4j-admin server license --accept-evaluation
```

商用ライセンスの場合は次のコマンドを使用します。
```bash
bin/neo4j-admin server license --accept-commercial
```

### 5. サーバ起動
次に、サーバを起動するために以下のコマンドを実行します。
```bash
bin\neo4j-admin server console
```

### 6. APOCプラグインのインストール
PDFを読み込む際に発生したエラーです。
```
Could not use APOC procedures. Please ensure the APOC plugin is installed in Neo4j and that 'apoc.meta.data()' is allowed in Neo4j configuration
```
このエラーの解決のため、APOCプラグインをインストールします。

#### neo4j.confの修正
`conf`フォルダ内の`neo4j.conf`ファイルに以下の設定を追加します。
```
dbms.security.procedures.unrestricted=apoc.*
dbms.security.procedures.allowlist=apoc.*
```

#### APOCライブラリの配置
また、APOCライブラリ（`apoc-5.24.1-core.jar`）を`plugins`フォルダに配置します。

### 7. サーバ再起動
設定変更後はサーバを再起動し、APOCプラグインを読み込ませます。
```bash
bin/neo4j-admin server console
```
やっと正常に起動しました。（エラー原因究明なども含め、所要時間2時間かかりました）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/f4846e5c-9cfd-1b69-91f5-3aa983002079.png)


## アプリ側 プログラム

### 1. .envファイルの設定
`Chainlit`での認証に必要な`.env`ファイルを作成し、APIキーなどを設定します。
```bash
chainlit create-secret
```

`.env`ファイルの設定例：
```
CHAINLIT_AUTH_SECRET=your_secret_key
```
今回のケースでは、自分で`.env`ファイルを`NEO4J_HOME`の直下に作成して、設定を行いました。

### 2. プログラムの構成
`Chainlit`を使用してPDFファイルの内容を**Neo4j**に保存し、質問応答を行うPythonプログラムを以下に示します。
冒頭でも述べましたが、@ssfujita(Shinjiro Fujita)さんのプログラムをOpenAI用にカスタマイズさせていただきました。

```python:
import os
from dotenv import load_dotenv
load_dotenv()

from langchain_community.document_loaders import PyPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_core.documents import Document
from langchain_core.prompts import PromptTemplate
from langchain_community.vectorstores import Neo4jVector
from langchain_openai import ChatOpenAI
from langchain_community.graphs import Neo4jGraph
from langchain_experimental.graph_transformers import LLMGraphTransformer
from langchain_community.chains.graph_qa.cypher import GraphCypherQAChain

from typing import Optional
import chainlit as cl

from langchain_openai import OpenAIEmbeddings

# Neo4j接続情報
url = "bolt://localhost:7687"
user = "neo4j"
pwd = "*****"
db_name = "neo4j"

# chainlitアカウント情報
chainlit_user = 'admin'
chainlit_pwd = 'admin'

# embeddingモデル設定
embedding_model = OpenAIEmbeddings(model='text-embedding-ada-002')
# LLM設定
llm = ChatOpenAI(model="gpt-4o-mini")

# Chainlitの認証設定
@cl.password_auth_callback
def auth_callback(username: str, password: str):
    if (username, password) == (chainlit_user, chainlit_pwd):
        return cl.User(
            identifier=chainlit_user, metadata={"role": "user", "provider": "credentials"}
        )
    else:
        return None

@cl.on_chat_start
async def on_chat_start():
    # ファイルアップロードの処理
    files = None
    while files is None:
        # chainlitのファイルアップロード機能を利用
        files = await cl.AskFileMessage(
            # ファイルの最大サイズ
            max_size_mb=20,
            # ファイルをアップロードさせる画面のメッセージ
            content="PDFを選択してください。",
            # PDFファイルを指定する
            accept=["application/pdf"],
            # タイムアウトなし
            raise_on_timeout=False,
        ).send()    
     
    loader = PyPDFLoader(files[0].path)
    
    # テキスト抽出
    pages = loader.load_and_split()

    # テキスト分割
    text_splitter = RecursiveCharacterTextSplitter(chunk_size=200, chunk_overlap=40)
    docs = text_splitter.split_documents(pages)

    # 分割結果を一時保存
    lc_docs = []
    for doc in docs:
        lc_docs.append(Document(page_content=doc.page_content.replace("\n", ""), metadata={'source': files[0].name}))
    
    # Neo4jへの接続情報を設定してgraphインスタンスを作成
    graph = Neo4jGraph(
        url=url,
        username=user,
        password=pwd,
        database=db_name
    )
    
    # DB内のグラフを削除するクエリ
    cypher = """
      MATCH (n)
      DETACH DELETE n;
    """
    
    # 既存グラフを削除して前回の内容をリセット
    graph.query(cypher)  
    
    # llmを使いドキュメントをグラフに変換するtransformerを作成
    transformer = LLMGraphTransformer(
        llm=llm,
        allowed_nodes=["entity"], # ノードのラベルに「entity」を設定
        node_properties=["text"], # ノードのプロパティに「text」を設定
        relationship_properties=True # リレーションシップのプロパティ生成を「True」に設定
    ) 
    
    # ドキュメントをグラフに変換
    graph_documents = transformer.convert_to_graph_documents(lc_docs)
    
    # 変換したグラフをデータベースに保存
    graph.add_graph_documents(graph_documents, include_source=True) 
    
    # ベクトルデータを含む検索用インデックス作成
    index = Neo4jVector.from_existing_graph(
        embedding=embedding_model,
        url=url,
        username=user,
        password=pwd,
        database=db_name,
        node_label="entity", # 検索対象ノード
        text_node_properties=["id", "text"], # 検索対象プロパティ
        embedding_node_property="embedding", # ベクトルデータの保存先プロパティ
        index_name="vector_index", # ベクトル検索用のインデックス名
        keyword_index_name="entity_index", # 全文検索用のインデックス名
        search_type="hybrid" # 検索タイプに「ハイブリッド」を設定
    )
    
    await cl.Message(content=f"`{files[0].name}` の準備が完了しました。").send()

    # Cypherクエリ用のプロンプトテンプレート
    template = """
    Task: グラフデータベースに問い合わせるCypher文を生成する。
    
    指示:
    schemaで提供されている関係タイプとプロパティのみを使用してください。
    提供されていない他の関係タイプやプロパティは使用しないでください。
    
    schema:
    {schema}
    
    注意: 回答に説明や謝罪は含めないでください。
    Cypher ステートメントを作成すること以外を問うような質問には回答しないでください。
    生成された Cypher ステートメント以外のテキストを含めないでください。
    
    質問: {question}""" 

    # プロンプトの設定
    question_prompt = PromptTemplate(
        template=template, # プロンプトテンプレートをセット
        input_variables=["schema", "question"] # プロンプトに挿入する変数
    )

    # Cypherクエリを作成 → 実行 → 結果から回答を行うChainを作成
    qa = GraphCypherQAChain.from_llm(
        llm=llm,
        graph=graph,
        cypher_prompt=question_prompt,
        allow_dangerous_requests=True
    ) 

    # セッション登録
    cl.user_session.set("runnable", qa)

@cl.on_message
async def on_message(message: cl.Message):
    # セッション情報から設定を読み込み
    runnable=cl.user_session.get("runnable")
    
    # Chainlit設定
    cb = cl.AsyncLangchainCallbackHandler(
        stream_final_answer=True,
        answer_prefix_tokens=["FINAL", "ANSWER"]
    )
    
    cb.answer_reached=True
    
    # 回答生成
    res=await runnable.ainvoke({"query": message.content}, callbacks=[cb])
    
    # 回答表示
    await cl.Message(content=f"\nAnswer:\n"+res['result']).send()
```


以下に、特に説明した方が良いと思う点について、記述いたします。


#### 接続設定
URLは`bolt://`の場合はポートが`7687`のようでしたので、そのように設定しました。また、`user`や`db_name`はデフォルトでは`neo4j`になっているようでしたので、そのまま使ってみました。
```python:
# Neo4j接続情報
url = "bolt://localhost:7687"
user = "neo4j"
pwd = "*****"
db_name = "neo4j"
```

### LLMの選択
OpenAIのモデルである、`gpt-4o-mini`を採用しました。お財布事情によるものです・・・
```python:
# LLM設定
llm = ChatOpenAI(model="gpt-4o-mini")
```

#### Embeddingモデルの選択
OpenAIのモデルを使用しました。

```python:
embedding_model = OpenAIEmbeddings(model='text-embedding-ada-002')
```

#### 認証設定
適宜、認証設定も行っています。思い切り流用させていただきました。

```python:
# Chainlitの認証設定
@cl.password_auth_callback
def auth_callback(username: str, password: str):
    if (username, password) == (chainlit_user, chainlit_pwd):
        return cl.User(
            identifier=chainlit_user, metadata={"role": "user", "provider": "credentials"}
        )
    else:
        return None

```


## 実行
`Chainlit`が正常に起動しました。
こちらが開発環境の**VSCode**のイメージ画像です。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/b7f7a77f-3e7f-b6f5-00b4-595e69559260.png)

それから、こちらがWebブラウザです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/aa562448-dd55-8537-dfef-ded86c10bc04.png)

では、`Chainlit`にPDFファイルを投入してみます。正常に処理がされたようです。
`chainlit`ではうんともすんとも言わないので、少し不安になりましたが、**VSCode**では完了していることを確認することができました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/9ca263c2-0abf-9360-ef99-5b11d514237e.png)

### プロンプト表示
では、**Neo4j**をみてみます。(ドキドキ)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/8a75f76c-845d-465a-6183-990711b7ed41.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/5e91b544-8ea3-4aff-64f8-97592ecb1372.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/15132049-3463-9161-b351-628701e07481.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/dca8c47f-e1ce-c45c-3614-fab173b287a9.png)

なんか出てるみたいですね。成功したように見えます。
これで、チャットの質問に答えるためのネタがそろいました。


### 質問受信時
では、データは出来上がったようなので、質問を投げてみることにします。

ん？大丈夫か？
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/6b08727b-251e-3d8b-76e5-43cd9f1186b1.png)

おお、なんとか食らいついてきた…
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3784222/a244882a-7f97-62a4-9dc3-53549c7affb0.png)



## おわりに
今回の試みで、無事に`Neo4j`を自前で構築し、グラフを描画できるようになりました。しかし、回答の質が向上したかはまだ不明です。今後は、`GPT-4o`や他の高性能なモデルに切り替えて改善を図りたいと思います。