LangChainを調べる

しむどん 2025-06-09

この記事は、僕が個人的な目的でLangChainを調べる必要に迫られた時の、技術的(もしかしたらそうでもないかも)なメモだ。だから、まとまっていないし、体系的でもないし、そもそも他人に見せる類のものではないのかもしれない。でもこういうものを書く事は好きだから、独り言のように書いていく。

LangChainは、大規模言語モデルを活用しアプリケーションを作るためのフレームワークであり、RAGの実装によく利用される。なお、LangChainを学ぶ目的なら、公式ドキュメントを参照すると良い。

RAGの概要

生成モデルの性能を向上させる技法の一つ。知識ベース、データベースなどから関連情報を検索し、その情報を元にプロンプトを生成し問合せを行う。生成モデルが持つパラメータだけに依存するのではなく、外部の最新情報や専門的な知識を取り入れることができるため、より正確で信頼性の高い応答ができる。

LangChainでやりたい事

僕はあまり覚えたり考えたりする事が得意ではない。だからそのような作業は、LLMに代わりにやってもらいたい。今、個人的なメモがファイルの形で手元にあるため、それらを元にして僕の考えや気持ちを反映し、呼びかけに答えたい。

ただその個人的なメモには、SaaSのLLMに食わせたくないような情報が含まれている可能性もある。またSaaSで提供されているLLMは、その処理に対してどの程度トークンを消費するかは読みにくいし、トークン数の制限などもあるため、そのままそこにデータを投入するという事はしたくない。

そのため生の情報は、個人的に動作させている何らかのシステムで処理し、それを元にプロンプトを作り目視で確認した後、LLMに渡すという流れにしたい。

通常、RAGでは検索結果をそのままLLMに流す。それとは少しやりたい事が異なるけれど、それらも含め、LangChainの内部で使っている技術や便利機能を調べて利用し、自分の欲しいものを作り込んでいく事にする。

簡単な使い方

LangChainの基本的な使い方を確認する。関連するパッケージをインストールする。

pip install transformers accelerate sentencepiece bitsandbytes
pip install langchain langchain_community

LLMとしては、ここではOpenAIのAPIを利用する。そのためAPIキーは事前に発行しておく必要がある。

OPENAI_API_KEY=DUMMY
環境変数にOpenAIのAPIキーを設定する

それではPythonを起動して、コードを動かしながら、動きを見ていく。まず、langchainをインポートしOpenAIを使う準備をする。

import langchain

llm = langchain.OpenAI()
langchainをインポートしOpenAIを使う準備をする

プロンプトを準備する。

template = """
<bos><start_of_turn>user
{query}
<end_of_turn><start_of_turn>model
"""
prompt = langchain.prompts.PromptTemplate.from_template(template)
プロンプトを準備する

チェーンを作成する。

chain = prompt | llm
チェーンを作成する

問い合わせたいクエリの文字列を作成する。

query = "Emacs LispとAIの親和性について考えて"
クエリを作成する

推論を実行する。

answer = chain.invoke({'query':query})
推論を実行する

これにより回答が得られる。もしかしたら、RAGによる検索をするためのデータがまだないから、何も対した回答がされないかもしれない。

Webページを読み込む

RAGのデータとしてWebページを読み込む方法を確認していく。

まずunstructuredをインストールする。

pip install unstructured
unstructuredをインストールする

langchain_community.document_loaders.UnstructuredURLLoader を使うとWebページのデータをダウンロードし内部に保持してくれる。

import langchain_community.document_loaders

urls = [
  'https://www.symdon.info/',
]

# ウェブページの内容を読込する
loader = langchain_community.document_loaders.UnstructuredURLLoader(
    urls=urls
)
docs = loader.load()
Webページを読み込む

これは非常に便利であるが、内部で何をやっているのか全く分からない。

ChromaDBを使う

