LangChainでQdrantをためしてみる
LangChainが対応しているVector Databaseはいろいろあるけど、Qdrantがとても良さそうなので試してみた。
Qdrant (read: quadrant) is a vector similarity search engine and vector database. It provides a production-ready service with a convenient API to store, search, and manage points—vectors with an additional payload Qdrant is tailored to extended filtering support. It makes it useful for all sorts of neural-network or semantic-based matching, faceted search, and other applications.
Qdrant is written in Rust 🦀, which makes it fast and reliable even under high load. See benchmarks.
With Qdrant, embeddings or neural network encoders can be turned into full-fledged applications for matching, searching, recommending, and much more!
前提
- あくまでもLangChainで使えるVectorDatabaseとして評価する。
- ただし、実際に使う場合を想定すると、qdrantそのものについても多少なりとも知っておく必要があるため、そのあたりは少し触ってみる。
やってみた
Vector DBの作成
pyenv-virtualenv&Jupyter Labで試す。pythonは3.10.11。
$ pip install jupyterlab ipywidgets
$ jupyter-lab --ip='0.0.0.0'
こんな感じのものを用意した。
data
├── dragonball.txt
├── gintama.txt
└── keion.txt
それぞれwikipediaの該当ページのあらすじをテキストファイルにしたもの。ではこれをVector Databaseに入れる。
パッケージインストール
!pip install openai langchain tiktoken qdrant-client
コードはこんな感じ。APIキーは.envで用意しておく。
import glob
import os
from dotenv import load_dotenv
from langchain.vectorstores import Qdant
from langchain.text_splitter import CharacterTextSplitter
from langchain.embeddings.openai import OpenAIEmbeddings
load_dotenv()
docs = []
files = glob.glob("data/*")
for file in files:
topic_name = os.path.splitext(os.path.basename(file))[0]
print(topic_name)
with open(file) as f:
content = f.read()
text_splitter = CharacterTextSplitter(chunk_size=100, chunk_overlap=0)
doc = text_splitter.create_documents(texts=[content], metadatas=[{"name": topic_name, "source": "wikipedia"}])
docs.extend(doc)
embeddings = OpenAIEmbeddings()
db = Qdrant.from_documents(docs, embeddings, path="./local_qdrant", collection_name="manga_data")
Qdrantは、ローカルのオンメモリ/ディスク、Dockerコンテナを使ったQdrantサーバ、Qdrantのクラウドなど、いろいろな接続方法があるが、今回はローカルのディスクとした。あと、おまけでドキュメントにはメタデータを付与してある。
で、作成されたVectorDBはこんな感じの構成になっている。
local_qdrant/
├── collection/
│ └── manga_data/
│ └── storage.sqlite
└── meta.json
meta.jsonは見る限り、VectorDBそのもののメタデータだけのように見える。
"collections": {
"manga_data": {
"vectors": {
"size": 1536,
"distance": "Cosine",
"hnsw_config": null,
"quantization_config": null,
"on_disk": null
},
"shard_number": null,
"replication_factor": null,
"write_consistency_factor": null,
"on_disk_payload": null,
"hnsw_config": null,
"wal_config": null,
"optimizers_config": null,
"init_from": null,
"quantization_config": null
}
},
"aliases": {}
}
ということはstorage.sqliteに全て入ってるということになる。sqliteでアクセスできる。
$ sqlite3 storage.sqlite
SQLite version 3.37.2 2022-01-06 13:25:41
Enter ".help" for usage hints.
sqlite>
sqlite> .tables
points
sqlite> .schema points
CREATE TABLE points (id TEXT PRIMARY KEY, point BLOB);
sqlite> select * from points;
gASVJAAAAAAAAACMIGMxNzgyZDVhODMwYTQ0MDE5MTJmNDE5Zjc1N2Q3NmE5lC4=|���=
gASVJAAAAAAAAACMIGQ3YWU1NThhYzJkMTQ1ZTI5MWNlMDM3YzI1ODhlYTFjlC4=|���;
gASVJAAAAAAAAACMIDhlZTk3YmYyMTA4YTQ3MDNhMTgzMTBhNDQ5ZjVmMzgzlC4=|���;
gASVJAAAAAAAAACMIDkzOWU3YmQ0YTdjZTQ4YTFiOWJjMmJjNmExYTExNTRmlC4=|��=
ちょっと中身は見えないのだけど。まあこんな感じでsqliteにvectorデータが入っているのだと思う。
検索
では、検索してみる。検索内容はあえてふわっとした感じで。
query = "主人公の名前は?"
docs = db.similarity_search(query=query, k=10)
for i in docs:
print({"content": i.page_content[0:20], "metadata": i.metadata})
{'content': 'サイヤ人編\nピッコロ(マジュニア)との闘', 'metadata': {'name': 'dragonball', 'source': 'wikipedia'}}
{'content': '孫悟空少年編\n地球の人里離れた山奥に住む', 'metadata': {'name': 'dragonball', 'source': 'wikipedia'}}
{'content': '人造人間・セル編\nナメック星での闘いから', 'metadata': {'name': 'dragonball', 'source': 'wikipedia'}}
{'content': 'ピッコロ大魔王編\n天下一武道会終了後、ピ', 'metadata': {'name': 'dragonball', 'source': 'wikipedia'}}
{'content': 'フリーザ編\n地球の神と殺された仲間たちを', 'metadata': {'name': 'dragonball', 'source': 'wikipedia'}}
{'content': '魔人ブウ編\nセルゲームより約7年後、高校', 'metadata': {'name': 'dragonball', 'source': 'wikipedia'}}
{'content': '第一訓 - 第八十八訓\n江戸時代末期、地', 'metadata': {'name': 'gintama', 'source': 'wikipedia'}}
{'content': '1年生編\n私立桜が丘女子高等学校(桜が丘', 'metadata': {'name': 'keion', 'source': 'wikipedia'}}
{'content': '第四百六十三訓 - 第四百六十九訓(死神', 'metadata': {'name': 'gintama', 'source': 'wikipedia'}}
{'content': '第二百十訓 - 第二百二十八訓(吉原炎上', 'metadata': {'name': 'gintama', 'source': 'wikipedia'}}
検索とスコアリング。
query = "主人公の名前は?"
docs = db.similarity_search_with_score(query=query, k=10)
for i in docs:
doc, score = i
print({"score": score, "content": doc.page_content[0:20], "metadata": doc.metadata} )
{'score': 0.7951053232247393, 'content': 'サイヤ人編\nピッコロ(マジュニア)との闘', 'metadata': {'name': 'dragonball', 'source': 'wikipedia'}}
{'score': 0.7898348054766879, 'content': '孫悟空少年編\n地球の人里離れた山奥に住む', 'metadata': {'name': 'dragonball', 'source': 'wikipedia'}}
{'score': 0.7892076939663021, 'content': '人造人間・セル編\nナメック星での闘いから', 'metadata': {'name': 'dragonball', 'source': 'wikipedia'}}
{'score': 0.7886801793325418, 'content': 'ピッコロ大魔王編\n天下一武道会終了後、ピ', 'metadata': {'name': 'dragonball', 'source': 'wikipedia'}}
{'score': 0.7865023526110424, 'content': 'フリーザ編\n地球の神と殺された仲間たちを', 'metadata': {'name': 'dragonball', 'source': 'wikipedia'}}
{'score': 0.7856599779787378, 'content': '魔人ブウ編\nセルゲームより約7年後、高校', 'metadata': {'name': 'dragonball', 'source': 'wikipedia'}}
{'score': 0.7835936993647101, 'content': '第一訓 - 第八十八訓\n江戸時代末期、地', 'metadata': {'name': 'gintama', 'source': 'wikipedia'}}
{'score': 0.7835525289313444, 'content': '1年生編\n私立桜が丘女子高等学校(桜が丘', 'metadata': {'name': 'keion', 'source': 'wikipedia'}}
{'score': 0.7809883165558954, 'content': '第四百六十三訓 - 第四百六十九訓(死神', 'metadata': {'name': 'gintama', 'source': 'wikipedia'}}
{'score': 0.7790633747221027, 'content': '第二百十訓 - 第二百二十八訓(吉原炎上', 'metadata': {'name': 'gintama', 'source': 'wikipedia'}}
Qdrantに限った話ではないけど、メタデータに対してフィルタをかけることもできる。
query = "主人公の名前は?"
docs = db.similarity_search(query=query, k=10, filter={"name": "dragonball"})
for i in docs:
print({"content": i.page_content[0:20], "metadata": i.metadata})
{'content': 'サイヤ人編\nピッコロ(マジュニア)との闘', 'metadata': {'name': 'dragonball', 'source': 'wikipedia'}}
{'content': '孫悟空少年編\n地球の人里離れた山奥に住む', 'metadata': {'name': 'dragonball', 'source': 'wikipedia'}}
{'content': '人造人間・セル編\nナメック星での闘いから', 'metadata': {'name': 'dragonball', 'source': 'wikipedia'}}
{'content': 'ピッコロ大魔王編\n天下一武道会終了後、ピ', 'metadata': {'name': 'dragonball', 'source': 'wikipedia'}}
{'content': 'フリーザ編\n地球の神と殺された仲間たちを', 'metadata': {'name': 'dragonball', 'source': 'wikipedia'}}
{'content': '魔人ブウ編\nセルゲームより約7年後、高校', 'metadata': {'name': 'dragonball', 'source': 'wikipedia'}}
Qrdantの特徴として柔軟なフィルタをかけることもできるらしいのだけど、LangChainの公式に載ってる例とQdrantのドキュメントからこんな感じで書くっぽい。
from qdrant_client.http.models import Filter, FieldCondition, MatchValue
query = "主人公の名前は?"
filter = Filter(
must_not=[
FieldCondition(
key="metadata.name",
match=MatchValue(value="dragonball")
),
FieldCondition(
key="metadata.name",
match=MatchValue(value="keion")
),
]
)
docs = db.similarity_search(query=query, k=10, filter=filter)
for i in docs:
print({"content": i.page_content[0:20], "metadata": i.metadata})
{'content': '第一訓 - 第八十八訓\n江戸時代末期、地', 'metadata': {'name': 'gintama', 'source': 'wikipedia'}}
{'content': '第四百六十三訓 - 第四百六十九訓(死神', 'metadata': {'name': 'gintama', 'source': 'wikipedia'}}
{'content': '第二百十訓 - 第二百二十八訓(吉原炎上', 'metadata': {'name': 'gintama', 'source': 'wikipedia'}}
{'content': '第二百九十七訓 - 第三百九訓(かぶき町', 'metadata': {'name': 'gintama', 'source': 'wikipedia'}}
{'content': '第五百五十二訓 - 第五百九十五訓(烙陽', 'metadata': {'name': 'gintama', 'source': 'wikipedia'}}
{'content': '第百五十八訓 - 第百六十八訓(真選組動', 'metadata': {'name': 'gintama', 'source': 'wikipedia'}}
{'content': '第三百六十五訓 - 第三百七十訓(バラガ', 'metadata': {'name': 'gintama', 'source': 'wikipedia'}}
{'content': '第五百二十五訓 - 第五百五十一訓(さら', 'metadata': {'name': 'gintama', 'source': 'wikipedia'}}
{'content': '第五百九十六訓 - 第七百四訓(銀ノ魂篇', 'metadata': {'name': 'gintama', 'source': 'wikipedia'}}
{'content': '第五百二訓 - 第五百二十四訓(将軍暗殺', 'metadata': {'name': 'gintama', 'source': 'wikipedia'}}
Qdrantをvector db retrieverとして使う
ではLangChainのvector retrieverとしてつかってみる。Qdrantのvector DBは既に作成済みのものを読み込むようにする。
- 2段階に分けているのはQdrantクライアントを初期化した際にロックファイルを作成するようで、同じブロックをもう一度実行すると既に接続中のクライアントが出るのでNGとなるため。
- あとちょっと個人的な興味もあってメタデータのフィルタも設定している。
- たぶんprefer_grpcは今の使い方だと要らない。
from dotenv import load_dotenv
from langchain.llms import OpenAI
from langchain.embeddings import OpenAIEmbeddings
from langchain.chains import RetrievalQA
from langchain.vectorstores import Qdrant
import qdrant_client
load_dotenv()
llm=OpenAI()
embeddings = OpenAIEmbeddings()
client = qdrant_client.QdrantClient(
path="./local_qdrant", prefer_grpc=True
)
db = Qdrant(
client=client, collection_name="manga_data",
embeddings=embeddings
)
retriever = db.as_retriever(
search_kwargs={
"filter": {"name":"keion"}
}
)
qa = RetrievalQA.from_chain_type(llm=llm, chain_type="stuff", retriever=retriever, return_source_documents=True, verbose=True)
> Entering new RetrievalQA chain...
> Finished chain.
> Entering new RetrievalQA chain...
> Finished chain.
{'query': '主人公の名前は?',
'result': ' 平沢唯',
'source_documents': [Document(page_content='1年生編\n私立桜が丘女子高等学校(桜が丘高校)[注 8] に入学した田井中律は一緒に入学した幼なじみの秋山澪とともに軽音部の見学に行こうとするが、部員が前年度末に全員卒業してしまったため、4月中に新入部員が4人集まらなければ廃部になると聞かされる。その後、合唱部の見学に来るつもりが律の勧誘で軽音部に来てしまった琴吹紬は、律と澪の掛け合いを聞いているうちに2人を気に入り、入部に同意する。3人は部の存続のため、あと1人を入部させるべく勧誘活動を開始した。\n主人公の平沢唯は何か部活に入ろうとするが何も思いつかず、2週間ものあいだ入部届を書けずに日々を過ごしていた。そんなとき、軽音部の存在を知った唯は「軽い音楽って書くから、簡単なことしかやらない(口笛とか)」と思い入部届を出してしまう。実際にはバンド活動をするなどを知り「自分にはバンドはできない」と入部を取り消してもらうために部室を訪れた唯だったが、3人の演奏を聴いて心を動かされ入部を決意した。\nこうして集まった4人は唯の楽器購入を手始めに、練習・合宿・学園祭とゼロからの音楽活動を行っていく。', metadata={'name': 'keion', 'source': 'wikipedia'}),
Document(page_content='3年生編\n生徒会長兼秋山澪ファンクラブ会長・曽我部恵の澪へのストーカー騒ぎがあった3学期後、唯・澪・律・紬は3年生に、梓は2年生に進級した。勧誘活動を開始したものの効果は実らず、新入生は1人も集まらなかった。しかし梓は部内の結束力を実感し、5人は現状維持のまま、緩やかな部活動を継続させていく。\n高校生活も残り1年となった唯・澪・律・紬は、卒業後の進路を決めなければならないという岐路に立っていた。澪と紬は大学進学を選ぶが、唯と律は進路を決められずにいた。学園祭ではクラスの出し物の演劇『ロミオとジュリエット』で澪がロミオ、律がジュリエットという普段のキャラと正反対の役を演じることになり四苦八苦しながらも無事に乗り切り、前年までハプニング続きだった学園祭ライブも、さわ子発案のサプライズもあり成功を収めた。\nやがて推薦入学を狙っていた澪が「みんなと同じ大学に行きたい」と考えたため、唯、律とともに紬の志望校であるN女子大学への進学を決める。そして軽音部は3年生の卒業、そして梓との別れの時を迎える。', metadata={'name': 'keion', 'source': 'wikipedia'}),
Document(page_content='けいおん! college編\n大学に進んだ唯たちは、同じ学生寮に住みながら大学に通い始めることとなった。寮や入学式で出会った和田晶、林幸、吉田菖の3人とともに軽音部に入部、いずれも高校時代より活動していた「放課後ティータイム」と「恩那組」というバンドとしてライバル関係として競い合うも、高校時代と同様に緩やかな学生生活を過ごすようになる。そして、大学や寮での生活、軽音部での合宿などを経て、大学祭で行われるライブで1年生バンド同士のバンド対決が行われることになる。', metadata={'name': 'keion', 'source': 'wikipedia'}),
Document(page_content='2年生編\n1年次に桜が丘高校の音楽教師・山中さわ子を半ば強制的に顧問に迎えた軽音部は2年生となり、新入生を獲得するため勧誘活動を開始。しかしさわ子発案の奇抜な勧誘活動と普段のだらけぶりが仇となり、新入生の足は遠のくばかりだった。\n同校に入学した中野梓は軽音部に興味を抱き、唯たちの新歓ライブの演奏に感動して、軽音部への入部を決めた。しかし、普段の軽音部の雰囲気を知った梓は、真面目な部活動をしたいという真剣な思いとの落差に唖然とし、戸惑ってしまう。最初はそのゆるい雰囲気に反発していたが、唯たち4人と過ごすうちに彼女らの素顔を知り、結束を固めていく。2年目にして「放課後ティータイム」というバンド名をつけた軽音部の学園祭のライブでは、唯が風邪を引いてしまい本番にこられないかもしれないというハプニングを迎えたものの、全員揃って出演することに成功するのだった。', metadata={'name': 'keion', 'source': 'wikipedia'})]}
まとめ
元々Qdrantを調べたのはいくつかのモチベーションがあって、
- ファイルベースのVector DBを使いたい。またなるべくファイル数は少なくてシンプルなものが望ましい。
- メタデータでフィルタしたい。最初はFAISS使ってたのだけどFAISSではメタデータのフィルタができなかった。
というところでQdrantに行き着いた次第。LangChainのドキュメントではChromaやFAISSがよく例に挙がっていることもあって、何も考えずに使いがちだけど、Qdrantは非常にシンプルで使いやすいと思うし、スケールアウトさせる場合の選択肢も用意されているので、個人的にはこちらのほうがいいと思った。
この辺がLangChainに来てくれると嬉しいところではある
Qdrant Cloudも試してみる予定
LangChainにだいぶ甘やかされているので、もうちょっとネイティブにやってみる。
まず、データを読み込む。わかりやすいようにpandasのデータフレームに入れた。
import pandas as pd
import os
import glob
df = pd.DataFrame(columns=["text","topic"])
files = glob.glob("data/*")
for file in files:
topic_name = os.path.splitext(os.path.basename(file))[0]
with open(file) as f:
content = f.read()
paragraphs = content.split("\n\n")
df_concat = pd.DataFrame({'text': paragraphs, 'topic': topic_name})
df = pd.concat([df, df_concat],ignore_index=True)
df
データフレームのtext列に対してembeddingを作成して、embedding列に入れる。
import openai
embedding_model = "text-embedding-ada-002"
def get_embedding(text, model):
response = openai.Embedding.create(
input=text,
model=model,
)
return response["data"][0]["embedding"]
df["embedding"] = df["text"].apply(lambda x: get_embedding(x, embedding_model))
df
Qdrantクライアントの初期化とコレクションの作成。OpenAIのEmbeddingsは1536 次元なのでsizeはこれにあわせて指定。
from qdrant_client import QdrantClient
from qdrant_client.http.models import Distance, VectorParams
client = QdrantClient(path="qdrant_sample")
client.recreate_collection(
collection_name="manga_info",
vectors_config=VectorParams(size=1536, distance=Distance.COSINE),
)
データフレーム内のembeddingsをQdrantのVector DBに追加する。わかりやすいように、テキストとカテゴリーをpayloadに追加しておいた。
from qdrant_client.http.models import Batch
for index, row in df.iterrows():
arr = row["embedding"]
text = row["text"]
client.upsert(
collection_name="manga_info",
points=Batch(
ids=[index + 1],
vectors=[row["embedding"]],
payloads=[{"text": row["text"], "topic": row["topic"]}]
)
)
では検索
from pprint import pprint
from qdrant_client.http.models import Filter, FieldCondition, MatchValue
query = "主人公の名前は"
query_embed = get_embedding(query, embedding_model)
search_result = client.search(
collection_name="manga_info",
query_vector=query_embed,
limit=4,
)
pprint(search_result)
結果
[ScoredPoint(id=15, version=0, score=0.7953094823332205, payload={'text': 'サイヤ人編\nピッコロ(マジュニア)との闘いから約5年後、息子の孫悟飯を儲けて平和な日々を過ごしていた悟空のもとに、実兄・ラディッツが宇宙より来襲し、自分が惑星ベジータの戦闘民族・サイヤ人であることを知らされる。・・・', 'topic': 'dragonball'}, vector=None),
ScoredPoint(id=13, version=0, score=0.7934801424501989, payload={'text': '孫悟空少年編\n地球の人里離れた山奥に住む尻尾の生えた少年・孫悟空はある日、西の都からやって来た少女・ブルマと出会う。そこで7つ集めると神龍(シェンロン)が現れ、どんな願いでも一つだけ叶えてくれるというドラゴンボールの・・・', 'topic': 'dragonball'}, vector=None),
ScoredPoint(id=1, version=0, score=0.7925997563399234, payload={'text': '第一訓 - 第八十八訓\n江戸時代末期、地球は「天人(あまんと)」と呼ばれる宇宙人の襲来を受ける。まもなく地球人と天人との間に十数年にも及ぶ攘夷戦争が勃発、数多くの侍たちが攘夷志士として天人との戦争に参加したが、天人の強大な・・・', 'topic': 'gintama'}, vector=None),
ScoredPoint(id=17, version=0, score=0.7902940685119297, payload={'text': '人造人間・セル編\nナメック星での闘いから約1年後、密かに生き延びていたフリーザとその一味が地球を襲撃するが、謎の超サイヤ人によって撃退される。トランクスと名乗るその青年は、自分は未来からやってきたブルマとベジータの息子・・・', 'topic': 'dragonball'}, vector=None)]
カテゴリでフィルタをかけてみる。
query = "主人公の名前は誰"
query_embed = get_embedding(query, embedding_model)
search_result = client.search(
collection_name="manga_info",
query_vector=query_embed,
query_filter=Filter(
must=[
FieldCondition(
key="topic",
match=MatchValue(value="keion")
)
]
),
limit=4,
)
pprint(search_result)
結果
[ScoredPoint(id=19, version=0, score=0.7807705748928826, payload={'text': '1年生編\n私立桜が丘女子高等学校(桜が丘高校)[注 8] に入学した田井中律は一緒に入学した幼なじみの秋山澪とともに軽音部の見学に行こうとするが、部員が前年度末に全員卒業してしまったため、4月中に新入部員が4人集まらなければ廃・・・', 'topic': 'keion'}, vector=None),
ScoredPoint(id=21, version=0, score=0.7719159879695996, payload={'text': '3年生編\n生徒会長兼秋山澪ファンクラブ会長・曽我部恵の澪へのストーカー騒ぎがあった3学期後、唯・澪・律・紬は3年生に、梓は2年生に進級した。勧誘活動を開始したものの効果は実らず、新入生は1人も集まらなかった。しかし梓は部内・・・', 'topic': 'keion'}, vector=None),
ScoredPoint(id=20, version=0, score=0.7624887353137756, payload={'text': '2年生編\n1年次に桜が丘高校の音楽教師・山中さわ子を半ば強制的に顧問に迎えた軽音部は2年生となり、新入生を獲得するため勧誘活動を開始。しかしさわ子発案の奇抜な勧誘活動と普段のだらけぶりが仇となり、新入生の足は遠のくばか・・・', 'topic': 'keion'}, vector=None),
ScoredPoint(id=22, version=0, score=0.7617138138546098, payload={'text': 'けいおん! college編\n大学に進んだ唯たちは、同じ学生寮に住みながら大学に通い始めることとなった。寮や入学式で出会った和田晶、林幸、吉田菖の3人とともに軽音部に入部、いずれも高校時代より活動していた「放課後ティータイム」・・・', 'topic': 'keion'}, vector=None)]
抽象化されてるので簡単に使えて中身さっぱりだったのだけど、多少なりともネイティブなインタフェースに触れることで理屈が色々わかってよかった。
とはいえ、改めてLangChainは楽だなー。ちょっとモジュールがデカすぎるというのはあるけど、どうやらコアは分割されるみたいだし、もっと使い勝手良くなるんじゃないかな。開発スピードを考えるとやっぱり有効に活用したい。
参考