見出し画像

langchain0.1.0以降を復習します。RAGとチャンクの見直し。

まずは環境

langchainはアップデートのたびに動かなくなるんで、まずはバージョンなど見ていきます。

0.0.220がこのwindowsにはインストールされていました。
pip install --upgrade langchain
pip install --upgrade langchain-openai
pip install --upgrade faiss-cpu #ベクトルDB作ってRAG用

pip show langchain langchain-openai faiss-cpu

こんな感じで最新環境にしていきます。

langchainは更新によるコードエラーが非常に多いです。0.1.0でlangchain-coreという概念ができたので今後は安定すると思い、このタイミングで今回はおさらいしてみます。
もちろん、今後以下のコードが動かなくなる可能性もあります、仮想環境などを作り、venv環境でしたら、
pip install -r requirements.txtということになりますので、langchainはつねにpip freeze > requirements.txtでバージョンを吐き出しておくのがいいのかと思います。

#requirements.txt
langchain==0.1.9
langchain-openai==0.0.7
faiss-cpu==1.7.4



本家のquickstartはここ

一つ一つのモジュールを見ながらしっかり学ぶならこちらから。

また、npaka先生がスターターキットを書いてくれています!

今回はRAGのハンズオンとして書いていきます。



langchain0.1.0以降のLCEL文法の基本

LCEL(LangChain Expression Language)だそうです

chainをパイプでつないで作る。
chain.invoke()
このような表記なんですね。

llm = ChatOpenAI()
prompt = ChatPromptTemplate.from_messages([
    ("system", "You are world class technical documentation writer."),
    ("user", "{input}")
])
query="??????"

chain = prompt | llm 
chain.invoke({"input": query})



とりあえずRAGを動かす①ベクトル化

大谷翔平選手のwikipediaをloaderで読み込んで
テキストスプリッターでチャンク分割、
OpenAIエンベッディングでFAISS indexにベクトルデータベースを作成、
レトリーバーの設定はとりあえず参照文献4個
ひととおりの手順でこんな感じになります。

from langchain.text_splitter import CharacterTextSplitter
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.vectorstores import FAISS
from langchain_openai import OpenAIEmbeddings
from langchain_community.document_loaders import WebBaseLoader
import os

loader=WebBaseLoader("https://ja.wikipedia.org/wiki/%E5%A4%A7%E8%B0%B7%E7%BF%94%E5%B9%B3")
documents = loader.load()

text_splitter = CharacterTextSplitter(separator="\n", chunk_size=1000, chunk_overlap=0) #text_splitter  = CharacterTextSplitter(separator="\n\n", chunk_size=300, chunk_overlap=30)#markdownなど成形されたテキストはこっち? #text_splitter  = RecursiveCharacterTextSplitter(chunk_size=300, chunk_overlap=30)


texts = text_splitter.split_documents(documents) #embeddings  = OpenAIEmbeddings(openai_api_key=os.environ['OPENAI_API_KEY'],model="text-embedding-3-small")
embeddings = OpenAIEmbeddings(openai_api_key=os.environ['OPENAI_API_KEY'],model="text-embedding-ada-002")
db = FAISS.from_documents(texts, embeddings)

retriever = db.as_retriever(search_kwargs={"k": 4})
Created a chunk of size 1046, which is longer than the specified 1000

#1個だけサイズがはみ出たチャンクがあるそうです。

細かな説明は後ほどにします。コードの解説だけしますと、
チャンクサイズとsearch_kwargs={"k": 4}を調整して回答をうまく引き出せるかをトライしていく感じです。CharacterTextSplitterはセパレータ"\n"で分割したものをチャンクサイズになるまで結合、RecursiveCharacterTextSplitterはチャンクサイズになるまで再帰的に分割。
エンベディングはopenaiのものを利用しました。先のアップデートでmodel="text-embedding-3-small"というエンベディングモデルが出て、コスト1/6だそうですがまだエラーが出ますね。ada002というモデルを置きました。
index DBはFAISSという使いやすいものを利用。

len(texts)
160

ドキュメントが160に分割されdbのなかにベクトル化されました。

RAGをもりもり回すとこんな割合のコスト感です



とりあえずRAGを動かす②クエリー部

query = "大谷翔平の所属チーム?"

context_docs = retriever.get_relevant_documents(query)
print(f"参照docs数 = {len(context_docs)}")
context_docs[0].page_content

from langchain.chains import RetrievalQA
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import PromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_openai import ChatOpenAI

