asopi tech
asopi techIndie Developer
Alopex DB v0.8.8チュートリアル 第4回:ベクトル検索とDataFrame

【2026年8月版】

Alopex DB v0.8.8チュートリアル 第4回:ベクトル検索とDataFrame

公開日: 2026/08/27
読了時間: 約 15分

第3回まででSQLを扱いました。第4回で扱うのは、ベクトル検索とDataFrameです。

v0.8.8のリリース検証では、ベクトル検索を含む同じSQLコーパスをライブラリ・組み込み・HTTP・gRPC・cluster-awareで読み直し、結果の一致まで確認されています。

ベクトル検索は「この記事に似た記事」を探す機能です。Alopex DBでは、埋め込みベクトルを記事と同じテーブルの列として持ち、SQLのWHEREと組み合わせて検索できます。DataFrameは、SQLで取り出した結果をPythonで整形するための道具です。

1. 環境

項目
Alopex DB0.8.8(PyPI / crates.io 公開版)
Python3.11.11
Rust1.96.0
OSLinux(WSL2、glibc 2.35)
確認日2026年8月17日

DataFrameを使うので、polars付きで入れます。

pip install --no-cache-dir "alopex[polars]==0.8.8"

2. ベクトル列を持つテーブルを作る

第2回・第3回のarticlesに、タグと埋め込みベクトルの列を足します。

from alopex import Database

db = Database.new()
db.execute_sql("""CREATE TABLE articles (
  id INTEGER PRIMARY KEY,
  title TEXT,
  tags TEXT,
  views INTEGER,
  embedding VECTOR(3, L2)
)""")

rows = [
    "(1, 'Rustで書くLSMツリー', 'rust,storage', 1200, [1.0, 0.0, 0.0])",
    "(2, 'SQLパーサをNimで実装する', 'nim,sql', 800, [0.9, 0.2, 0.0])",
    "(3, 'ベクトル検索の基礎', 'vector,search', 1500, [0.0, 1.0, 0.0])",
    "(4, '組み込みDBの選び方', 'database,embedded', 600, [0.1, 0.9, 0.0])",
    "(5, 'WALとクラッシュ復旧', 'storage,recovery', 300, [0.8, 0.0, 0.3])",
]
for row in rows:
    db.execute_sql(f"INSERT INTO articles VALUES {row}")

VECTOR(3, L2)は3次元のベクトル列で、距離の計算にL2距離(ユークリッド距離)を使うという宣言です。実際のアプリでは、記事本文を埋め込みモデルへ通して得た数百次元のベクトルを入れます。ここでは手で追えるように3次元にしてあります。

値の意味は次のとおりです。1軸目がストレージ寄り、2軸目が検索寄りの度合いだと考えてください。id = 1[1.0, 0.0, 0.0]はストレージの記事、id = 3[0.0, 1.0, 0.0]は検索の記事です。

3. vector_distance

vector_distance()で、指定したベクトルとの距離を計算します。

print(db.execute_sql("""
SELECT id, title, vector_distance(embedding, [1.0, 0.0, 0.0], 'l2') AS d
FROM articles
ORDER BY d
LIMIT 3
"""))
[{'id': 1, 'title': 'Rustで書くLSMツリー', 'd': 0.0},
 {'id': 2, 'title': 'SQLパーサをNimで実装する', 'd': 0.22360680997371674},
 {'id': 5, 'title': 'WALとクラッシュ復旧', 'd': 0.36055511236190796}]

[1.0, 0.0, 0.0]はストレージ寄りのベクトルです。距離0.0で一致したid = 1が先頭に来て、次いでid = 2id = 5が返ります。

実際にv0.8.8でvector_distance()を実行した結果を録画しています。問い合わせベクトルと一致するid = 1が距離0.0で先頭になり、近い順に結果が返ることを確認できます。

v0.8.8で問い合わせベクトルとの距離を計算し、近い順に返した結果

動画を開く

距離は小さいほど近いので、ORDER BY dは昇順のままにします。LIMITで件数を決めます。この二つを書かないと、単に距離を計算した列が全件返るだけです。

4. vector_distanceとWHERE

ベクトル検索の結果にWHEREを足せます。

print(db.execute_sql("""
SELECT id, title, vector_distance(embedding, [1.0, 0.0, 0.0], 'l2') AS d
FROM articles
WHERE views >= 800
ORDER BY d
LIMIT 3
"""))
[{'id': 1, 'title': 'Rustで書くLSMツリー', 'd': 0.0},
 {'id': 2, 'title': 'SQLパーサをNimで実装する', 'd': 0.22360680997371674},
 {'id': 3, 'title': 'ベクトル検索の基礎', 'd': 1.4142135381698608}]

3番目が変わります。viewsが300のid = 5が条件から外れ、代わりに距離の遠いid = 3が入ります。

ベクトル検索を別の製品に置くと、この絞り込みをアプリ側で書くことになります。検索基盤から候補を多めに取り、データベースへ問い合わせて条件で削り、件数が足りなければやり直す、という手順です。同じテーブルの列として持てば、WHEREを足すだけで済みます。

