はじめに
最近、SeekDB というデータベースがあると聞いて、試してみました。
ベクトル検索と全文検索とリレーショナルが1つのエンジンに入っていて、MySQL 互換のプロトコルなので普通の SQL クライアントからそのまま繋がる、というものです。
ただ、サンプルコードを動かすだけだと使用感が分からないので、せっかくなら前から作ろうと思っていた知識ネットワークの可視化ツールを作ってみようと思い、そのデータベースとして採用してみました。
ここでは、SeekDB を試した記録と、詰まったところを書いていきます。
SeekDBとは
OceanBaseチームが開発しているAIネイティブな検索データベースです。Apache-2.0のオープンソースで、Alipayなどの本番を支えているOceanBaseのエンジンが土台になっています。
簡単な特徴をここに載せます。
1. 1つのエンジンにリレーショナル・ベクトル・全文検索・JSONが同居している
MySQL互換のプロトコルなので、mysql コマンドでもSQLAlchemyでも各言語のMySQLドライバでも、そのまま繋がります。何が嬉しいかというと、普通のRAG構成では「ベクトルはPinecone、全文検索はElasticsearch、メタデータはPostgreSQL」と分かれてしまい、それぞれ別のクエリ言語・別の運用・そしてクライアント側でのマージ処理が必要になる。SeekDBはこれを1つのSQLに畳み込めます。
SELECT id, title, l2_distance(embedding, '[0.12, ...]') AS dist
FROM articles
WHERE MATCH(content) AGAINST('quarterly report') -- 全文(BM25)
AND author_id = 42 -- スカラー条件
ORDER BY dist APPROXIMATE LIMIT 10; -- ベクトル
「ベクトル検索で100件取ってからPythonで絞る」ではなく、1つの実行計画に押し込むのが要点です。
2. 書き込みと検索が同時に走ることを前提にした構造
書き込みはインデックス構築を待たずにコミットして返ります。redoログに載った時点で完了で、ベクトル索引への反映はChange Streamが非同期に追いかけます。検索は「新しい分だけのdelta HNSW」と「確定済みのsnapshot HNSW」の両方を見にいく二層構造です。
これによって書き込みが詰まらないのですが、副作用もあります。書き込んだ直後の検索が空振りするという挙動で、これが後半の1つ目の話になります。
位置づけ
スキーマ不要で手軽に動かせるのと、MySQL互換の本格的なDBであるという、普通は両立しない2つを繋いでいるのが特異な点です。1コア1GBでも動き、埋め込みモードとクライアント/サーバモードの両方に対応しています。
- 本体: https://github.com/oceanbase/seekdb
- Python SDK: https://github.com/oceanbase/pyseekdb
- SDKドキュメント: https://oceanbase.github.io/pyseekdb/
作ったもの
Claude の Skill として実装しました。会話が一区切りしたら「wikiに入れて」と言うだけで、Claude が粒を切り出して DB に投入してくれる形です。
3つのコレクションを持ちます。
| コレクション | 役割 |
|---|---|
sources |
原文。記事や会話ログをそのまま保存する。不変 |
notes |
粒。1粒=1つの主張・判断・事実。検索の対象 |
genres |
ジャンル定義 |
原文をそのままベクトル化すると、言い直しや脱線まで検索対象になって精度を削ります。かといって捨てると経緯を辿れない。なので原文の保存と粒の切り出しを別工程にしています。
粒には stance(判断・意見)、fact(手順・事実)、context(当時の前提)の種別を持たせ、検索では既定で stance だけを見ます。手順が混ざると「考えていたこと」が埋もれるためです。
構成図
SeekDB 内部の書き込み経路はこうなっています。
意味検索
$ w add --text "pylibseekdb は arm64 のみ。Rosetta 越しのシェルだと platform.machine() が x86_64 を返して読み込めない" \
--genre tech --speaker joint --tags macos,seekdb
$ w search "Mac で動かないとき"
[tech/joint] note_1785572318875_2b10a8 dist=0.139
pylibseekdb は arm64 のみ。Rosetta 越しのシェルだと platform.machine() が
x86_64 を返して読み込めない
tags: macos,seekdb
「Mac」以外に共通語が無い状態で引けています。既定のモデルを多言語のものに差し替えれば、日本語でも意味検索が機能します。
ここで自分では埋め込みベクトルを一切扱っていません。テキストを渡すと SeekDB 側が埋め込みモデルを呼んでベクトル化し、索引に載せてくれます。検索時も同じで、渡した文字列がベクトルに変換されてから比較されます。
これは SeekDB の collection API を使っているためで、CREATE TABLE もスキーマ定義も書いていません。
col = client.get_or_create_collection(name="notes")
col.upsert(ids=["a1"], documents=["テキスト"])
col.query(query_texts=["検索語"], n_results=3)
矛盾検出
設定資料のように「矛盾がバグ」なジャンルには、近接する粒の組を洗い出す機能を付けました。
$ w conflicts --genre novel
dist=0.089
A: 魔法の代償は記憶で支払われる
B: 魔法の代償は寿命で支払われる
この機能は「ジャンルで絞ってから、ベクトルの近さで並べる」という処理をしています。素朴に作るとベクトル検索で広めに取ってからアプリ側でジャンルを絞ることになりますが、SeekDB ではメタデータの条件とベクトルの近さが1つの実行計画に入るので、where を渡すだけで済みます。
col.query(query_texts=text, n_results=4, where={"genre": "novel"})
繋がりの可視化
粒をノード、近い粒の組を辺にして描いたものです。点をクリックすると、その粒と響き合っている粒が並びます。
詰まったところ
書き込み直後のベクトル検索は空振りする
upsert した直後に query すると 0件が返ります。
col.upsert(ids=['a1'], documents=['魔法は等価交換'])
col.query(query_texts='交換', n_results=3)
# => {'ids': [[]], ...}
time.sleep(2)
col.query(query_texts='交換', n_results=3)
# => {'ids': [['a1']], ...} ← 出る
前述の Change Stream がまだ追いついていないためです。壊れているわけではなく、書き込みを速くするために意図的に切り離した結果だと思います。
影響を受けるのはベクトル検索だけです。
| 操作 | 直後の挙動 |
|---|---|
count() |
即座に正しい |
get(where=...) |
即座に正しい |
query(query_texts=...) |
空振りしうる |
get や count は元テーブルを直接読むので即答し、ベクトル索引を経由する query だけが遅れます。少し待ってから引き直すリトライを入れて対処しました。
カスタム埋め込み関数は register_embedding_function が必須
@pyseekdb.register_embedding_function を付け忘れると、DB を開き直したときに落ちます。
@pyseekdb.register_embedding_function # ← これが無いと死ぬ
class WikiEmbedding(pyseekdb.EmbeddingFunction):
def __call__(self, input): ...
def get_config(self): return {...}
@staticmethod
def build_from_config(config): return WikiEmbedding()
@staticmethod
def name(): return "wiki_multilingual"
エラーはこう出ます。
Failed to get collection. The data may be corrupted.
The collection name: 'notes': Embedding function class 'xxx' not found
「data may be corrupted」と出るので焦りますが、データは壊れていません。
コレクションを作るとき、SeekDB は「どの埋め込み関数を使ったか」を DB に保存します。ただし関数そのものは保存できないので、代わりに名前の文字列(上の例なら wiki_multilingual)だけを持っておきます。
次に DB を開くとき、SeekDB はその文字列を頼りに実体を探しにいきます。register_embedding_function は探される側の名簿に自分を載せるための記述で、これが無いと名前は残っているのに実体が見つからない、という状態になります。
既定の埋め込みモデルは英語向け
何も指定しないと all-MiniLM-L6-v2 が落ちてきます。日本語の意味検索はほぼ機能しません。intfloat/multilingual-e5-small に差し替えました。
モデルを途中で変えると既存のベクトルと互換性が失われます。 次元が同じでも空間が別物なので、混ざると検索結果が静かに壊れる。全件の再埋め込みが必要です。設定のドリフトを検知して警告を出すようにしました。