prompt = PromptTemplate.from_template(
    """以下の文脈だけを踏まえて質問に回答してください。

{context}

Question: {question}
"""
)
 #model  = ChatOpenAI(openai_api_key=os.environ['OPENAI_API_KEY'],model_name="gpt-3.5-turbo-16k", temperature=0) #model  = ChatOpenAI(openai_api_key=os.environ['OPENAI_API_KEY'],model_name="gpt-4-0125-preview", temperature=0)
model = ChatOpenAI(openai_api_key=os.environ['OPENAI_API_KEY'],model_name="gpt-3.5-turbo-0125", temperature=0)

chain = (
    {"context": retriever, "question": RunnablePassthrough()}
    | prompt
    | model
    | StrOutputParser()
)

result = chain.invoke(query)
print(result)
参照docs数 = 4
大谷翔平はMLBのロサンゼルス・エンゼルスに所属しています。

さっそく微妙な回答ですので、クエリを変更

query = "大谷翔平の2024所属チーム?"
参照docs数 = 4
2024年に大谷翔平が所属するチームはロサンゼルス・ドジャースです。

うまく回答が引き出せました。便利ですね。

参照しているドキュメント



ここまでのRAGの構造

これで一通りRAGは動くわけですが、大まかに分けていくと
「loader」
「splitter」
「embedding」
「retriever」
「query」
このようになっていると思います。
RAGをやるにあたって検索データベースにクエリを投げて関連文書を引っ張ってきてLLMに渡し、回答生成という流れです。
上記の流れで言うと
loaderはWebBaseLoaderというものを使って
splitterは文字ベースで分割するCharacterTextSplitter、
embeddingはOpenAIのtext-embedding-ada-002というモデル、
retrieverはVector store-backed retriever、
それぞれにlangchainの様々なモジュールが用意され色々なことができるようになっています。
ここまで読んだ方は公式に行ってみるとかなりDOCが読めるようになっているのではないでしょうか。

これをchainすることによってlangchainはとても便利で多彩なコードを書けるように頻繁なアップデートを繰り返してきました。あまりに早く進化していくので途中でついていけなくなったりもしたんですが、いよいよ0.1.0以降は安定しそうなんでここでやり直すのはチャンスかと思われます。



ところでチャンクって何?

prompt = PromptTemplate.from_template(
    """以下の文脈だけを踏まえて質問に回答してください。

{context}

Question: {question}
"""
)

ここの{context}に入るのがレトリバーk=4で引っ張ってきたチャンクです。一度クエリーの内容をレトリバーが検索して近しいと思われる文章の塊を提示してくれたものをコンテキストとしてLLMにinputし、クエリーの回答を生成する流れです。今回はこのチャンクを視認していきたいと思います。

テキスト分割>エンベディング>ベクトルデータベース
エンベディングでわずかですがopenaiのAPI課金されます。RAGをやるにあたって、チャンク数とオーバラップを調整するのはみなさん一度は悩まれることかと思います。自分でも調べた限りチャンキング戦略でのベストプラクティスというのはあまり共有されていなかったように感じたので、扱う文章によってケースバイケースでみなさん設定されていることかと思います。参考にしたのは以下のようなサイトでした。


https://chunkviz.up.railway.app/

作ったツール

from langchain.text_splitter import CharacterTextSplitter, RecursiveCharacterTextSplitter
from langchain_community.document_loaders import TextLoader
import gradio as gr
import tempfile
import os