ベクトルデータを保存するにはChromaDBを使う。ここではchromadbの公式ドキュメントのGetting Startedを見ながら、基本的な挙動を把握する

pip install chromadb
chromadbをインストール

クライアントを作成する。

import chromadb

chroma_client = chromadb.Client()
クライアントを作成する

コレクションを作成する。

collection = chroma_client.create_collection(name="my_collection")
コレクションを作成する

コレクションにテキストを追加する。この処理にはモデルが必要となるため、まだダウンロードされていない場合には、モデルのダウンロードが始まる。筆者の環境では~/.cache/chroma/onnx_models/all-MiniLM-L6-v2/onnx.tar.gzにモデルがダウンロードされた。コンテナイメージを作成する場合には、このモデルのダウンロードもビルド時に行い、イメージ内に同梱しておくと良さそうだ。

https://huggingface.co/intfloat/multilingual-e5-base/tree/main

collection.add(
    documents=[
        "This is a document about pineapple",
        "This is a document about oranges"
    ],
    ids=["id1", "id2"]
)
コレクションに要素を追加する

先程データを登録したコレクションに対して問合せをおこなう。

results = collection.query(
    query_texts=["This is a query document about hawaii"], # Chroma will embed this for you
    n_results=2 # how many results to return
)
コレクションに対して問合せを行う

resultsには以下のような辞書が返される。

{'data': None,
 'distances': [[1.0403739213943481, 1.2430689334869385]],
 'documents': [['This is a document about pineapple',
                'This is a document about oranges']],
 'embeddings': None,
 'ids': [['id1', 'id2']],
 'included': [<IncludeEnum.distances: 'distances'>,
              <IncludeEnum.documents: 'documents'>,
              <IncludeEnum.metadatas: 'metadatas'>],
 'metadatas': [[None, None]],
 'uris': None}

問合せに関しては、どれだけ距離が近いかという観点で問合せができるという事らしい。ElasticsearchやMongoDBにもちょっと似てるし、だいぶモノは異なるけどZODBにも使い方はちょっと似てるなと思った。

テキスト分割

テキストを適切な粒度で分割する機能は、テキストスプリッターと呼ばれる。いくつかのテキストスプリッターがあるが、ここでは langchain.text_splitter.RecursiveCharacterTextSplitter を使う。このスプリッターは、指定した文字によって分割位置を特定する。分割文字を文字を複数指定でき、再帰的に分割処理を行う。とてもシンプルなスプリッターだ。

from langchain_text_splitters import RecursiveCharacterTextSplitter

separators = [
    "\n\n",
    "\n",
    " ",
    ".",
    ",",
    "\u200b",  # Zero-width space
    "\uff0c",  # Fullwidth comma
    "\u3001",  # Ideographic comma
    "\uff0e",  # Fullwidth full stop
    "\u3002",  # Ideographic full stop
    "",
    "、",
    "。",
    ":",
    "!",
    "?",
]

sp = RecursiveCharacterTextSplitter(
    separators=separators,
    chunk_size=30,
    chunk_overlap=10)

splitted_text_list = sp.split_text(dat)

ChromaDBをもう少し

ChromaDBでもう少し遊ぶ。

import chromadb

client = chromadb.Client()

collection = client.get_or_create_collection("dummy_ja_texts")

for i, chunk in enumerate(splitted_text_list):
    collection.add(
        documents=[chunk],
        ids=[f"{i}"],
        metadatas=[{"source": "/PATH/TO/TARGET/TEXTFILE.txt"}]
    )

問い合わせする。

