Neo4jへの日本語テキスト登録手順のメモ（ナレッジグラフ・ベクトルDB・フルテキストインデックス）

引き続き以下記事をまったり追試していく。

https://qiita.com/satken2/items/ea3ddc5f273d43cd63d4


## 要旨
Neo4jに、とりあえず"走れメロス" のテキスト登録する。
以下の３観点での登録を試みる。
- ナレッジグラフの生成
- ベクトル埋め込みの作成
- フルテキストインデックスの作成

## 環境構築と初期設定

### **1. Neo4j 環境の構築**

Docker を利用して Neo4j を起動する。

```yaml
services:
  neo4j:
    image: neo4j:latest
    ports:
      - "7474:7474"
      - "7687:7687"
    environment:
      NEO4J_AUTH: "neo4j/[パスワード]"
      NEO4J_apoc_export_file_enabled: "true"
      NEO4J_apoc_import_file_enabled: "true"
      NEO4J_dbms_security_procedures_unrestricted: "apoc.*"
    volumes:
      - ./data:/data
      - ./logs:/logs
```

### **2. テキストのチャンク化のテスト**

単純に文字数で区切る手もあるが、文章として途中で途切れるのはどうなんだという感があり、英語ならnltk、日本語ならsudachipyで分割を試みることにした。

#### 使用技術

- Python ライブラリ: `nltk`, `sudachipy`

#### 実行テスト

- &#x20;NLTK はデフォルトで `/home` にテキスト分割用のデータをダウンロードするため、以下のように明示的にデータ格納パスを指定したほうが良い

```python
import nltk
nltk.data.path.insert(0, '/mnt/NVMe01TB01/nltk_data')
```

最終的なチャンク化のスクリプトは以下の通り。

```python
import os
import nltk
from langdetect import detect
from nltk.tokenize import sent_tokenize
from sudachipy import dictionary, tokenizer
import json
# NLTKデータパスを指定
nltk.data.path.insert(0, '/mnt/NVMe01TB01/nltk_data')
def detect_language(text):
    """テキストの言語を判定する"""
    try:
        return detect(text)
    except:
        return "unknown"
def split_text_into_chunks(text, max_chunk_size):
    """
    テキストを言語に応じて分割し、指定された上限文字数内で意味のまとまりを保持してチャンク化する
    """
    language = detect_language(text)
    if language == "en":  # 英語の場合
        try:
            sentences = sent_tokenize(text, language='english')  # 明示的に英語を指定
            sentences = [s for s in sentences if s.strip()]  # 空行を除去
        except LookupError:
            print("Failed to load punkt tokenizer. Falling back to newline-based splitting.")
            sentences = text.splitlines()
    elif language == "ja":  # 日本語の場合
        tokenizer_obj = dictionary.Dictionary().create()
        mode = tokenizer.Tokenizer.SplitMode.C
        sentences = text.split("。")  # 「。」で一旦分割
        sentences = [s.replace("\n", "") + "。" for s in sentences if s.strip()]  # 改行を除去し整形
    else:
        # 言語判定に失敗した場合、改行で分割
        sentences = text.splitlines()
    # チャンク化
    chunks = []
    current_chunk = ""
    for sentence in sentences:
        if len(current_chunk) + len(sentence) <= max_chunk_size:
            # 現在のチャンクに追加
            current_chunk += sentence
        else:
            # 上限を超えた場合は新しいチャンクを作成
            chunks.append(current_chunk)
            current_chunk = sentence
    if current_chunk:
        chunks.append(current_chunk)
    return chunks
def prepare_text_data(input_file, output_file, max_chunk_size):
    """
    テキストファイルを読み込み、チャンク化して保存
    """
    if not os.path.exists(input_file):
        print(f"Input file {input_file} does not exist.")
        return
    with open(input_file, "r", encoding="utf-8") as f:
        text = f.read()
    # チャンク化
    chunks = split_text_into_chunks(text, max_chunk_size)
    # 結果をJSON形式で保存
    with open(output_file, "w", encoding="utf-8") as f:
        json.dump(chunks, f, ensure_ascii=False, indent=4)
    print(f"Chunked text data saved to {output_file}")
# ファイルパス
input_file = "hashire_merosu.txt"  # 処理対象のテキストファイル
output_file = "output_chunks.json"  # チャンク化結果の保存先
max_chunk_size = 250  # 最大チャンクサイズ
# 実行
prepare_text_data(input_file, output_file, max_chunk_size)
```

