faiss_service 補足

前回の記事で、faiss のインストールと、サービス（faiss_service.py）の作成（2025/4/28追記）を行いましたが、使いやすさのために補足します。すべてを使用しているわけではありません。

https://qiita.com/sato_7411/items/2fd82f7602ced4e0d69c


## OpenAPI (Swagger) 対応

```
pip install flasgger
```

```python:faiss_service.py の変更
# from flask の下
from flasgger import Swagger
# app = ... の下
swagger = Swagger(app)

# """クエリベクトルで検索""" を以下に変更

    """
    クエリベクトルで検索
    ---
    tags:
      - Document Management
    description: クエリをベクトル化してKnowledge Baseから関連ドキュメントを検索します。
    parameters:
      - in: body
        name: body
        required: true
        schema:
          type: object
          required:
            - knowledge_id
            - query
            - retrieval_setting
          properties:
            knowledge_id:
              type: string
              description: 検索対象のKnowledge BaseのID
              example: "AAA-BBB-CCC"
            query:
              type: string
              description: 検索クエリのテキスト
              example: "売上レポートについて教えてください。"
            retrieval_setting:
              type: object
              description: 検索の詳細設定
              properties:
                top_k:
                  type: integer
                  description: 取得する上位ドキュメント数
                  example: 2
                score_threshold:
                  type: number
                  format: float
                  description: スコアの最低しきい値（小さいとヒットしやすい）
                  example: 0.4
    responses:
      200:
        description: 検索結果が返された場合
        schema:
          type: object
          properties:
            records:
              type: array
              items:
                type: object
                properties:
                  content:
                    type: string
                    description: ドキュメント本文
                    example: "売上レポート2023年第1四半期版"
                  title:
                    type: string
                    description: ドキュメントタイトル
                    example: "2023年Q1 売上まとめ.txt"
                  metadata:
                    type: object
                    description: メタデータ（任意）
                    example: {"category": "sales"}
                  score:
                    type: number
                    format: float
                    description: 類似スコア（1に近いほど類似）
                    example: 0.87
      500:
        description: サーバーエラー
        schema:
          type: object
          properties:
            error_code:
              type: integer
              example: 5000
            error_msg:
              type: string
              example: "Internal Server Error"
    """

# """新しいドキュメント追加""" を以下に変更

    """
    新しいドキュメント追加
    ---
    tags:
      - Document Management
    description: 新しいドキュメントをKnowledge Baseに追加します。
    parameters:
      - in: body
        name: body
        required: true
        schema:
          type: object
          required:
            - knowledge_id
            - content
            - title
          properties:
            knowledge_id:
              type: string
              description: 登録するKnowledge BaseのID
              example: "AAA-BBB-CCC"
            content:
              type: string
              description: 登録するドキュメントの本文
              example: "これはサンプルドキュメントの本文です。"
            title:
              type: string
              description: ドキュメントのタイトル
              example: "サンプルドキュメント.txt"
            metadata:
              type: object
              description: 任意のメタデータ情報
              example: {"category": "example"}
    responses:
      200:
        description: 正常に登録された場合
        schema:
          type: object
          properties:
            status:
              type: string
              example: "success"
            id:
              type: string
              description: 登録されたドキュメントのUUID
              example: "d290f1ee-6c54-4b01-90e6-d701748f0851"
      500:
        description: サーバーエラー（登録に失敗した場合）
        schema:
          type: object
          properties:
            error_code:
              type: integer
              example: 5000
            error_msg:
              type: string
              example: "Internal Server Error"
    """

# """ドキュメントを削除""" を以下に変更

    """
    ドキュメントを削除
    ---
    tags:
      - Document Management
    description: 指定されたドキュメントIDを削除します。
    parameters:
      - in: body
        name: body
        required: true
        schema:
          type: object
          required:
            - id
          properties:
            id:
              type: string
              description: 削除対象のドキュメントID（UUID形式）
              example: "d290f1ee-6c54-4b01-90e6-d701748f0851"
    responses:
      200:
        description: 正常に削除された場合
        schema:
          type: object
          properties:
            status:
              type: string
              example: "success"
            id:
              type: string
              description: 削除されたドキュメントのID
      500:
        description: サーバーエラー（削除に失敗した場合）
        schema:
          type: object
          properties:
            error_code:
              type: integer
              example: 5000
            error_msg:
              type: string
              example: "Internal Server Error"
    """
```

　
ウェブページを表示
```
http://192.168.1.13:8000/apidocs/
```

　
画面
　
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3956789/73e2b44d-8a78-43cf-a761-e82e107f47c6.png" width=60%>

　
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3956789/176ad2dd-a507-4f73-b755-dd81883273a6.png" width=60%>

　
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3956789/4dbe6a89-38d4-4c69-8e1c-5d9210ed0fa9.png" width=60%>

　
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3956789/f8cb7769-f3ed-4ff3-95ed-a833cf851a7a.png" width=60%>

　