collection.query(query_texts=["情報"], n_results=10)
{'data': None,
 'distances': [[0.7711247801780701,
                0.8559898138046265,
                0.9533899426460266,
                0.9725660085678101,
                1.0124714374542236,
                1.0124714374542236,
                1.0143235921859741,
                1.0719481706619263,
                1.0799652338027954,
                1.1033663749694824]],
 'documents': [['。満足いく結果かと言われればそうではないけれど',
                '、もうその程度しか残されていない',
                '、最初から何もしない方がよいだろう',
                '、もっと自由な環境があるだろう',
                '。',
                '。',
                '、自分という存在と言葉をインターネットに残していこうと思う。',
                'しかし、そうじゃない世界もありそうだ。おそらく情報の中では',
                '元々',
                '、何かをしようとしても足を引っぱられたりする']],
 'embeddings': None,
 'ids': [['7', '6', '21', '25', '13', '23', '4', '24', '9', '17']],
 'included': ['metadatas', 'documents', 'distances'],
 'metadatas': [[{'source': '/PATH/TO/TARGET/TEXTFILE.txt'},
                {'source': '/PATH/TO/TARGET/TEXTFILE.txt'},
                {'source': '/PATH/TO/TARGET/TEXTFILE.txt'},
                {'source': '/PATH/TO/TARGET/TEXTFILE.txt'},
                {'source': '/PATH/TO/TARGET/TEXTFILE.txt'},
                {'source': '/PATH/TO/TARGET/TEXTFILE.txt'},
                {'source': '/PATH/TO/TARGET/TEXTFILE.txt'},
                {'source': '/PATH/TO/TARGET/TEXTFILE.txt'},
                {'source': '/PATH/TO/TARGET/TEXTFILE.txt'},
                {'source': '/PATH/TO/TARGET/TEXTFILE.txt'}]],
 'uris': None}

各属性は以下の通りだ。

属性 説明
data Optional 特定の追加情報や結果の本文
distances List[List[str]] クエリとドキュメントとの類似度、小さいほど似ている
documents Optional[List[Document]] 検索結果に該当するテキストの内容
enbeddings Optional[List[Embedding]] 各ドキュメントのベクトル表現
ids List[ID] 各ドキュメントのIDのリスト
included Include どの情報が結果に含まれているかのリスト
metadatas Optional[List[Metadata]] 付随情報、データソースなどを格納
uris Optional[List[URI]] ドキュメントのURI
クエリ結果の各属性

結果を見ると enbeddings の値が含まれていないようだ。

埋め込み

enbeddings とは埋め込みと訳されるもので、文字列や画像や動画などのデータを、コンピュータが扱いやすいようにベクトル形式(数値)に変換する。このベクトル形式によって、意味に基づく類似度を計算できる。

今回の対象は文章なので sentence-transformers を使用し、日本語を扱うので軽量の多言語モデルである sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2 を使用する。

sentence-transformers パッケージをインストールする。

pip install sentence-transformers

sentence_transformersをインポートし、SentenceTransformerをインスタンス化する。この際、モデルのダウンロードが行われる。

from sentence_transformers import SentenceTransformer

model = SentenceTransformer(
  "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")

model.encode(["例文1", "例文2", "例文3"])

これはnumpyのndarrayを返す。

array([[ 0.02994491,  0.1921082 , -0.08651033, ...,  0.09979711,
         0.2779473 , -0.03533154],
       [ 0.06969076,  0.21776648, -0.08240339, ...,  0.20254831,
         0.10684088, -0.20881264],
       [ 0.19809206, -0.01457558, -0.01600829, ...,  0.06902885,
         0.35626626,  0.03319727]], shape=(3, 384), dtype=float32)

ChromaDBにデータを登録する際に、このndarrayも一緒に登録する。

埋め込みのベクトルデータを保存する

chromadbを使い、データの登録から検索までを実施してみる。

from sentence_transformers import SentenceTransformer

splitted_text_list = ["DUMMY1", "DUMMY2"]

model = SentenceTransformer(
  "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")

embeddings = model.encode(splitted_text_list)

splitted_text_list には文章を分割した文字列のリスト、 embeddings にはそれから計算した埋め込みデータが格納されている。これらを合わせて、ChromaDBに保存する。

import chromadb

