【GPT】結論ファーストはAIにも大事だった

# 概要

* 独自文書質疑でよく使われる、OpenAI Embedding(Ada v2)ベクトル変換の特徴を見た
* 一番大きな特徴は、**文章の先頭ほど重視**される点

:::note info
ポイント
先頭に形式的な挨拶とかがあるとノイズになる
検索対象の前処理で、大事な部分が先頭になる様に分割など調整するとベター
:::


# 背景

ChatGPTなど大規模言語モデル（LLM）は、非公開や学習時期より後の情報を知らない。そのままではこれらについて適切に回答できないが、代表的な２つの対策がある。
* **Fine-Tune**
  * LLMネットワーク自体を追加で学習させ、LLM自体が知っている状態にする方法
  * LLMの学習が高コスト
  * 利用時は質問するだけで良いので、低コスト
* **In-Context Learinig**
  * 質問と同時与えたカンペを参考に、LLMに回答させる方法
  * LLM自体の学習は不要
  * 利用時は質問以外にカンペを与える必要があり、高コスト

ChatGPTなど、OpenAIの新しいモデルはFine-Tune非対応であり、独自文書質疑をする場合にはIn-Context Learningしか選択肢が無くなる。この場合、質問文に関連する文書を、どこからか検索してきてカンペとして与えるのが一般的だ。

https://qiita.com/kuromiya123/items/01c2807288c055f017b9

https://qiita.com/windows222/items/232f05bafa95a9c8874e

検索は、関連文書さえ見つかれば何でも良いが、お手軽にある程度の性能が出せるため、ChatGPTと同じOpenAI社のEmbedding(Ada v2)によるベクトル類似検索が使われる事が多い。

https://qiita.com/akeyhero/items/ce371bfed64399027c23

数値的にはかなり良い様だが、実際に使っていると、良い場合と悪い場合のブレが大きい様に感じた。そこで、どういう文書をどう似ていると判断しているかを検証してみる。

:::note info
検証環境
OpenAI社のEmbedding(Ada v2)でベクトル化したデータを、ChromaベクトルDBで類似度検索させた。Chromaは検証に必須ではないが、手頃なベクトルDBとして利用。
:::

<details><summary>サンプルコード</summary>

```python
import argparse
import chromadb
import chromadb.config
import glob
import logging
import numpy as np
import os
import openai
import sys
from tenacity import (
    before_sleep_log,
    retry,
    retry_if_exception_type,
    stop_after_attempt,
    wait_exponential,
)
import tiktoken
from typing import Callable


# ベクトル変換器初期化
# 環境変数 OPENAI_API_KEYを事前に設定しておくこと
# フラグをFalseにすると、Chroma内部のベクトル変換を利用可能
# ざっと試したところ、少なくとも日本語に関してはOPENAIの方が良さげ
USE_OPENAI_MODEL = True
EMBED_MODEL = 'text-embedding-ada-002'
ENCODING = tiktoken.encoding_for_model(EMBED_MODEL)

# リトライ付きベクトル変換
# 参考：https://github.com/hwchase17/langchain/blob/master/langchain/embeddings/openai.py
def embed_with_retry(text: str) -> list[float]:
    retry_decorator = _create_retry_decorator()

    @retry_decorator
    def _embed_with_retry() -> list[float]:
        return openai.Embedding.create(
            input=text,
            model=EMBED_MODEL
        )["data"][0]["embedding"]

    return _embed_with_retry()

def _create_retry_decorator() -> Callable[[], list[float]]:
    min_seconds = 4
    max_seconds = 10
    max_retries = 6
    # Wait 2^x * 1 second between each retry starting with
    # 4 seconds, then up to 10 seconds, then 10 seconds afterwards
    return retry(
        reraise=True,
        stop=stop_after_attempt(max_retries),
        wait=wait_exponential(multiplier=1, min=min_seconds, max=max_seconds),
        retry=(
            retry_if_exception_type(openai.error.Timeout)
            | retry_if_exception_type(openai.error.APIError)
            | retry_if_exception_type(openai.error.APIConnectionError)
            | retry_if_exception_type(openai.error.RateLimitError)
            | retry_if_exception_type(openai.error.ServiceUnavailableError)
        ),
        before_sleep=before_sleep_log(logger, logging.WARNING),
    )


def collection_has_id(collection, id: str) -> bool:
    return 0 < len(collection.get(ids=id).get('ids'))


def count_token(text: str) -> int:
    return len(ENCODING.encode(text, allowed_special="all"))


# パラメタ解析
parser = argparse.ArgumentParser(description='Embedding vector analysis')
parser.add_argument('vector_store_path', metavar='V', type=str, nargs=1,
                    help='Vector store for load and save vectors')
parser.add_argument('-c', dest='clear_vector_store', action='store_true',
                    help='Clear vector store at start')
parser.add_argument('-i', dest='input_text_folder', action='store', default=None,
                    help='Adding documents for analysis')
args = parser.parse_args()
vector_store_path = args.vector_store_path[0]
clear_vector_store = args.clear_vector_store
input_text_folder = args.input_text_folder

# loggerの設定
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
for handler in logger.handlers[:]:
    logger.removeHandler(handler)
logger.addHandler(logging.StreamHandler(sys.stdout))

# chroma初期化
db_setting = chromadb.config.Settings(
    chroma_db_impl='duckdb+parquet',
    persist_directory=vector_store_path,
)
db = chromadb.Client(db_setting)
collection = db.get_or_create_collection('default')
if clear_vector_store:
    collection.delete()
logger.info(f'Load {collection.count()} documents')

# 新文書があればベクトル取得して追加
if input_text_folder:
    files = glob.glob(f'{input_text_folder}/*.txt')
    logger.info(f'Add {len(files)} documents')
    for file in files:
        name = os.path.splitext(os.path.basename(file))[0]
        with open(file, encoding='utf-8') as f:
            text = f.read()
            logger.info(f'{name} has {count_token(text)} tokens')
            if collection_has_id(collection, name):
                # 既にあるので無視
                logger.info(f'Skip an existing file : {name}')
                continue
            if USE_OPENAI_MODEL:
                # OPENAIのモデルでベクトル変換
                embed = embed_with_retry(text)
                collection.add(embeddings=embed, ids=name, documents=text)
            else:
                # Chroma内部のベクトル変換利用
                collection.add(ids=name, documents=text)

# 文書を解析
# Chromaからembeddingsを取得する方法が、今のところこれしか無さそう
# 参考：https://stackoverflow.com/questions/76379440/how-to-see-the-embedding-of-the-documents-with-chroma-or-any-other-db-saved-in
col_size = collection.count()
ids = collection.peek(col_size).get('ids')
embeds = collection.peek(col_size).get('embeddings')
for id, embed in zip(ids, embeds):
    logger.info(id)
    res = collection.query(query_embeddings=[embed], n_results=col_size)
    for id, dist in zip(res.get('ids')[0], res.get('distances')[0]):
        logger.info(f'{id} {dist}')
    '''
    for i, e in zip(ids, embeds):
        # OpenAI embeddingはnorm = 1に正規化されているので、内積類似度 = cos類似度
        # Chromaのデフォルト類似度（距離）は、1-cosの2倍らしい
        # logger.info(f'{i} {2-2 * np.dot(embed, e)}')
    '''
```
</details>