/add の「Try it out」ボタンを押下したとき

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3956789/840c2641-2b29-4e07-8b07-2882e2d9657e.png" width=60%>


content に改行を入れる際は「￥n」を記入します（JSON の仕様）。テキストエディタで編集するときは、正規表現を利用して「￥r￥n」を「￥￥n」（￥を2つ）に置換します。
「"」は「￥￥"」に置換します。
タブは空白に置換します。正規表現を利用して「￥t」を「　　」（半角空白4つ）に置換します。
「￥」（バックスラッシュ）は「￥￥」（￥を2つ）に置換します。



## 登録データの削除
rm faiss.index
rm doc_id_map.json
rm vector_map.npy # vector_map を使用している場合

psql -U webmaster -d webmaster -h localhost
DELETE FROM knowledge_documents;


## 登録データのバックアップ
mkdir backup

cp faiss.index ./backup/faiss_＄(date '+%Y%m%d_%H%M%S').index
cp doc_id_map.json ./backup/doc_id_map_＄(date '+%Y%m%d_%H%M%S').json
cp vector_map.npy ./backup/vector_map_＄(date '+%Y%m%d_%H%M%S').npy # vector_map を使用している場合

pg_dump -U webmaster -d webmaster -t knowledge_documents -F p -f ./backup/knowledge_documents_＄(date '+%Y%m%d_%H%M%S').sql

（「＄」は半角文字）



## Dify の構築

Ubuntu に Dify を構築する方法については、次のウェブページに記載しています。以下には、作業の順序と、Ubuntu Server で異なる部分のみ示します。

https://qiita.com/sato_7411/items/8c2bd2333b7660c65125

Docker をインストールします。今回は Ubuntu Server を使用しており、Docker Desktop は使用しないので、インストール作業のうち前半部分のみ行います。

```console:putty 上にて
sudo apt-get update
sudo apt-get install ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
sudo chmod a+r /etc/apt/keyrings/docker.asc
```

```console
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}") stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
```

```console
sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
```

途中で選択画面が表示されますが、デフォルトのまま「OK」で進みます

動作することを確認します

```console
sudo docker run hello-world
```

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3956789/c51f94e0-9995-45a8-8a7e-0ac4b8bdf3c7.png" width=70%>

　
Dify をインストールします。

mkdir Repos
cd Repos

git clone https://github.com/langgenius/dify.git
cd dify/docker
cp .env.example .env
docker compose up -d


## delete で OpenAI API の再ベクトル化を行わない方法

以下のように変更します。

```python:faiss_service.py (3/7) 初期化
# doc_id_map の下
vector_map = {}

# それぞれ追加
# ロード
    vectors = np.load("vector_map.npy")
    vector_map = {doc_id: vectors[i] for i, doc_id in enumerate(doc_id_map)}
# 新規作成（上記で初期化）
# 保存
    vectors = np.array([vector_map[doc_id] for doc_id in doc_id_map], dtype=np.float32)
    np.save("vector_map.npy", vectors)    
```

```python:faiss_service.py (5/7)　
# def add()
# doc_id_map.append(new_id) の下
vector_map[new_id] = vector
```

```python:faiss_service.py (6/7)
@app.route('/delete', methods=['POST'])
def delete():
    """ドキュメントを削除"""
    try:
        data = request.get_json()
        doc_id = data['id'] # 削除対象のUUID文字列
        # knowledge が複数ある場合は、複数 faiss_service_n.py を作成して、ポートで分ける

        # 関数内で値を変更しする際は、global宣言が必要
        global gpu_index
        global doc_id_map
        global vector_map

        # PostgreSQLから削除
        cur = db_conn.cursor()
        cur.execute("DELETE FROM knowledge_documents WHERE id = %s", (doc_id,))
        db_conn.commit()

        # doc_id_map から削除対象の index を取得
        if doc_id not in doc_id_map or doc_id not in vector_map:
            return jsonify({"error_code": 4004, "error_msg": "Document ID not found"}), 404
        index_to_remove = doc_id_map.index(doc_id)

        # doc_id_map から削除し、vector_map からも削除
        doc_id_map.pop(index_to_remove)
        del vector_map[doc_id]

        # FAISS 再構築
        dimension = 1536
        index = faiss.IndexFlatL2(dimension)
        gpu_index = faiss.index_cpu_to_gpu(res, 0, index)

        for kept_id in doc_id_map:
            vector = vector_map[kept_id]
            gpu_index.add(np.array([vector]))

        # 保存
        save_faiss()

        return jsonify({"status": "success", "id": doc_id})

    except Exception as e:
        return jsonify({"error_code": 5000, "error_msg": str(e)}), 500
        
```