## テキストデータのインポート

### **3. チャンクデータのインポート**

- Python スクリプトを作成して、チャンク化したテキストを Neo4j にノードとしてインポート。
  元のテキストをチャンク化し、jsonファイルとして保存

```python
from neo4j import GraphDatabase
import json
# Neo4j接続情報
NEO4J_URI = "bolt://localhost:7687"
NEO4J_USER = "neo4j"
NEO4J_PASSWORD = "[パスワード]"
def import_chunks_to_neo4j(json_file):
    """
    JSONファイルのチャンクデータをNeo4jにインポート
    """
    # JSONデータの読み込み
    with open(json_file, "r", encoding="utf-8") as f:
        chunks = json.load(f)
    # Neo4jセッションの初期化
    driver = GraphDatabase.driver(NEO4J_URI, auth=(NEO4J_USER, NEO4J_PASSWORD))
    with driver.session() as session:
        # ノードの作成
        for i, chunk in enumerate(chunks):
            session.write_transaction(create_chunk_node, i, chunk)
    driver.close()
    print("Data imported to Neo4j successfully.")
def create_chunk_node(tx, chunk_id, chunk_text):
    """
    チャンクデータをノードとしてNeo4jに追加
    """
    query = """
    CREATE (c:Chunk {id: $chunk_id, text: $chunk_text})
    """
    tx.run(query, chunk_id=chunk_id, chunk_text=chunk_text)
# ファイルパス
json_file = "output_chunks.json"  # チャンク化されたJSONファイル
# 実行
import_chunks_to_neo4j(json_file)
```

### **4. チャンク間リレーションの構築**

```python
from neo4j import GraphDatabase
# Neo4j接続情報
NEO4J_URI = "bolt://localhost:7687"
NEO4J_USER = "neo4j"
NEO4J_PASSWORD = "[パスワード]"
def create_chunk_relationships():
    """
    チャンク間にリレーションを構築する（:NEXT）
    """
    driver = GraphDatabase.driver(NEO4J_URI, auth=(NEO4J_USER, NEO4J_PASSWORD))
    with driver.session() as session:
        session.execute_write(create_next_relationships)
    driver.close()
    print("Relationships created successfully.")
def create_next_relationships(tx):
    """
    各チャンクに対して :NEXT リレーションを作成
    """
    query = """
    MATCH (c1:Chunk), (c2:Chunk)
    WHERE c1.id = c2.id - 1
    CREATE (c1)-[:NEXT]->(c2)
    """
    tx.run(query)
# 実行
create_chunk_relationships()
```

---

## ベクトル埋め込みの生成

### **5. ベクトル埋め込みの生成**

- Open WebUIが提供する OpenAI 互換 API を使用したが、埋め込み用エンドポイントが見当たらず挫折（実はあるのかもしれないが）
- 代わりにOllamaが持っているembedding用APIを利用することにした

 参考：Curlコマンドで直接embeddingのエンドポイントを叩くテストコマンド

```python
$ curl -X POST http://localhost:11434/api/embed -H "Content-Type: application/json" -d '{"model": "nomic-embed-text:latest", "input": "テストテキスト"}'
{"model":"nomic-embed-text:latest","embeddings":[[0.022953669,0.08734732,-0.17950457,・・・
```