5. SQLの結果をDataFrameへ移す

ここからはDataFrameです。execute_sql()が返すのは辞書のリストなので、列ごとにまとめ直してDataFrameを作ります。

from alopex import DataFrame

rows = db.execute_sql("SELECT id, title, tags, views FROM articles ORDER BY id")
df = DataFrame.from_columns({k: [r[k] for r in rows] for k in rows[0]})

print(df.height(), "", df.width(), "")
5 行 4 列

height()width()はメソッドなので、括弧を付けて呼びます。

DataFrameが扱えるのは整数・文字列・日時・文字列のリストです。浮動小数点の列は作れないので、ratingのような列はここでは外しています。

6. str名前空間のsplit

tags列はrust,storageのようなカンマ区切りの文字列です。これを扱うためにstr名前空間を使います。

tagged = df.str("tags").split(",", "tag_list")
print(tagged.to_dict()["tag_list"])
[['rust', 'storage'], ['nim', 'sql'], ['vector', 'search'],
 ['database', 'embedded'], ['storage', 'recovery']]

df.str("tags")のように、対象の列名を引数で渡します。split(",", "tag_list")は、カンマで分割した結果をtag_listという新しい列に入れます。第2引数を省略すると元の列を上書きします。

戻り値は列ではなくDataFrame全体なので、そのまま次の操作へつなげられます。

str名前空間には、ほかにto_lowercaseto_uppercasecontainsreplaceextractlen_charsstrip_charsがあります。

7. explode

タグごとに集計したい場合、1行に複数のタグが入っていると扱いにくくなります。explode()で、リストの要素を行に展開します。

per_tag = tagged.explode("tag_list")
d = per_tag.to_dict()
print(d["tag_list"])
print(d["id"])
['rust', 'storage', 'nim', 'sql', 'vector', 'search', 'database', 'embedded', 'storage', 'recovery']
[1, 1, 2, 2, 3, 3, 4, 4, 5, 5]

5行が10行になります。idを見ると、元の行が2回ずつ現れます。展開するのは指定した列だけで、他の列は各行へ複製します。

これで「タグごとの記事数」のような集計ができる形になります。

8. LazyFrameで絞り込む

lazy()でLazyFrameへ変換すると、filterselectをつなげて書けます。

from alopex import col, lit

hot = (per_tag.lazy()
       .filter(col("views").gt(lit(800)))
       .select([col("tag_list"), col("views")])
       .collect())

print(hot.to_dict())
{'tag_list': ['rust', 'storage', 'vector', 'search'], 'views': [1200, 1200, 1500, 1500]}

閲覧数が800を超える記事のタグだけが残ります。id = 1id = 3の記事から来たタグです。

比較はcol("views").gt(lit(800))のようにメソッドで書きます。views > 800のような演算子は使えないので、gtlteqand_or_mulaliasを組み合わせて式を作ります。

collect()を呼ぶまで計算は実行されません。処理を組み立ててから最後にまとめて走らせるので、途中で中間結果を持たずに済みます。

9. dt名前空間の入力形式

dt名前空間で日時を扱えますが、入力の形式が決まっています。

DataFrameに渡せるのはエポックマイクロ秒の整数です。ISO 8601の文字列やPythonのdatetimeオブジェクトは受け付けません。

df = DataFrame.from_columns(
    {"published": [1768473000000000]},
    {"published": "datetime"},
)

17684730000000002026-01-15T10:30:00Zです。秒やミリ秒の値を渡すと、エラーにはならず別の日時として解釈されます。単位を間違えると1970年や58010年になるので、投入前に確かめてください。

10. Rustからベクトル検索とDataFrameを使う

ベクトル検索はSQLの関数なので、Rustからもexecute_sql()で呼びます。DataFrameは、Pythonとは作り方が変わります。

この回のRustのコードでは、依存が四つになります。

[dependencies]
alopex-embedded = "=0.8.8"
alopex-dataframe = "0.8"
alopex-sql = "0.8"
arrow = "53"

alopex-embeddedが再輸出しているのはDataFrameだけです。DataFrameを組み立てるSeriesと、式を書くcollitalopex-dataframeから、結果の値を取り出すSqlValuealopex-sqlから直接使います。SeriesはArrowの配列から作るので、arrowも要ります。

以下のコードは、次のuseをまとめて書いた前提です。

use alopex_dataframe::{col, lit, DataFrame, Series};
use alopex_embedded::{Database, SqlResult};
use alopex_sql::storage::value::SqlValue;
use arrow::array::{ArrayRef, Int64Array, StringArray};
use arrow::util::display::array_value_to_string;
use std::path::Path;
use std::sync::Arc;

テーブルは2章と同じarticlesを、Database::open(Path::new("./notes-db"))で作った前提で進めます。

まずベクトル検索です。