def process_text(input_text, splitter_type, separator, chunk_size, chunk_overlap):
    # 一時ファイルを作成し、入力テキストを書き込む
    with tempfile.NamedTemporaryFile(delete=False, mode='w', encoding='utf-8') as temp_file:
        temp_file_path = temp_file.name
        temp_file.write(input_text)

    # TextLoaderを使用してドキュメントをロード
    loader = TextLoader(temp_file_path)
    documents = loader.load()

    # ユーザーが選択したパラメータでスプリッターを初期化
    if splitter_type == "CharacterTextSplitter":
        text_splitter = CharacterTextSplitter(separator=separator, chunk_size=chunk_size, chunk_overlap=chunk_overlap)
    elif splitter_type == "RecursiveCharacterTextSplitter":
        text_splitter = RecursiveCharacterTextSplitter(chunk_size=chunk_size, chunk_overlap=chunk_overlap)

    texts = text_splitter.split_documents(documents)

    # 一時ファイルを削除
    os.remove(temp_file_path)
    
    # 分割されたテキストの内容を比較し、オーバーラップ部分を特定して表示
    output_html = ""
    colors = ["#CCFFCC", "#CCCCFF", "#FFCCCC", "#FFFF99", "#FFCC99", "#CC99FF", "#99CCFF"]  # 色のバリエーション

    for i, doc in enumerate(texts):
        current_text = doc.page_content
        color = colors[i % len(colors)]  # チャンクのインデックスに応じて色を選択

        if i < len(texts) - 1:  # 最後のチャンクを除く
            next_text = texts[i + 1].page_content
            # オーバーラップ部分を検出
            overlap = ""
            for j in range(1, min(len(current_text), len(next_text), chunk_overlap) + 1):
                if current_text.endswith(next_text[:j]):
                    overlap = next_text[:j]
                    break
            
            if overlap:
                non_overlap_part = current_text[:-len(overlap)]
                output_html += f"<div style='background-color:{color};'>{non_overlap_part}</div>"
                output_html += f"<div style='background-color:#FF9999;'>{overlap}</div><br/>"
            else:
                output_html += f"<div style='background-color:{color};'>{current_text}</div><br/>"
        else:
            # 最後のチャンク
            output_html += f"<div style='background-color:{color};'>{current_text}</div>"

    return output_html



# Gradioインターフェースの構築
iface = gr.Interface(
    fn=process_text,
    inputs=[
        gr.Textbox(label="Input Text", lines=10, placeholder="Paste your text here..."),
        gr.Dropdown(
            label="Splitter Type", 
            choices=["CharacterTextSplitter", ("RecursiveCharacterTextSplitter(separatorは無視します)","RecursiveCharacterTextSplitter")], 
            value="CharacterTextSplitter"  # デフォルト値を設定
        ),
        gr.Dropdown(
            label="Separator", 
            choices=[("改行コード", "\n"), ("改行コードx2", "\n\n"), ("。", "。")], 
            value="\n"  # デフォルト値を設定
        ),
        gr.Dropdown(
            label="Chunk Size", 
            choices=[100, 300, 500, 1000], 
            value=300  # デフォルト値を設定
        ),
        gr.Dropdown(
            label="Chunk Overlap", 
            choices=[0, 30, 50, 100], 
            value=0  # デフォルト値を設定
        )
    ],
    outputs=gr.HTML(label="Processed Text"),
    title="Color visibility of text division by chunk / For LangChain",
    description="Enter text to split it into chunks. Adjust the splitting parameters and select the splitter type as needed. Each chunk is highlighted with a different color."
)

iface.launch(inbrowser=True)


実行画面

テキストを分割するセパレータを改行や「。」に設定して文章を細切れにした後、langchainのモジュールのアルゴリズムで一塊にしてくれます。チャンクごとに塊に色をつけて視認しやすくしました。本番環境と同じモジュールを使いますので実際にどのように分割されたのかを確認できると思います。オーバーラップがこのように処理されているのかと理解できたので良かったと思います。

オーバーラップの処理

RecursiveCharacterTextSplitterもセパレータの設定がないのでどうやって処理しているのかと疑問でしたが、内部的にうまく処理できるようです。

RAGを使ってやる処理にQAボットなどが考えられますが、まずデータ部分に一問一答を用意して、それをチャンクがバラバラにならないように視認しながら調整してエンコードすると上手に検索できるのかと思いました。

ちなみに英文をCharacterTextSplitterでセパレータを "。" にして実行すると、分割できずに1チャンクになってしまいます。このような挙動も確認できました。

良い感じになった設定でエンベディングしていただければ節約になりますでしょうか。

それではまた。


###参考googleのエンベッディングとレトリバー


from langchain_google_genai import GoogleGenerativeAI
from langchain_google_genai.llms import GoogleGenerativeAI
model = GoogleGenerativeAI(model="gemini-1.0-pro-latest", google_api_key=os.environ['GOOGLE_API_KEY'])

from langchain_google_genai.embeddings import GoogleGenerativeAIEmbeddings
embedding = GoogleGenerativeAIEmbeddings(model="models/embedding-001")

頑張ってやってみたんですが、RAGにコンテキストを突っ込んでもうまく回答を引き出せなかったり、エンベディング>近似したドキュメントを引いてくるあたりの挙動がうまくいかなかった気がします。

エンベディング>openai
クエリー>google
この組み合わせでやっても回答がなしになる頻度が高い
ゆるーくRAGで楽をするのには不向きでした。
個人的な感想ですが。