use async_trait::async_trait;
use sqlx::PgPool;
use sqlx::QueryBuilder;
use sqlx::postgres::PgPoolOptions;
use crate::embedding::vector_index::SearchHit;
use crate::error::{KernelError, Result};
#[derive(sqlx::FromRow)]
struct ScoreRow {
id: i64,
score: f64,
}
fn vec_literal(v: &[f32]) -> String {
let mut s = String::from("[");
for (i, f) in v.iter().enumerate() {
if i > 0 {
s.push(',');
}
s.push_str(&f.to_string());
}
s.push(']');
s
}
#[derive(Debug, Clone, Default)]
pub struct PgVectorOpts {
pub half: bool,
pub hnsw_m: Option<u32>,
pub hnsw_ef_construction: Option<u32>,
pub hnsw_ef_search: Option<u32>,
}
pub struct PgVectorIndex {
pool: PgPool,
table: String,
dim: usize,
half: bool,
hnsw_m: Option<u32>,
hnsw_ef_construction: Option<u32>,
}
impl PgVectorIndex {
pub async fn new(url: &str, table: &str, dim: usize) -> Result<Self> {
Self::new_with_opts(url, table, dim, PgVectorOpts::default()).await
}
pub async fn new_halfvec(url: &str, table: &str, dim: usize) -> Result<Self> {
Self::new_with_opts(
url,
table,
dim,
PgVectorOpts {
half: true,
..Default::default()
},
)
.await
}
pub async fn new_with_opts(
url: &str,
table: &str,
dim: usize,
opts: PgVectorOpts,
) -> Result<Self> {
validate_table_name(table)?;
let mut pool_opts = PgPoolOptions::new().max_connections(8);
if let Some(ef) = opts.hnsw_ef_search {
pool_opts = pool_opts.after_connect(move |conn, _meta| {
Box::pin(async move {
sqlx::query(&format!("SET hnsw.ef_search = {ef}"))
.execute(conn)
.await?;
Ok(())
})
});
}
let pool = pool_opts
.connect(url)
.await
.map_err(|e| KernelError::Embedding(format!("pgvector connect: {e}")))?;
let idx = Self {
pool,
table: table.to_string(),
dim,
half: opts.half,
hnsw_m: opts.hnsw_m,
hnsw_ef_construction: opts.hnsw_ef_construction,
};
idx.init_schema().await?;
Ok(idx)
}
fn sql_type(&self) -> &'static str {
if self.half { "halfvec" } else { "vector" }
}
fn ops_class(&self) -> &'static str {
if self.half {
"halfvec_cosine_ops"
} else {
"vector_cosine_ops"
}
}
async fn init_schema(&self) -> Result<()> {
let ty = self.sql_type();
let ops = self.ops_class();
let with = hnsw_with_clause(self.hnsw_m, self.hnsw_ef_construction);
sqlx::query(&format!(
"CREATE TABLE IF NOT EXISTS {} (id BIGINT PRIMARY KEY, vec {}({}) NOT NULL)",
self.table, ty, self.dim
))
.execute(&self.pool)
.await
.map_err(|e| KernelError::Embedding(format!("pgvector create table: {e}")))?;
sqlx::query(&format!(
"CREATE INDEX IF NOT EXISTS idx_{}_vec ON {} USING hnsw (vec {}){}",
self.table, self.table, ops, with
))
.execute(&self.pool)
.await
.map_err(|e| KernelError::Embedding(format!("pgvector hnsw index: {e}")))?;
Ok(())
}
pub fn pool(&self) -> &PgPool {
&self.pool
}
pub async fn remove_in_tx(&self, tx: &mut sqlx::PgConnection, ids: &[u64]) -> Result<()> {
if ids.is_empty() {
return Ok(());
}
let ids: Vec<i64> = ids.iter().map(|&i| to_pg_id(i)).collect::<Result<_>>()?;
sqlx::query(&format!("DELETE FROM {} WHERE id = ANY($1)", self.table))
.bind(&ids)
.execute(tx)
.await
.map_err(|e| KernelError::Embedding(format!("pgvector remove_in_tx: {e}")))?;
Ok(())
}
}
#[async_trait]
impl crate::embedding::AsyncVectorIndex for PgVectorIndex {
async fn add(&self, vectors: &[Vec<f32>], ids: &[u64]) -> Result<()> {
if vectors.len() != ids.len() {
return Err(KernelError::Embedding(format!(
"vectors.len() ({}) must equal ids.len() ({})",
vectors.len(),
ids.len()
)));
}
if vectors.is_empty() {
return Ok(());
}
let pg_ids: Vec<i64> = ids.iter().map(|&id| to_pg_id(id)).collect::<Result<_>>()?;
const ROWS_PER_CHUNK: usize = 16_000;
for chunk in vectors
.chunks(ROWS_PER_CHUNK)
.zip(pg_ids.chunks(ROWS_PER_CHUNK))
{
let (chunk_vecs, chunk_ids) = chunk;
let mut q = QueryBuilder::new("INSERT INTO ");
q.push(self.table.as_str());
q.push(" (id, vec) VALUES ");
for (i, (v, &id)) in chunk_vecs.iter().zip(chunk_ids.iter()).enumerate() {
if i > 0 {
q.push(", ");
}
q.push("(");
q.push_bind(id);
q.push(", ");
q.push_bind(vec_literal(v));
q.push("::");
q.push(self.sql_type());
q.push(")");
}
q.push(" ON CONFLICT (id) DO UPDATE SET vec = EXCLUDED.vec");
q.build()
.execute(&self.pool)
.await
.map_err(|e| KernelError::Embedding(format!("pgvector add: {e}")))?;
}
Ok(())
}
async fn remove(&self, ids: &[u64]) -> Result<()> {
if ids.is_empty() {
return Ok(());
}
let ids: Vec<i64> = ids.iter().map(|&i| to_pg_id(i)).collect::<Result<_>>()?;
sqlx::query(&format!("DELETE FROM {} WHERE id = ANY($1)", self.table))
.bind(&ids)
.execute(&self.pool)
.await
.map_err(|e| KernelError::Embedding(format!("pgvector remove: {e}")))?;
Ok(())
}
async fn search(&self, query: &[f32], k: usize) -> Result<Vec<SearchHit>> {
let q = vec_literal(query);
let ty = self.sql_type();
let rows: Vec<ScoreRow> = sqlx::query_as(&format!(
"SELECT id, 1 - (vec <=> $1::{ty}) AS score FROM {} ORDER BY vec <=> $1::{ty} LIMIT $2",
self.table
))
.bind(q)
.bind(k as i64)
.fetch_all(&self.pool)
.await
.map_err(|e| KernelError::Embedding(format!("pgvector search: {e}")))?;
Ok(rows
.into_iter()
.map(|r| SearchHit {
id: r.id as u64,
score: r.score as f32,
})
.collect())
}
async fn search_filtered(
&self,
query: &[f32],
k: usize,
allowlist: &[u64],
) -> Result<Vec<SearchHit>> {
if allowlist.is_empty() {
return Ok(Vec::new());
}
let q = vec_literal(query);
let ty = self.sql_type();
let allow: Vec<i64> = allowlist
.iter()
.map(|&i| to_pg_id(i))
.collect::<Result<_>>()?;
let rows: Vec<ScoreRow> = sqlx::query_as(&format!(
"SELECT id, 1 - (vec <=> $1::{ty}) AS score FROM {} WHERE id = ANY($2) \
ORDER BY vec <=> $1::{ty} LIMIT $3",
self.table
))
.bind(q)
.bind(&allow)
.bind(k as i64)
.fetch_all(&self.pool)
.await
.map_err(|e| KernelError::Embedding(format!("pgvector search_filtered: {e}")))?;
Ok(rows
.into_iter()
.map(|r| SearchHit {
id: r.id as u64,
score: r.score as f32,
})
.collect())
}
async fn len(&self) -> Result<usize> {
let n: i64 = sqlx::query_scalar(&format!("SELECT count(*) FROM {}", self.table))
.fetch_one(&self.pool)
.await
.map_err(|e| KernelError::Embedding(format!("pgvector len: {e}")))?;
Ok(n as usize)
}
fn dim(&self) -> usize {
self.dim
}
}
fn hnsw_with_clause(m: Option<u32>, ef_construction: Option<u32>) -> String {
let mut parts: Vec<String> = Vec::new();
if let Some(m) = m {
parts.push(format!("m = {m}"));
}
if let Some(ef) = ef_construction {
parts.push(format!("ef_construction = {ef}"));
}
if parts.is_empty() {
String::new()
} else {
format!(" WITH ({})", parts.join(", "))
}
}
fn to_pg_id(id: u64) -> Result<i64> {
i64::try_from(id).map_err(|_| KernelError::Embedding(format!("id {id} exceeds BIGINT range")))
}
fn validate_table_name(table: &str) -> Result<()> {
let mut chars = table.chars();
let first_ok = matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_');
let valid = first_ok && chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
if !valid {
return Err(KernelError::Embedding(format!(
"invalid table identifier: {table:?}"
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::embedding::AsyncVectorIndex;
fn pg_url() -> Option<String> {
std::env::var("LLMKERNEL_PG_URL").ok()
}
#[tokio::test]
async fn roundtrip_add_search_remove() {
let Some(url) = pg_url() else {
eprintln!("skip pgvector test: LLMKERNEL_PG_URL unset");
return;
};
let table = format!("lk_test_{}", line!());
let idx = PgVectorIndex::new(&url, &table, 3).await.expect("new");
let vecs = vec![
vec![1.0, 0.0, 0.0],
vec![0.0, 1.0, 0.0],
vec![0.0, 0.0, 1.0],
];
let ids = vec![10u64, 20, 30];
idx.add(&vecs, &ids).await.expect("add");
assert_eq!(idx.len().await.unwrap(), 3);
let hits = idx.search(&[1.0, 0.0, 0.0], 1).await.unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].id, 10);
let hits = idx
.search_filtered(&[1.0, 0.0, 0.0], 1, &[20, 30])
.await
.unwrap();
assert_eq!(hits.len(), 1);
assert_ne!(hits[0].id, 10);
idx.remove(&[10]).await.unwrap();
assert_eq!(idx.len().await.unwrap(), 2);
sqlx::query(&format!("DROP TABLE IF EXISTS {}", table))
.execute(&idx.pool)
.await
.ok();
}
#[tokio::test]
async fn roundtrip_halfvec() {
let Some(url) = pg_url() else {
eprintln!("skip pgvector halfvec test: LLMKERNEL_PG_URL unset");
return;
};
let table = format!("lk_test_hv_{}", line!());
let idx = PgVectorIndex::new_halfvec(&url, &table, 3)
.await
.expect("new_halfvec");
let vecs = vec![
vec![1.0, 0.0, 0.0],
vec![0.0, 1.0, 0.0],
vec![0.0, 0.0, 1.0],
];
let ids = vec![10u64, 20, 30];
idx.add(&vecs, &ids).await.expect("add");
assert_eq!(idx.len().await.unwrap(), 3);
let hits = idx.search(&[1.0, 0.0, 0.0], 1).await.unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].id, 10);
idx.remove(&[10]).await.unwrap();
assert_eq!(idx.len().await.unwrap(), 2);
sqlx::query(&format!("DROP TABLE IF EXISTS {}", table))
.execute(&idx.pool)
.await
.ok();
}
#[tokio::test]
async fn remove_in_tx_atomic_delete() {
let Some(url) = pg_url() else {
eprintln!("skip pgvector test: LLMKERNEL_PG_URL unset");
return;
};
let table = format!("lk_txtx_{}", line!());
let idx = PgVectorIndex::new(&url, &table, 3).await.expect("new");
idx.add(&[vec![1.0, 0.0, 0.0], vec![0.0, 1.0, 0.0]], &[11, 12])
.await
.expect("add");
assert_eq!(idx.len().await.unwrap(), 2);
let mut tx = idx.pool().begin().await.expect("begin tx");
idx.remove_in_tx(&mut tx, &[11])
.await
.expect("remove_in_tx");
tx.commit().await.expect("commit");
assert_eq!(idx.len().await.unwrap(), 1);
let mut tx2 = idx.pool().begin().await.expect("begin tx2");
idx.remove_in_tx(&mut tx2, &[12])
.await
.expect("remove_in_tx2");
tx2.rollback().await.expect("rollback");
assert_eq!(idx.len().await.unwrap(), 1);
sqlx::query(&format!("DROP TABLE IF EXISTS {}", table))
.execute(idx.pool())
.await
.ok();
}
#[test]
fn rejects_invalid_table_name() {
assert!(validate_table_name("lk_test_1").is_ok());
assert!(validate_table_name("_vec").is_ok());
assert!(validate_table_name("").is_err());
assert!(validate_table_name("1bad").is_err());
assert!(validate_table_name("rm; DROP").is_err());
assert!(validate_table_name("weird\"name").is_err());
assert!(validate_table_name("sch.tbl").is_err());
}
#[test]
fn hnsw_with_clause_variants() {
assert_eq!(hnsw_with_clause(None, None), "");
assert_eq!(hnsw_with_clause(Some(32), None), " WITH (m = 32)");
assert_eq!(
hnsw_with_clause(None, Some(200)),
" WITH (ef_construction = 200)"
);
assert_eq!(
hnsw_with_clause(Some(32), Some(200)),
" WITH (m = 32, ef_construction = 200)"
);
}
#[test]
fn default_opts_are_full_precision_and_untuned() {
let o = PgVectorOpts::default();
assert!(!o.half);
assert!(o.hnsw_m.is_none());
assert!(o.hnsw_ef_construction.is_none());
assert!(o.hnsw_ef_search.is_none());
}
#[test]
fn rejects_overflowing_id() {
assert_eq!(to_pg_id(0).unwrap(), 0);
assert_eq!(to_pg_id(42).unwrap(), 42);
assert_eq!(to_pg_id(i64::MAX as u64).unwrap(), i64::MAX);
assert!(to_pg_id((i64::MAX as u64) + 1).is_err());
assert!(to_pg_id(u64::MAX).is_err());
}
}