## ドキュメント一覧を表示

```python:faiss_service.py (6と7の間)
@app.route('/list', methods=['GET'])
def list():
    """
    ドキュメント一覧を表示
    ---
    tags:
      - Document Management
    summary: 登録されたドキュメントの一覧を表示します
    description:
      登録されている全ドキュメントを取得し、index、doc_id、knowledge_id、title、文字数、metadata、作成日を表示します。  
      さらに、データベース内レコード数、doc_id_map数、vector_map数の統計も返します。
    responses:
      200:
        description: ドキュメント一覧と統計情報
        schema:
          type: object
          properties:
            documents:
              type: array
              description: ドキュメントのリスト
              items:
                type: object
                properties:
                  index:
                    type: integer
                    description: リスト内の連番
                    example: 0
                  doc_id:
                    type: string
                    description: ドキュメントの一意ID
                    example: "abc123"
                  knowledge_id:
                    type: string
                    description: ナレッジベースのID
                    example: "AAA-BBB-CCC"
                  title:
                    type: string
                    description: ドキュメントのタイトル
                    example: "https://sample.com"
                  char_number:
                    type: integer
                    description: コンテンツの文字数
                    example: 1342
                  metadata:
                    type: object
                    description: メタデータ情報（JSON形式）
                    example: { "filename": "Sample.txt" }
                  created_at:
                    type: string
                    format: datetime
                    description: 作成日時
                    example: "2024-07-10 12:34:56"
            summary:
              type: object
              description: 登録数の統計
              properties:
                database_records:
                  type: integer
                  description: データベースのドキュメント総数
                  example: 25
                doc_id_map_count:
                  type: integer
                  description: メモリ上のdoc_id_map件数
                  example: 25
                vector_map_count:
                  type: integer
                  description: メモリ上のvector_map件数
                  example: 25
      500:
        description: サーバー内部エラー
        schema:
          type: object
          properties:
            error_code:
              type: integer
              example: 5000
            error_msg:
              type: string
              example: "Database connection failed"
    """
    global doc_id_map
    global vector_map

    try:
        cur = db_conn.cursor()
        cur.execute("SELECT id, knowledge_id, title, content, metadata, created_at FROM knowledge_documents ORDER BY created_at ASC")
        rows = cur.fetchall()

        documents = []
        for index, row in enumerate(rows):
            doc_id, knowledge_id, title, content, metadata, created_at = row
            documents.append({
                "index": index,
                "doc_id": doc_id,
                "knowledge_id": knowledge_id,
                "title": title,
                "char_number": len(content or ""),
                "metadata": metadata or {},
                "created_at": created_at.strftime("%Y-%m-%d %H:%M:%S")
            })

        summary = {
            "database_records": len(rows),
            "doc_id_map_count": len(doc_id_map),
            "vector_map_count": len(vector_map)
        }

        return jsonify({
            "documents": documents,
            "summary": summary
        })

    except Exception as e:
        return jsonify({"error_code": 5000, "error_msg": str(e)}), 500
        
```


## テキストファイルをアップロードして登録

テキストファイルをアップロードして登録します。各ファイルについてタイトルを指定します。省略時はファイル名がタイトルになります。Dify で検索したとき、引用元としてタイトルが表示されます。ファイル名は metadata に保存されます。

