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
}
pub struct PgVectorIndex {
pool: PgPool,
table: String,
dim: usize,
}
impl PgVectorIndex {
pub async fn new(url: &str, table: &str, dim: usize) -> Result<Self> {
validate_table_name(table)?;
let pool = PgPoolOptions::new()
.max_connections(8)
.connect(url)
.await
.map_err(|e| KernelError::Embedding(format!("pgvector connect: {e}")))?;
let idx = Self {
pool,
table: table.to_string(),
dim,
};
idx.init_schema().await?;
Ok(idx)
}
async fn init_schema(&self) -> Result<()> {
sqlx::query(&format!(
"CREATE TABLE IF NOT EXISTS {} (id BIGINT PRIMARY KEY, vec vector({}) NOT NULL)",
self.table, 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 vector_cosine_ops)",
self.table, self.table
))
.execute(&self.pool)
.await
.map_err(|e| KernelError::Embedding(format!("pgvector hnsw index: {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<_>>()?;
let mut q = QueryBuilder::new("INSERT INTO ");
q.push(self.table.as_str());
q.push(" (id, vec) ");
q.push_values(vectors.iter().zip(pg_ids.iter()), |mut b, (v, &id)| {
b.push_bind(id).push_bind(vec_literal(v));
});
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 rows: Vec<ScoreRow> = sqlx::query_as(&format!(
"SELECT id, 1 - (vec <=> $1::vector) AS score FROM {} ORDER BY vec <=> $1::vector 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 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::vector) AS score FROM {} WHERE id = ANY($2) \
ORDER BY vec <=> $1::vector 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 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();
}
#[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 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());
}
}