let result = db.execute_sql(
    "SELECT id, title, tags, views, vector_distance(embedding, [1.0, 0.0, 0.0], 'l2') AS d
     FROM articles ORDER BY d LIMIT 3")?;

let SqlResult::Query(q) = result else { return Ok(()) };
for row in &q.rows {
    println!("{}", row.iter().map(|v| format!("{v:?}")).collect::<Vec<_>>().join(" | "));
}
Integer(1) | Text("Rustで書くLSMツリー") | Text("rust,storage") | Integer(1200) | Double(0.0)
Integer(2) | Text("SQLパーサをNimで実装する") | Text("nim,sql") | Integer(800) | Double(0.22360680997371674)
Integer(5) | Text("WALとクラッシュ復旧") | Text("storage,recovery") | Integer(300) | Double(0.36055511236190796)

3章のPythonと同じ順序、同じ距離です。vector_distance()はSQLの関数なので、呼び出す言語によらず同じ結果を返します。

次にDataFrameへ移します。PythonではDataFrame.from_columns()に辞書を渡しました。Rustでは列ごとにArrowの配列を作り、SeriesにしてからDataFrame::new()へ渡します。

let mut ids = Vec::new();
let mut tags = Vec::new();
let mut views = Vec::new();
for row in &q.rows {
    if let SqlValue::Integer(v) = &row[0] { ids.push(*v as i64) }
    if let SqlValue::Text(v) = &row[2] { tags.push(v.clone()) }
    if let SqlValue::Integer(v) = &row[3] { views.push(*v as i64) }
}

let id_col: ArrayRef = Arc::new(Int64Array::from(ids));
let tag_col: ArrayRef = Arc::new(StringArray::from(tags));
let view_col: ArrayRef = Arc::new(Int64Array::from(views));

let df = DataFrame::new(vec![
    Series::from_arrow("id", vec![id_col])?,
    Series::from_arrow("tags", vec![tag_col])?,
    Series::from_arrow("views", vec![view_col])?,
])?;
println!("{} 行 {} 列", df.height(), df.width());
3 行 3 列

SqlValueから値を取り出すところが、Pythonにはない手順です。Pythonのexecute_sql()は辞書を返すので、キーを指定すれば値を取り出せます。Rustは型の付いた列挙型を返すので、if letで該当する型のときだけ取り出します。整数の列はInteger(i32)で返るため、ArrowのInt64Arrayへ入れるときにas i64で広げています。

タグの分割と展開は、Pythonのdf.str("tags").split(",", "tag_list")が式の形になります。

let per_tag = df.lazy()
    .with_columns(vec![col("tags").str().split(",").alias("tag_list")])
    .explode("tag_list")
    .collect()?;
println!("--- explode: {} 行", per_tag.height());
--- explode: 6 行

PythonではDataFrameのメソッドとしてstr名前空間を呼び、対象の列名を引数で渡しました。Rustではcol("tags")で列を指す式を作り、.str().split(",")をつなげ、.alias("tag_list")で結果の列名を決めます。この式をwith_columnsへ渡すと、その列が足されます。

explodeはPythonとRustで同じ名前です。1行に2個ずつタグが入っているので、3行が6行になります。

filterselectもつなげられます。

let hot = per_tag.lazy()
    .filter(col("views").gt(lit(700i64)))
    .select(vec![col("id"), col("tag_list"), col("views")])
    .collect()?;

表示するには、列ごとにArrowの配列へ戻します。

for s in hot.columns() {
    let arr = &s.to_arrow()[0];
    let cells: Vec<String> = (0..arr.len())
        .map(|i| array_value_to_string(arr, i).unwrap())
        .collect();
    println!("{}: {}", s.name(), cells.join(", "));
}
id: 1, 1, 2, 2
tag_list: rust, storage, nim, sql
views: 1200, 1200, 800, 800

viewsが300のid = 5が条件から外れ、4行になります。

lit()に渡す値の型は、比べる列の型に合わせます。上で700i64と書いているのは、viewsの列をInt64Arrayで作ったためです。lit(700)と書くとi64と解釈されるので、列が32ビットの整数だとInt32 > Int64という型の不一致で実行時に落ちます。列を作る段階でInt64Arrayにそろえておくと、この食い違いが起きません。

DataFrameの機能そのものはPythonと同じです。違うのは、列を作るときにArrowの型を自分で選ぶことと、列の操作が式になることです。

11. ここまでで使ったAPI

呼び出し役割
VECTOR(n, L2)n次元のベクトル列を宣言する
vector_distance(列, [...], 'l2')指定したベクトルとの距離を計算する
DataFrame.from_columns(dict)列ごとの辞書からDataFrameを作る。RustはDataFrame::new(Vec<Series>)
df.str(列名).split(...)文字列を分割して新しい列に入れる。Rustはcol(...).str().split(...)
df.explode(列名)リストの要素を行に展開する
df.lazy()LazyFrameへ変換する
col(...), lit(...)LazyFrameの式を組み立てる
.collect()組み立てた処理を実行する

次回は、データディレクトリをサーバーで開き、HTTPからSQLを実行します。

参照