client = chromadb.Client()

collection = client.get_or_create_collection("dummy_ja_texts")

for ii,  chunk_embedding in enumerate(zip(splitted_text_list, embeddings)):
    chunk, embedding = chunk_embedding
    collection.add(
        documents=[chunk],
        embeddings=[embedding],
        ids=[f"{ii}"],
        metadatas=[{"source": "/PATH/TO/TARGET/TEXTFILE.txt"}]
    )

データを登録できたため、これを元に問い合わせをしてみる。

query_enb = model.encode(["必死に"])

collection.query(query_embeddings=query_enb, n_results=10)

これは次のような、結果を得られる。

{'data': None,
 'distances': [[2.7929227352142334,
                2.7929227352142334,
                6.312737464904785,
                6.375030517578125,
                7.276592254638672,
                9.036642074584961,
                9.123868942260742,
                9.377449035644531,
                9.40998649597168,
                10.185521125793457]],
 'documents': [['。',
                '。',
                '僕にできる事は',
                'いう事の方が良いという事だ',
                '、何かをしようとしても足を引っぱられたりする',
                '、もうその程度しか残されていない',
                '。今日かもしれないし',
                '。今は猶予の期間を生きているだけだ',
                '。努力をしよう。',
                '。家族は裏切るし']],
 'embeddings': None,
 'ids': [['13', '23', '5', '12', '17', '6', '2', '1', '26', '18']],
 'included': ['metadatas', 'documents', 'distances'],
 'metadatas': [[{'source': '/PATH/TO/TARGET/TEXTFILE.txt'},
                {'source': '/PATH/TO/TARGET/TEXTFILE.txt'},
                {'source': '/PATH/TO/TARGET/TEXTFILE.txt'},
                {'source': '/PATH/TO/TARGET/TEXTFILE.txt'},
                {'source': '/PATH/TO/TARGET/TEXTFILE.txt'},
                {'source': '/PATH/TO/TARGET/TEXTFILE.txt'},
                {'source': '/PATH/TO/TARGET/TEXTFILE.txt'},
                {'source': '/PATH/TO/TARGET/TEXTFILE.txt'},
                {'source': '/PATH/TO/TARGET/TEXTFILE.txt'},
                {'source': '/PATH/TO/TARGET/TEXTFILE.txt'}]],
 'uris': None}

調べた所、enbeddingsは返されないらしい。ただ検索の状態もあまり良い検索とは言えない気もする。

WebページをChromaDBに登録していく

import langchain.embeddings

embedding = langchain.embeddings.HuggingFaceEmbeddings(
    model_name="intfloat/multilingual-e5-base"
)
import langchain_community.document_loaders

urls = [
  'https://blog.symdon.info/',
]

# ウェブページの内容を読込する
loader = langchain_community.document_loaders.UnstructuredURLLoader(
    urls=urls
)
docs = loader.load()
import langchain.vectorstores

vectorstore = langchain.vectorstores.Chroma.from_documents(
    documents=docs,
    embedding=embedding
)
import langchain.prompts

# プロンプトを準備
template = """
<bos><start_of_turn>system
次の文脈を使用して、最後の質問に答えてください。
{context}
<end_of_turn><start_of_turn>user
{query}
<end_of_turn><start_of_turn>model
"""
prompt = langchain.prompts.PromptTemplate.from_template(template)
chain = prompt | llm
チェーンを作成する
query = "いつ執筆を辞めてしまったのか。そしてなぜ辞めてしまったのか。"
docs = vectorstore.similarity_search(query=query, k=5)  # 検索する
検索を行う
content = "\n".join([f"Content:\n{doc.page_content}" for doc in docs])

# 推論を実行
answer = chain.invoke({'query':query, 'context':content})
print(answer)
チェーンを実行して回答する

実行可能なコード

import langchain.prompts
import langchain.vectorstores
import langchain.embeddings

