概要
- 独自文書質疑でよく使われる、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(単語をバラバラに組み替え)
昨日、兄は田中とカラオケに行った。
今日、 妹は鈴木とドライブで楽しむ。
明日、私は両親と遊園地で遊んだ。
読み取れることは以下の通り。
- クエリ文が先頭にあるほど、距離が近くなる、つまり検索されやすくなる
- 単語をバラバラに組み替え、クエリ文の意味を含まなくなっても、距離が遠くならない
- 意味そのものではなく、BoW(単語集合)的な性格が強い
本当に先頭ほど重視されるか追試
前章の文章は短すぎるので、実利用時のより長い文章でも同じ傾向か見てみる。
約2000文字の青空文書の桜の木の下にはを題材にして距離を測る。
前半のみ
桜の樹の下には~その液体を吸っている。
後半のみ
何があんな花弁を~呑のめそうな気がする。
原文
前半 → 後半
逆文
後半 → 前半
単語としては全く同じものを含んでいる原文と逆文の距離より、半分欠けている前半と原文ペア、後半と逆文ペアの方がはるかに近い事が分かる。短文だけでなく長文でも同様に、OpenAI社のEmbedding(Ada v2)は文章の先頭ほど重視したベクトルを生成している様だ。
単純なBoWとの違いを検証
ChatGPTなど大規模言語モデル(LLM)は、文章解析中に関連する他の場所の情報を引っ張ってくるattentionという仕組みを持つ。これにより例えば指示代名詞の解決が可能だ。
クエリ文
今日、私は田中とカラオケで遊んだ。
検索対象Mix(単語をバラバラに組み替え)
昨日、兄は田中とカラオケに行った。
今日、 妹は鈴木とドライブで楽しむ。
明日、私は両親と遊園地で遊んだ。
検索対象Mix+代名詞
昨日、兄は田中とカラオケに行った。
今日、 妹も彼女とそこで楽しむ。
明日、私は両親と遊園地で遊んだ。
検索対象Mix+代名詞解決
昨日、兄は田中とカラオケに行った。
今日、 妹も田中とカラオケで楽しむ。
明日、私は両親と遊園地で遊んだ。
クエリ文の単語が多くなる「検索対象Mix+代名詞解決」が距離が近くなるのは当然として、クエリ文の単語自体は多くならない「検索対象Mix+代名詞」もその半分程度「検索対象Mix」に比べて距離が近くなっていることが分かる。指示代名詞の面では、単なるBoWではない意味に近づいたベクトルにできている事が分かる。