LoginSignup
5
3

More than 1 year has passed since last update.

概要

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

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

背景

ChatGPTなど大規模言語モデル(LLM)は、非公開や学習時期より後の情報を知らない。そのままではこれらについて適切に回答できないが、代表的な2つの対策がある。

  • Fine-Tune
    • LLMネットワーク自体を追加で学習させ、LLM自体が知っている状態にする方法
    • LLMの学習が高コスト
    • 利用時は質問するだけで良いので、低コスト
  • In-Context Learinig
    • 質問と同時与えたカンペを参考に、LLMに回答させる方法
    • LLM自体の学習は不要
    • 利用時は質問以外にカンペを与える必要があり、高コスト

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

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

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

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

サンプルコード
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)}')
    '''

結果

短文の位置による影響

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

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

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

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

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

image.png

読み取れることは以下の通り。

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

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

前章の文章は短すぎるので、実利用時のより長い文章でも同じ傾向か見てみる。
約2000文字の青空文書の桜の木の下にはを題材にして距離を測る。

前半のみ
桜の樹の下には~その液体を吸っている。

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

原文
前半 → 後半

逆文
後半 → 前半

image.png

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

単純なBoWとの違いを検証

ChatGPTなど大規模言語モデル(LLM)は、文章解析中に関連する他の場所の情報を引っ張ってくるattentionという仕組みを持つ。これにより例えば指示代名詞の解決が可能だ。

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

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

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

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

image.png

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

5
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
3