```python:faiss_service.py (7の手前)
@app.route('/upload_files', methods=['POST'])
def upload_files():
    """
    テキストファイルをアップロードして登録
    ---
    tags:
      - Document Management
    summary: テキストファイルをアップロードしてKnowledge Baseに登録します。
    consumes:
      - multipart/form-data
    parameters:
      - in: formData
        name: knowledge_id
        type: string
        required: true
        description: 全ファイル共通の knowledge_id
        example: "AAA-BBB-CCC"
      - in: formData
        name: files
        type: array
        items:
          type: file
          format: binary
        required: true
        description: 最大20個のテキストファイル
        allowMultiple: true
      - in: formData
        name: titles
        type: array
        items:
          type: string
        required: false
        description: 各ファイルのタイトル（最大20件まで、省略時はファイル名になります）
    responses:
      200:
        description: 登録成功時
        schema:
          type: object
          properties:
            status:
              type: string
              example: "success"
            titles_uploaded:
              type: array
              items:
                type: string
              example:
                - "https://en.wikipedia.org/wiki/Curling_at_the_2022_Winter_Olympics"
            files_uploaded:
              type: array
              items:
                type: string
              example:
                - "CurlingAtThe2022WinterOlympics.txt"
            uploaded_doc_ids:
              type: array
              items:
                type: string
              example:
                - "4469d7e6-f5be-44a8-9808-a17c96b05e53"
    """
    global gpu_index, doc_id_map, vector_map

    # 入力取得
    knowledge_id = request.form.get('knowledge_id')
    files = request.files.getlist('files')
    titles_raw = request.form.get('titles', '')
    title_list = [t.strip() for t in titles_raw.split(',')] if titles_raw else []

    if not knowledge_id:
        return jsonify({"error": "knowledge_id is required"}), 400

    if not files or len(files) == 0:
        return jsonify({"error": "No files uploaded"}), 400

    if len(files) > 20:
        return jsonify({"error": "Maximum 20 files allowed"}), 400

    added_titles = []
    added_docs = []
    added_doc_ids = []

    for i, file in enumerate(files):
        try:
            filename = file.filename
            content = file.read().decode('utf-8')  # テキストファイル前提

            # 改行・タブ・クォート・バックスラッシュを置換
            content = content.replace("\r\n", "\n") \
                             .replace("\\", "\\\\") \
                             .replace("\"", "\\\"") \
                             .replace("\t", "    ")

            title = title_list[i] if i < len(title_list) and title_list[i] else filename

            # OpenAI APIで埋め込みベクトルを取得
            response = client.embeddings.create(
                input = content,
                model = "text-embedding-3-small"
            )
            vector = np.array(response.data[0].embedding, dtype=np.float32)

            # UUID生成
            doc_uuid = str(uuid.uuid4())

            # PostgreSQLに登録
            cur = db_conn.cursor()
            cur.execute("""
                INSERT INTO knowledge_documents (id, knowledge_id, title, content, metadata)
                VALUES (%s, %s, %s, %s, %s)
            """, (doc_uuid, knowledge_id, title, content, json.dumps({"filename": filename})))
            db_conn.commit()

            # FAISS に追加
            gpu_index.add(np.array([vector]))
            doc_id_map.append(doc_uuid)
            vector_map[doc_uuid] = vector

            added_titles.append(title)
            added_docs.append(filename)
            added_doc_ids.append(doc_uuid)

        except Exception as e:
            return jsonify({"error": f"Failed to process {file.filename}: {str(e)}"}), 500

    # 保存
    save_faiss()

    return jsonify({
        "status": "success",
        "titles_uploaded": added_titles,
        "files_uploaded": added_docs,
        "uploaded_doc_ids": added_doc_ids
    })

```


## ドキュメントのタイトルを変更する

ドキュメントのタイトルを変更します。タイトルは PostgreSQL データベースに保存されており、ベクトルデータとはドキュメントIDで結び付けられているため、後からでも変更可能です。タイトルはチャットボットの回答において、引用情報として表示されます。

```python:faiss_service.py (7の手前)
@app.route('/edit_title', methods=['POST'])
def edit_title():
    """
    ドキュメントのタイトルを更新
    ---
    tags:
      - Document Management
    description: 指定されたドキュメントIDのタイトルを更新します。チャットボットの回答では、タイトルが引用情報として使用されます。
    parameters:
      - in: body
        name: body
        required: true
        schema:
          type: object
          required:
            - id
            - title
          properties:
            id:
              type: string
              description: 変更対象のドキュメントUUID
              example: "d290f1ee-6c54-4b01-90e6-d701748f0851"
            title:
              type: string
              description: 新しいタイトル
              example: "2023年Q1 売上まとめ.txt"
    responses:
      200:
        description: タイトルの更新成功
        schema:
          type: object
          properties:
            status:
              type: string
              example: "success"
            id:
              type: string
              example: "d290f1ee-6c54-4b01-90e6-d701748f0851"
      404:
        description: 指定されたIDが存在しない場合
      500:
        description: サーバーエラー
    """
    try:
        data = request.get_json()
        doc_id = data['id']
        new_title = data['title']

        cur = db_conn.cursor()
        cur.execute("""
            UPDATE knowledge_documents
            SET title = %s
            WHERE id = %s
        """, (new_title, doc_id))

        if cur.rowcount == 0:
            return jsonify({"error_code": 404, "error_msg": "指定されたIDのドキュメントが見つかりません。"}), 404

        db_conn.commit()
        return jsonify({"status": "success", "id": doc_id})

    except Exception as e:
        return jsonify({"error_code": 5000, "error_msg": str(e)}), 500
        
```