# 結果

## 短文の位置による影響

:::note warn
クエリ文
今日、私は田中とカラオケで遊んだ。
:::

:::note info
検索対象１（１行目がクエリ文）
**今日、私は田中とカラオケで遊んだ。**
昨日、兄は鈴木とドライブに行った。
明日、妹は両親と遊園地で楽しむ。
:::

:::note info
検索対象２（２行目がクエリ文）
昨日、兄は鈴木とドライブに行った。
**今日、私は田中とカラオケで遊んだ。**
明日、妹は両親と遊園地で楽しむ。
:::

:::note info
検索対象３（３行目がクエリ文）
昨日、兄は鈴木とドライブに行った。
明日、妹は両親と遊園地で楽しむ。
**今日、私は田中とカラオケで遊んだ。**
:::

:::note info
検索対象Mix（単語をバラバラに組み替え）
昨日、兄は**田中とカラオケ**に行った。
**今日、** 妹は鈴木とドライブで楽しむ。
明日、**私は**両親と遊園地**で遊んだ。**
:::

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1747214/4291a1d8-6bb9-0682-e225-8ac6144f98b6.png)

読み取れることは以下の通り。
* クエリ文が先頭にあるほど、距離が近くなる、つまり検索されやすくなる
* 単語をバラバラに組み替え、クエリ文の意味を含まなくなっても、距離が遠くならない
  * 意味そのものではなく、BoW（単語集合）的な性格が強い

## 本当に先頭ほど重視されるか追試

前章の文章は短すぎるので、実利用時のより長い文章でも同じ傾向か見てみる。
約2000文字の青空文書の[桜の木の下には](https://www.aozora.gr.jp/cards/000074/files/427_19793.html)を題材にして距離を測る。

:::note info
前半のみ
桜の樹の下には～その液体を吸っている。
:::

:::note info
後半のみ
何があんな花弁を～呑のめそうな気がする。
:::

:::note info
原文
前半　→　後半
:::

:::note info
逆文
後半　→　前半
:::

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1747214/00929863-362f-2794-bf7d-d7ba44657ebb.png)

単語としては全く同じものを含んでいる原文と逆文の距離より、半分欠けている前半と原文ペア、後半と逆文ペアの方がはるかに近い事が分かる。短文だけでなく長文でも同様に、**OpenAI社のEmbedding(Ada v2)は文章の先頭ほど重視したベクトルを生成**している様だ。

## 単純なBoWとの違いを検証

ChatGPTなど大規模言語モデル（LLM）は、文章解析中に関連する他の場所の情報を引っ張ってくるattentionという仕組みを持つ。これにより例えば[指示代名詞の解決](https://www.researchgate.net/figure/An-example-of-an-attention-map-between-source-and-context-On-the-y-axis-are-the-source_fig3_332341730)が可能だ。

:::note warn
クエリ文
今日、私は田中とカラオケで遊んだ。
:::

:::note info
検索対象Mix（単語をバラバラに組み替え）
昨日、兄は**田中とカラオケ**に行った。
**今日、** 妹は鈴木とドライブで楽しむ。
明日、**私は**両親と遊園地**で遊んだ。**
:::

:::note info
検索対象Mix＋代名詞
昨日、兄は**田中とカラオケ**に行った。
**今日、** 妹も**彼女とそこ**で楽しむ。
明日、**私は**両親と遊園地**で遊んだ。**
:::

:::note info
検索対象Mix＋代名詞解決
昨日、兄は**田中とカラオケ**に行った。
**今日、** 妹も**田中とカラオケ**で楽しむ。
明日、**私は**両親と遊園地**で遊んだ。**
:::

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1747214/b352a5f4-ae16-dabd-6607-3c665fb21770.png)

クエリ文の単語が多くなる「検索対象Mix＋代名詞解決」が距離が近くなるのは当然として、クエリ文の単語自体は多くならない「検索対象Mix＋代名詞」もその半分程度「検索対象Mix」に比べて距離が近くなっていることが分かる。**指示代名詞の面では、単なるBoWではない意味に近づいたベクトルにできている**事が分かる。