import langchain.text_splitter
import langchain_community.document_loaders
import langchain.llms

import langchain
import transformers
import torch
import bitsandbytes


# トークナイザとモデルの準備
# tokenizer = transformers.AutoTokenizer.from_pretrained(
#     "google/gemma-2b-it"
# )
# model = transformers.AutoModelForCausalLM.from_pretrained(
#     "google/gemma-2b-it",
#     device_map="auto",
#     quantization_config=transformers.BitsAndBytesConfig(load_in_8bit=True)  # 量子化の指定する
# )

# # パイプラインを準備
# pipe = transformers.pipeline(
#     'text-generation',
#     model=model,
#     tokenizer=tokenizer,
#     max_new_tokens=512,
#     torch_dtype=torch.float16
# )
# llm = langchain.llms.HuggingFacePipeline(
#     pipeline=pipe
# )

urls = [
    'https://blog.symdon.info/',
]

# ウェブページの内容を読込する
loader = langchain_community.document_loaders.UnstructuredURLLoader(
    urls=urls
)
docs = loader.load()

# 読込した内容を分割する
text_splitter = langchain.text_splitter.RecursiveCharacterTextSplitter(
    chunk_size=100,
    chunk_overlap=10,
)
docs = text_splitter.split_documents(docs)

# ベクトル化する準備
embedding = langchain.embeddings.HuggingFaceEmbeddings(
    model_name="intfloat/multilingual-e5-base"
)
# 読込した内容を保存
vectorstore = langchain.vectorstores.Chroma.from_documents(
    documents=docs,
    embedding=embedding
)
query = "なぜ執筆をやめてしまったのですか?"

# 検索する
docs = vectorstore.similarity_search(query=query, k=5)

for index, doc in enumerate(docs):
    print("%d:" % (index + 1))
    print(doc.page_content)

# プロンプトを準備
template = """
<bos><start_of_turn>system
次の文脈を使用して、最後の質問に答えてください。
{context}
<end_of_turn><start_of_turn>user
{query}
<end_of_turn><start_of_turn>model
"""
prompt = langchain.prompts.PromptTemplate.from_template(template)

# チェーンを準備
llm = langchain.OpenAI()
chain = prompt | llm

# 検索する
docs = vectorstore.similarity_search(query=query, k=5)
content = "\n".join([f"Content:\n{doc.page_content}" for doc in docs])

# 推論を実行
answer = chain.invoke({'query':query, 'context':content})
print(answer)

org-modeのファイルを処理する

基本的に僕はほとんどの文書をorg-modeで記述している。org-modeというのは簡潔にいうと、Emacsでよく使われるドキュメントシステムで、マークアップ言語としての機能もある。

このorg-modeスタイルで記述された文章を適切に分割しベクトルストアに保存していくためには、分割する前にテキスト部分を抽出し余計なマークアップ表現を取り除き、そのテキストデータに対して分割、埋め込みの計算、ベクトルストアへの保存を行う方が良さそうだ。

org-modeのファイルを探索する

org-modeのファイルには .org という拡張子を付けているため、ファイルを探し出すのはそれほど難しくない。ここでは find コマンドを使用してファイルを探索する。

find . -name '*.org'

org-mode形式からマークアップを除去する

org-mode形式には文章以外の記述がある。例えばコードブロックや見出しなどだ。それらの記法はノイズになるため除去したい。そこでpandoc のLuaフィルターを使って文章にあたる箇所のみを抽出する。

pandoc --lua-filter=$HOME/ng/symdon/articles/posts/1736862943/pandoc_text_filter.lua $HOME/ng/symdon/articles/posts/1736862943/index.ja.org -o aaa.txt > output.txt

このコマンドの実行例では output.txt に文章にあたる所のみが出力される。このままパイプで繋いで実行したい所だが、ファイルパスはベクトルストアに付加情報として登録したい。そのため、一工夫必要になる。