ベクトルデータをNeo4jに登録する
```python
import requests
from neo4j import GraphDatabase
# Neo4j接続情報
NEO4J_URI = "bolt://localhost:7687"
NEO4J_USER = "neo4j"
NEO4J_PASSWORD = "[パスワード]"
# Ollama API設定
OLLAMA_API_BASE = "http://localhost:11434"
EMBEDDING_MODEL = "nomic-embed-text:latest"
def get_embedding(text):
    """
    Ollama APIを使用してテキストの埋め込みを生成
    """
    url = f"{OLLAMA_API_BASE}/api/embed"  # エンドポイントを指定
    headers = {
        "Content-Type": "application/json",
    }
    payload = {
        "model": EMBEDDING_MODEL,
        "input": text  # `input` フィールドを使用
    }
    response = requests.post(url, headers=headers, json=payload)
    if response.status_code == 200:
        embedding = response.json().get("embeddings", [[]])[0]  # 最初の埋め込みを取得
        if not embedding:
            print(f"Warning: Empty embedding for text: {text}")
        return embedding
    else:
        print(f"Error: {response.status_code} - {response.json()}")
        raise Exception(f"Failed to get embedding: {response.status_code}")
def add_embeddings_to_chunks():
    """
    チャンクのテキストに対して埋め込みを生成し、Neo4jに保存
    """
    driver = GraphDatabase.driver(NEO4J_URI, auth=(NEO4J_USER, NEO4J_PASSWORD))
    with driver.session() as session:
        # すべてのチャンクを取得
        chunks = session.execute_read(fetch_all_chunks)
        for chunk_id, chunk_text in chunks:
            # 埋め込み生成
            embedding = get_embedding(chunk_text)
            print(f"Chunk ID: {chunk_id}, Embedding: {embedding[:5]}...")  # 一部を表示
            # Neo4jに保存
            session.execute_write(add_embedding_to_chunk, chunk_id, embedding)
    driver.close()
    print("Embeddings added to all chunks successfully.")
def fetch_all_chunks(tx):
    """
    すべてのチャンクを取得
    """
    query = """
    MATCH (c:Chunk)
    RETURN c.id AS id, c.text AS text
    """
    result = tx.run(query)
    return [(record["id"], record["text"]) for record in result]
def add_embedding_to_chunk(tx, chunk_id, embedding):
    """
    チャンクに埋め込みを追加
    """
    query = """
    MATCH (c:Chunk {id: $chunk_id})
    SET c.embedding = $embedding
    """
    tx.run(query, chunk_id=chunk_id, embedding=embedding)
# 実行
add_embeddings_to_chunks()
```
## フルテキストインデックスの作成

※ここから先は　http\://localhost:7474　で表示したNeo4jの管理画面から実行する

### **6. インデックス作成**

- `CREATE FULLTEXT INDEX` を使用してインデックスを作成。

```cypher
CREATE FULLTEXT INDEX chunkTextIndex FOR (n:Chunk) ON EACH [n.text];
```

#### インデックスの存在確認

```cypher
SHOW INDEXES;
```
## フルテキスト検索の実行

### **7. フルテキスト検索**

インデックスを利用して検索する。

```cypher
CALL db.index.fulltext.queryNodes("chunkTextIndex", "激怒") YIELD node, score
RETURN node.id AS id, node.text AS text, score
ORDER BY score DESC;
```

検索結果:

- キーワード「激怒」を含むノードが返却され、関連するスコアも表示。

## ナレッジグラフの可視化

### **8. 「メロス」に関連するノードの表示**

- 特定のキーワード（例:「メロス」）に関連するノードとリレーションを抽出。

```cypher
MATCH (c:Chunk)-[r]-(m)
WHERE c.text CONTAINS "メロス"
RETURN c, r, m;
```

### 結果の確認

- 「メロス」に関連するノードがグラフビューで可視化された。
- 小説のようなテキスト同士がひと繋がりに密結合（？）したものを使ったのが悪かったのか、ナレッジグラフはギャグみたいな形状になってしまった。（なんとなくいい感じに登場人物の相関関係とか、そういうエンティティがでてくるのかと思ったが、そこまで甘くはなかった。そういえばRangGraphはそのへん、幾つかのチャンクごとにLLMで要約を書いたりして、エンティティを自力で作成するようにしてたんだっけな…）

---

ナレッジグラフの図：巻き貝の化石かな？
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/179750/e0041627-4138-bf94-2d54-041dd2336861.png)