mirror of
https://github.com/seaweedfs/seaweedfs.git
synced 2026-10-27 15:30:45 +01:00
vector: seaweed-vector workspace + flat/indexed top-K engine (P0 phase 1) (#12096)
* vector: scaffold the seaweed-vector workspace Standalone cargo workspace for the vector query engine, mirroring the seaweed-worker layout: own lockfile, same lint policy, path dependencies across the two trees since there is no common root manifest. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * vector: resolve a table's vector layout VectorTable::resolve finds the vector column (explicit name, else the column named vector, else the sole fixed-size float list), the optional key column, and the metadata columns a query can project. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * vector: flat and indexed top-k, key lookup query::flat runs an exact scan (use_index(false)); query::ann lets Lance split the query between index segments and a flat pass over fragments no index covers yet, so rows written after the last index build stay eligible. Both honour a metadata filter and return the raw _distance in the query's metric. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * vector: reject a non-utf8 or non-identifier key column An explicit --key-column naming a non-UTF8 field made hits() silently report row ids as keys, and get() interpolated the name into a SQL filter verbatim. Require Utf8 at resolve and in hits(), and require a plain identifier before building the IN predicate. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * vector: fall back to row id on a null key keys.value() on a null slot fabricates an empty-string key; a row with no key reports its row id instead, matching the no-key-column case. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * vector: quote the key column in the IN filter A Utf8 key column named e.g. document-key made the predicate parse as subtraction, and an identifier interpolated unquoted is injectable. Backtick-quote it and double its inner backticks; double quotes are string literals in the filter dialect, not identifiers. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> * vector: project only the columns a hit needs The knn projection pulled every metadata column — including other vector columns — although Hit returns none of them. Filters still evaluate off the scan path, so restrict the output to the key and the scoring columns. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
6f8f8e7079
commit
7388d0d4fc
5 files changed
+578
No files matched your search
@@ -0,0 +1,28 @@
|
||||
# Vector query engine for SeaweedFS Lance table buckets.
|
||||
#
|
||||
# `core` is the engine: open a table through the Lance namespace and run
|
||||
# top-K over it. The crate layout follows vector_buckets.md; a `server`
|
||||
# crate with the VectorService gRPC endpoint lands with the S3 Vectors
|
||||
# milestone.
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["crates/core"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
# The edition needs 1.85; the lance dependency tree needs more. Same floor as
|
||||
# seaweed-worker, which this workspace path-depends on.
|
||||
rust-version = "1.94.1"
|
||||
|
||||
# No common root manifest links this tree to seaweed-worker or
|
||||
# seaweed-common; keep the lint policy identical to theirs.
|
||||
[workspace.lints.clippy]
|
||||
needless_update = "allow"
|
||||
undocumented_unsafe_blocks = "warn"
|
||||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
@@ -0,0 +1,34 @@
|
||||
[package]
|
||||
name = "weed-vector-core"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "Vector query engine over SeaweedFS Lance table buckets"
|
||||
|
||||
[lib]
|
||||
name = "weed_vector_core"
|
||||
|
||||
[dependencies]
|
||||
# The catalog client and dataset-opening helper the maintenance worker uses.
|
||||
# A path dependency because the two trees are separate cargo workspaces with
|
||||
# no common root manifest.
|
||||
weed-lance-worker = { path = "../../../seaweed-worker/crates/lance" }
|
||||
lance = { version = "10", default-features = false, features = ["aws"] }
|
||||
lance-index = "10"
|
||||
lance-linalg = "10"
|
||||
lance-table = "10"
|
||||
arrow-array = "58"
|
||||
arrow-schema = "58"
|
||||
roaring = "0.11"
|
||||
anyhow.workspace = true
|
||||
futures = "0.3"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,12 @@
|
||||
//! Vector queries over the Lance tables the SeaweedFS namespace registers.
|
||||
//!
|
||||
//! A table bucket's rows carry a key column, a fixed-size float vector column,
|
||||
//! and metadata columns. This crate resolves that layout and runs top-K over
|
||||
//! the whole dataset: index-accelerated where an ANN index exists, exact where
|
||||
//! it does not, always covering the fragments no index has reached yet.
|
||||
|
||||
pub mod query;
|
||||
pub mod table;
|
||||
|
||||
pub use query::{Hit, Query};
|
||||
pub use table::VectorTable;
|
||||
@@ -0,0 +1,326 @@
|
||||
//! Top-K queries over a [`VectorTable`]: exact flat scan, or the ANN index
|
||||
//! with a flat pass over the fragments it does not cover yet.
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use arrow_array::{Array, Float32Array, RecordBatch, StringArray, UInt64Array};
|
||||
use futures::TryStreamExt;
|
||||
use lance::Dataset;
|
||||
use lance::dataset::scanner::Scanner;
|
||||
use lance_linalg::distance::DistanceType;
|
||||
|
||||
use crate::table::VectorTable;
|
||||
|
||||
const DISTANCE_COLUMN: &str = "_distance";
|
||||
const ROW_ID_COLUMN: &str = "_rowid";
|
||||
|
||||
/// One vector search request.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Query {
|
||||
pub vector: Vec<f32>,
|
||||
pub k: usize,
|
||||
/// A Lance SQL filter over the metadata columns.
|
||||
pub filter: Option<String>,
|
||||
pub distance: DistanceType,
|
||||
pub nprobes: Option<usize>,
|
||||
}
|
||||
|
||||
/// One row of a top-K answer.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Hit {
|
||||
/// The key column value, or the row id when the table has no key column.
|
||||
pub key: String,
|
||||
/// Raw Lance `_distance` in the query's metric.
|
||||
pub distance: f32,
|
||||
pub row_id: u64,
|
||||
}
|
||||
|
||||
/// Exact top-K over every fragment, ignoring any index.
|
||||
pub async fn flat(dataset: &Dataset, table: &VectorTable, query: &Query) -> Result<Vec<Hit>> {
|
||||
run(scanner(dataset, table, query, false)?, table).await
|
||||
}
|
||||
|
||||
/// Top-K with the ANN index where it covers the fragment and a flat pass over
|
||||
/// the rest. Whether the index is used is Lance's decision per fragment, so
|
||||
/// rows written after the last index build are always candidates.
|
||||
pub async fn ann(dataset: &Dataset, table: &VectorTable, query: &Query) -> Result<Vec<Hit>> {
|
||||
run(scanner(dataset, table, query, true)?, table).await
|
||||
}
|
||||
|
||||
/// Fetch rows by key.
|
||||
pub async fn get(
|
||||
dataset: &Dataset,
|
||||
table: &VectorTable,
|
||||
keys: &[String],
|
||||
) -> Result<Vec<RecordBatch>> {
|
||||
let Some(key_column) = &table.key_column else {
|
||||
bail!("the table has no key column to look up");
|
||||
};
|
||||
if keys.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let list = keys
|
||||
.iter()
|
||||
.map(|k| format!("'{}'", k.replace('\'', "''")))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let mut scanner = dataset.scan();
|
||||
scanner
|
||||
.filter(&format!("`{}` in ({list})", key_column.replace('`', "``")))?
|
||||
.with_row_id();
|
||||
scanner
|
||||
.try_into_stream()
|
||||
.await?
|
||||
.try_collect()
|
||||
.await
|
||||
.context("scan by keys")
|
||||
}
|
||||
|
||||
fn scanner(
|
||||
dataset: &Dataset,
|
||||
table: &VectorTable,
|
||||
query: &Query,
|
||||
use_index: bool,
|
||||
) -> Result<Scanner> {
|
||||
if query.vector.len() != table.dimension {
|
||||
bail!(
|
||||
"query dim({}) does not match the table's dim({})",
|
||||
query.vector.len(),
|
||||
table.dimension
|
||||
);
|
||||
}
|
||||
let vector = Float32Array::from(query.vector.clone());
|
||||
let mut scanner = dataset.scan();
|
||||
scanner
|
||||
.nearest(&table.vector_column, &vector, query.k)?
|
||||
.distance_metric(query.distance)
|
||||
.use_index(use_index)
|
||||
.with_row_id();
|
||||
if let Some(filter) = &query.filter {
|
||||
scanner.filter(filter)?;
|
||||
}
|
||||
if let Some(nprobes) = query.nprobes {
|
||||
scanner.nprobes(nprobes);
|
||||
}
|
||||
let mut columns = Vec::new();
|
||||
if let Some(key) = &table.key_column {
|
||||
columns.push(key.clone());
|
||||
}
|
||||
scanner.project(&columns)?;
|
||||
Ok(scanner)
|
||||
}
|
||||
|
||||
async fn run(scanner: Scanner, table: &VectorTable) -> Result<Vec<Hit>> {
|
||||
let mut stream = scanner.try_into_stream().await?;
|
||||
let mut out = Vec::new();
|
||||
while let Some(batch) = stream.try_next().await? {
|
||||
out.extend(hits(&batch, table)?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn hits(batch: &RecordBatch, table: &VectorTable) -> Result<Vec<Hit>> {
|
||||
let distances = batch
|
||||
.column_by_name(DISTANCE_COLUMN)
|
||||
.and_then(|c| c.as_any().downcast_ref::<Float32Array>())
|
||||
.context("the scan did not return a _distance column")?;
|
||||
let row_ids = batch
|
||||
.column_by_name(ROW_ID_COLUMN)
|
||||
.and_then(|c| c.as_any().downcast_ref::<UInt64Array>())
|
||||
.context("the scan did not return a _rowid column")?;
|
||||
let keys = table.key_column.as_ref().and_then(|name| {
|
||||
batch
|
||||
.column_by_name(name)
|
||||
.and_then(|c| c.as_any().downcast_ref::<StringArray>())
|
||||
});
|
||||
if table.key_column.is_some() && keys.is_none() {
|
||||
bail!("key column did not come back as Utf8");
|
||||
}
|
||||
(0..batch.num_rows())
|
||||
.map(|i| {
|
||||
Ok(Hit {
|
||||
key: match keys {
|
||||
Some(keys) if !keys.is_null(i) => keys.value(i).to_string(),
|
||||
_ => row_ids.value(i).to_string(),
|
||||
},
|
||||
distance: distances.value(i),
|
||||
row_id: row_ids.value(i),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::table::VectorTable;
|
||||
use arrow_array::{FixedSizeListArray, RecordBatchIterator, StringArray, types::Float32Type};
|
||||
use arrow_schema::{DataType, Field, Schema};
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
|
||||
const DIM: usize = 4;
|
||||
|
||||
fn vector_column(vectors: &[Vec<f32>]) -> FixedSizeListArray {
|
||||
FixedSizeListArray::from_iter_primitive::<Float32Type, _, _>(
|
||||
vectors
|
||||
.iter()
|
||||
.map(|v| Some(v.iter().copied().map(Some).collect::<Vec<_>>())),
|
||||
DIM as i32,
|
||||
)
|
||||
}
|
||||
|
||||
async fn write_dataset(dir: &TempDir, vectors: &[Vec<f32>], tags: &[&str]) -> Dataset {
|
||||
let schema = Arc::new(Schema::new(vec![
|
||||
Field::new("key", DataType::Utf8, false),
|
||||
Field::new(
|
||||
"vector",
|
||||
DataType::FixedSizeList(
|
||||
Arc::new(Field::new("item", DataType::Float32, true)),
|
||||
DIM as i32,
|
||||
),
|
||||
false,
|
||||
),
|
||||
Field::new("tag", DataType::Utf8, true),
|
||||
]));
|
||||
let keys: Vec<String> = (0..vectors.len()).map(|i| format!("k{i}")).collect();
|
||||
let batch = RecordBatch::try_new(
|
||||
schema.clone(),
|
||||
vec![
|
||||
Arc::new(StringArray::from(keys)),
|
||||
Arc::new(vector_column(vectors)),
|
||||
Arc::new(StringArray::from(
|
||||
tags.iter().map(|t| Some(t.to_string())).collect::<Vec<_>>(),
|
||||
)),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
let reader = RecordBatchIterator::new(vec![Ok(batch)].into_iter(), schema);
|
||||
Dataset::write(reader, dir.path().to_str().unwrap(), None)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn l2(a: &[f32], b: &[f32]) -> f32 {
|
||||
a.iter().zip(b).map(|(x, y)| (x - y) * (x - y)).sum()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn flat_returns_exact_top_k() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vectors = vec![
|
||||
vec![1.0, 0.0, 0.0, 0.0],
|
||||
vec![0.9, 0.1, 0.0, 0.0],
|
||||
vec![0.0, 1.0, 0.0, 0.0],
|
||||
vec![0.0, 0.0, 1.0, 0.0],
|
||||
];
|
||||
let dataset = write_dataset(&dir, &vectors, &["a", "b", "a", "b"]).await;
|
||||
let table = VectorTable::resolve(&dataset, None, None).unwrap();
|
||||
|
||||
let query = Query {
|
||||
vector: vec![1.0, 0.0, 0.0, 0.0],
|
||||
k: 2,
|
||||
filter: None,
|
||||
distance: DistanceType::L2,
|
||||
nprobes: None,
|
||||
};
|
||||
let hits = flat(&dataset, &table, &query).await.unwrap();
|
||||
assert_eq!(hits.len(), 2);
|
||||
assert_eq!(hits[0].key, "k0");
|
||||
assert!(hits[0].distance <= hits[1].distance);
|
||||
|
||||
let mut brute: Vec<(String, f32)> = vectors
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| (format!("k{i}"), l2(&query.vector, v)))
|
||||
.collect();
|
||||
brute.sort_by(|a, b| a.1.total_cmp(&b.1));
|
||||
for (hit, (key, distance)) in hits.iter().zip(brute.iter()) {
|
||||
assert_eq!(&hit.key, key);
|
||||
assert!((hit.distance - distance).abs() < 1e-5);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn flat_applies_the_filter() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vectors = vec![
|
||||
vec![1.0, 0.0, 0.0, 0.0],
|
||||
vec![0.9, 0.1, 0.0, 0.0],
|
||||
vec![0.0, 1.0, 0.0, 0.0],
|
||||
];
|
||||
let dataset = write_dataset(&dir, &vectors, &["a", "b", "a"]).await;
|
||||
let table = VectorTable::resolve(&dataset, None, None).unwrap();
|
||||
|
||||
let hits = flat(
|
||||
&dataset,
|
||||
&table,
|
||||
&Query {
|
||||
vector: vec![1.0, 0.0, 0.0, 0.0],
|
||||
k: 3,
|
||||
filter: Some("tag = 'a'".to_string()),
|
||||
distance: DistanceType::L2,
|
||||
nprobes: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(hits.len(), 2);
|
||||
assert_eq!(hits[0].key, "k0");
|
||||
assert_eq!(hits[1].key, "k2");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_fetches_rows_by_key() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vectors = vec![vec![1.0; DIM], vec![2.0; DIM], vec![3.0; DIM]];
|
||||
let dataset = write_dataset(&dir, &vectors, &["a", "b", "c"]).await;
|
||||
let table = VectorTable::resolve(&dataset, None, None).unwrap();
|
||||
|
||||
let batches = get(&dataset, &table, &["k0".to_string(), "k2".to_string()])
|
||||
.await
|
||||
.unwrap();
|
||||
let rows: usize = batches.iter().map(|b| b.num_rows()).sum();
|
||||
assert_eq!(rows, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn query_rejects_a_wrong_dimension() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vectors = vec![vec![1.0; DIM]];
|
||||
let dataset = write_dataset(&dir, &vectors, &["a"]).await;
|
||||
let table = VectorTable::resolve(&dataset, None, None).unwrap();
|
||||
let query = Query {
|
||||
vector: vec![1.0; DIM + 1],
|
||||
k: 1,
|
||||
filter: None,
|
||||
distance: DistanceType::L2,
|
||||
nprobes: None,
|
||||
};
|
||||
assert!(flat(&dataset, &table, &query).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ann_matches_flat_without_an_index() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let vectors = vec![
|
||||
vec![1.0, 0.0, 0.0, 0.0],
|
||||
vec![0.0, 1.0, 0.0, 0.0],
|
||||
vec![0.0, 0.0, 1.0, 0.0],
|
||||
];
|
||||
let dataset = write_dataset(&dir, &vectors, &["a", "b", "c"]).await;
|
||||
let table = VectorTable::resolve(&dataset, None, None).unwrap();
|
||||
let query = Query {
|
||||
vector: vec![1.0, 0.0, 0.0, 0.0],
|
||||
k: 2,
|
||||
filter: None,
|
||||
distance: DistanceType::L2,
|
||||
nprobes: None,
|
||||
};
|
||||
let indexed = ann(&dataset, &table, &query).await.unwrap();
|
||||
let exact = flat(&dataset, &table, &query).await.unwrap();
|
||||
assert_eq!(
|
||||
indexed.iter().map(|h| &h.key).collect::<Vec<_>>(),
|
||||
exact.iter().map(|h| &h.key).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
//! The columns a vector query needs, resolved from a table's Arrow schema.
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use arrow_schema::DataType;
|
||||
use lance::Dataset;
|
||||
|
||||
/// The default name for the vector column, matching what the S3 Vectors API
|
||||
/// writes.
|
||||
const DEFAULT_VECTOR_COLUMN: &str = "vector";
|
||||
|
||||
/// The default name for the key column.
|
||||
const DEFAULT_KEY_COLUMN: &str = "key";
|
||||
|
||||
/// Layout of a Lance table used as a vector index.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct VectorTable {
|
||||
/// The row key column; `None` falls back to `_rowid` in results.
|
||||
pub key_column: Option<String>,
|
||||
pub vector_column: String,
|
||||
pub dimension: usize,
|
||||
pub metadata_columns: Vec<String>,
|
||||
}
|
||||
|
||||
impl VectorTable {
|
||||
/// Resolve the vector layout of `dataset`. `vector_column` and
|
||||
/// `key_column` override detection: the vector column defaults to the one
|
||||
/// named `vector`, or the sole fixed-size float list when unambiguous.
|
||||
pub fn resolve(
|
||||
dataset: &Dataset,
|
||||
vector_column: Option<&str>,
|
||||
key_column: Option<&str>,
|
||||
) -> Result<Self> {
|
||||
let schema: arrow_schema::Schema = dataset.schema().into();
|
||||
resolve_schema(&schema, vector_column, key_column)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_schema(
|
||||
schema: &arrow_schema::Schema,
|
||||
vector_column: Option<&str>,
|
||||
key_column: Option<&str>,
|
||||
) -> Result<VectorTable> {
|
||||
let vector_column = match vector_column {
|
||||
Some(name) => name.to_string(),
|
||||
None => detect_vector_column(schema)?,
|
||||
};
|
||||
let field = schema
|
||||
.field_with_name(&vector_column)
|
||||
.with_context(|| format!("no such column {vector_column}"))?;
|
||||
let dimension = vector_dimension(field.data_type())
|
||||
.with_context(|| format!("column {vector_column} is not a fixed-size float vector"))?;
|
||||
|
||||
let key_column = match key_column {
|
||||
Some(name) => {
|
||||
let field = schema
|
||||
.field_with_name(name)
|
||||
.with_context(|| format!("no such column {name}"))?;
|
||||
if field.data_type() != &DataType::Utf8 {
|
||||
bail!("key column {name} is not Utf8");
|
||||
}
|
||||
Some(name.to_string())
|
||||
}
|
||||
None => detect_key_column(schema),
|
||||
};
|
||||
|
||||
let metadata_columns = schema
|
||||
.fields()
|
||||
.iter()
|
||||
.map(|f| f.name().clone())
|
||||
.filter(|name| name != &vector_column && Some(name) != key_column.as_ref())
|
||||
.collect();
|
||||
|
||||
Ok(VectorTable {
|
||||
key_column,
|
||||
vector_column,
|
||||
dimension,
|
||||
metadata_columns,
|
||||
})
|
||||
}
|
||||
|
||||
fn detect_vector_column(schema: &arrow_schema::Schema) -> Result<String> {
|
||||
let mut named = None;
|
||||
let mut candidates = Vec::new();
|
||||
for field in schema.fields() {
|
||||
if vector_dimension(field.data_type()).is_none() {
|
||||
continue;
|
||||
}
|
||||
if field.name() == DEFAULT_VECTOR_COLUMN {
|
||||
named = Some(field.name().clone());
|
||||
}
|
||||
candidates.push(field.name().clone());
|
||||
}
|
||||
if let Some(name) = named {
|
||||
return Ok(name);
|
||||
}
|
||||
match candidates.as_slice() {
|
||||
[only] => Ok(only.clone()),
|
||||
[] => bail!("no fixed-size float vector column found"),
|
||||
many => bail!("several vector columns, pick one of: {}", many.join(", ")),
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_key_column(schema: &arrow_schema::Schema) -> Option<String> {
|
||||
schema
|
||||
.field_with_name(DEFAULT_KEY_COLUMN)
|
||||
.ok()
|
||||
.filter(|f| f.data_type() == &DataType::Utf8)
|
||||
.map(|f| f.name().clone())
|
||||
}
|
||||
|
||||
fn vector_dimension(data_type: &DataType) -> Option<usize> {
|
||||
let DataType::FixedSizeList(child, dimension) = data_type else {
|
||||
return None;
|
||||
};
|
||||
match child.data_type() {
|
||||
DataType::Float16 | DataType::Float32 | DataType::Float64 => Some(*dimension as usize),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use arrow_schema::{Field, Schema};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn vector_field(name: &str, dim: i32) -> Field {
|
||||
Field::new(
|
||||
name,
|
||||
DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), dim),
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_prefers_the_column_named_vector() {
|
||||
let schema = Schema::new(vec![
|
||||
Field::new("key", DataType::Utf8, false),
|
||||
vector_field("embedding", 4),
|
||||
vector_field("vector", 8),
|
||||
Field::new("tag", DataType::Utf8, true),
|
||||
]);
|
||||
let table = resolve_schema(&schema, None, None).unwrap();
|
||||
assert_eq!(table.vector_column, "vector");
|
||||
assert_eq!(table.dimension, 8);
|
||||
assert_eq!(table.key_column.as_deref(), Some("key"));
|
||||
assert_eq!(table.metadata_columns, vec!["embedding", "tag"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_uses_the_sole_vector_column() {
|
||||
let schema = Schema::new(vec![
|
||||
vector_field("embedding", 4),
|
||||
Field::new("tag", DataType::Utf8, true),
|
||||
]);
|
||||
let table = resolve_schema(&schema, None, None).unwrap();
|
||||
assert_eq!(table.vector_column, "embedding");
|
||||
assert_eq!(table.key_column, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_rejects_ambiguous_vector_columns() {
|
||||
let schema = Schema::new(vec![vector_field("a", 4), vector_field("b", 4)]);
|
||||
assert!(resolve_schema(&schema, None, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_rejects_a_non_vector_column() {
|
||||
let schema = Schema::new(vec![
|
||||
Field::new("vector", DataType::Utf8, true),
|
||||
vector_field("embedding", 4),
|
||||
]);
|
||||
assert!(resolve_schema(&schema, Some("vector"), None).is_err());
|
||||
let table = resolve_schema(&schema, None, Some("vector")).unwrap();
|
||||
assert_eq!(table.vector_column, "embedding");
|
||||
assert_eq!(table.key_column.as_deref(), Some("vector"));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user