#[cfg(test)]
mod fixtures;
mod persist;
mod pgv_table_types;
mod retrieve;
use anyhow::Result;
use derive_builder::Builder;
use sqlx::PgPool;
use std::fmt;
use std::sync::Arc;
use std::sync::OnceLock;
use tokio::time::Duration;
pub use pgv_table_types::{FieldConfig, MetadataConfig, VectorConfig};
const DB_POOL_CONN_MAX: u32 = 10;
const DB_POOL_CONN_RETRY_MAX: u32 = 3;
const DB_POOL_CONN_RETRY_DELAY_SECS: u64 = 3;
const BATCH_SIZE: usize = 50;
#[derive(Builder, Clone)]
#[builder(setter(into, strip_option), build_fn(error = "anyhow::Error"))]
pub struct PgVector {
#[builder(default = "String::from(\"swiftide_pgv_store\")")]
table_name: String,
vector_size: i32,
#[builder(default = "BATCH_SIZE")]
batch_size: usize,
#[builder(default)]
fields: Vec<FieldConfig>,
db_url: String,
#[builder(default = "DB_POOL_CONN_MAX")]
db_max_connections: u32,
#[builder(default = "DB_POOL_CONN_RETRY_MAX")]
db_max_retry: u32,
#[builder(default = "Duration::from_secs(DB_POOL_CONN_RETRY_DELAY_SECS)")]
db_conn_retry_delay: Duration,
#[builder(default = "Arc::new(OnceLock::new())")]
connection_pool: Arc<OnceLock<PgPool>>,
#[builder(default = "Arc::new(OnceLock::new())")]
sql_stmt_bulk_insert: Arc<OnceLock<Arc<String>>>,
}
impl fmt::Debug for PgVector {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("PgVector")
.field("table_name", &self.table_name)
.field("vector_size", &self.vector_size)
.field("batch_size", &self.batch_size)
.finish()
}
}
impl PgVector {
pub fn builder() -> PgVectorBuilder {
PgVectorBuilder::default()
}
pub async fn get_pool(&self) -> Result<&PgPool> {
self.pool_get_or_initialize().await
}
pub fn get_table_name(&self) -> &str {
&self.table_name
}
}
impl PgVectorBuilder {
pub fn with_vector(&mut self, config: impl Into<VectorConfig>) -> &mut Self {
self.fields
.get_or_insert_with(Self::default_fields)
.push(FieldConfig::Vector(config.into()));
self
}
pub fn with_metadata(&mut self, config: impl Into<MetadataConfig>) -> &mut Self {
self.fields
.get_or_insert_with(Self::default_fields)
.push(FieldConfig::Metadata(config.into()));
self
}
pub fn default_fields() -> Vec<FieldConfig> {
vec![FieldConfig::ID, FieldConfig::Chunk]
}
}
#[cfg(test)]
mod tests {
use crate::pgvector::fixtures::{PgVectorTestData, TestContext};
use futures_util::TryStreamExt;
use std::collections::HashSet;
use swiftide_core::{
Persist, Retrieve,
document::Document,
indexing::{self, EmbedMode, EmbeddedField},
querying::{Query, search_strategies::SimilaritySingleEmbedding, states},
};
use test_case::test_case;
#[test_log::test(tokio::test)]
async fn test_metadata_filter_with_vector_search() {
let test_context = TestContext::setup_with_cfg(
vec!["category", "priority"].into(),
HashSet::from([EmbeddedField::Combined]),
)
.await
.expect("Test setup failed");
let nodes = vec![
indexing::TextNode::new("content1")
.with_vectors([(EmbeddedField::Combined, vec![1.0; 384])])
.with_metadata(vec![("category", "A"), ("priority", "1")]),
indexing::TextNode::new("content2")
.with_vectors([(EmbeddedField::Combined, vec![1.1; 384])])
.with_metadata(vec![("category", "A"), ("priority", "2")]),
indexing::TextNode::new("content3")
.with_vectors([(EmbeddedField::Combined, vec![1.2; 384])])
.with_metadata(vec![("category", "B"), ("priority", "1")]),
]
.into_iter()
.map(|node| node.to_owned())
.collect();
test_context
.pgv_storage
.batch_store(nodes)
.await
.try_collect::<Vec<_>>()
.await
.unwrap();
let mut query = Query::<states::Pending>::new("test_query");
query.embedding = Some(vec![1.0; 384]);
let search_strategy =
SimilaritySingleEmbedding::from_filter("category = \"A\"".to_string());
let result = test_context
.pgv_storage
.retrieve(&search_strategy, query.clone())
.await
.unwrap();
assert_eq!(result.documents().len(), 2);
let contents = result
.documents()
.iter()
.map(Document::content)
.collect::<Vec<_>>();
assert!(contents.contains(&"content1"));
assert!(contents.contains(&"content2"));
let search_strategy =
SimilaritySingleEmbedding::from_filter("priority = \"1\"".to_string());
let result = test_context
.pgv_storage
.retrieve(&search_strategy, query)
.await
.unwrap();
assert_eq!(result.documents().len(), 2);
let contents = result
.documents()
.iter()
.map(Document::content)
.collect::<Vec<_>>();
assert!(contents.contains(&"content1"));
assert!(contents.contains(&"content3"));
}
#[test_log::test(tokio::test)]
async fn test_vector_similarity_search_accuracy() {
let test_context = TestContext::setup_with_cfg(
vec!["category", "priority"].into(),
HashSet::from([EmbeddedField::Combined]),
)
.await
.expect("Test setup failed");
let base_vector = vec![1.0; 384];
let similar_vector = base_vector.iter().map(|x| x + 0.1).collect::<Vec<_>>();
let dissimilar_vector = vec![-1.0; 384];
let nodes = vec![
indexing::TextNode::new("base_content")
.with_vectors([(EmbeddedField::Combined, base_vector)])
.with_metadata(vec![("category", "A"), ("priority", "1")]),
indexing::TextNode::new("similar_content")
.with_vectors([(EmbeddedField::Combined, similar_vector)])
.with_metadata(vec![("category", "A"), ("priority", "2")]),
indexing::TextNode::new("dissimilar_content")
.with_vectors([(EmbeddedField::Combined, dissimilar_vector)])
.with_metadata(vec![("category", "B"), ("priority", "1")]),
]
.into_iter()
.map(|node| node.to_owned())
.collect();
test_context
.pgv_storage
.batch_store(nodes)
.await
.try_collect::<Vec<_>>()
.await
.unwrap();
let mut query = Query::<states::Pending>::new("test_query");
query.embedding = Some(vec![1.0; 384]);
let search_strategy = SimilaritySingleEmbedding::default().with_top_k(2);
let result = test_context
.pgv_storage
.retrieve(&search_strategy, query)
.await
.unwrap();
assert_eq!(result.documents().len(), 2);
let contents = result
.documents()
.iter()
.map(Document::content)
.collect::<Vec<_>>();
assert!(contents.contains(&"base_content"));
assert!(contents.contains(&"similar_content"));
}
#[test_case(
// SingleWithMetadata - No Metadata
vec![
PgVectorTestData {
embed_mode: EmbedMode::SingleWithMetadata,
chunk: "single_no_meta_1",
metadata: None,
vectors: vec![PgVectorTestData::create_test_vector(EmbeddedField::Combined, 1.0)],
expected_in_results: true,
},
PgVectorTestData {
embed_mode: EmbedMode::SingleWithMetadata,
chunk: "single_no_meta_2",
metadata: None,
vectors: vec![PgVectorTestData::create_test_vector(EmbeddedField::Combined, 1.1)],
expected_in_results: true,
}
],
HashSet::from([EmbeddedField::Combined])
; "SingleWithMetadata mode without metadata")]
#[test_case(
// SingleWithMetadata - With Metadata
vec![
PgVectorTestData {
embed_mode: EmbedMode::SingleWithMetadata,
chunk: "single_with_meta_1",
metadata: Some(vec![
("category", "A"),
("priority", "high")
].into()),
vectors: vec![PgVectorTestData::create_test_vector(EmbeddedField::Combined, 1.2)],
expected_in_results: true,
},
PgVectorTestData {
embed_mode: EmbedMode::SingleWithMetadata,
chunk: "single_with_meta_2",
metadata: Some(vec![
("category", "B"),
("priority", "low")
].into()),
vectors: vec![PgVectorTestData::create_test_vector(EmbeddedField::Combined, 1.3)],
expected_in_results: true,
}
],
HashSet::from([EmbeddedField::Combined])
; "SingleWithMetadata mode with metadata")]
#[test_log::test(tokio::test)]
async fn test_persist_nodes(
test_cases: Vec<PgVectorTestData<'_>>,
vector_fields: HashSet<EmbeddedField>,
) {
let metadata_fields: Vec<&str> = test_cases
.iter()
.filter_map(|case| case.metadata.as_ref())
.flat_map(|metadata| metadata.iter().map(|(key, _)| key.as_str()))
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
let test_context = TestContext::setup_with_cfg(Some(metadata_fields), vector_fields)
.await
.expect("Test setup failed");
let nodes: Vec<indexing::TextNode> =
test_cases.iter().map(PgVectorTestData::to_node).collect();
let stored_nodes = test_context
.pgv_storage
.batch_store(nodes.clone())
.await
.try_collect::<Vec<_>>()
.await
.expect("Failed to store nodes");
assert_eq!(
stored_nodes.len(),
nodes.len(),
"All nodes should be stored"
);
for (test_case, stored_node) in test_cases.iter().zip(stored_nodes.iter()) {
assert_eq!(
stored_node.chunk, test_case.chunk,
"Stored chunk should match"
);
assert_eq!(
stored_node.embed_mode, test_case.embed_mode,
"Embed mode should match"
);
let stored_vectors = stored_node
.vectors
.as_ref()
.expect("Vectors should be present");
assert_eq!(
stored_vectors.len(),
test_case.vectors.len(),
"Vector count should match"
);
for (field, vector) in &test_case.vectors {
let mut query = Query::<states::Pending>::new("test_query");
query.embedding = Some(vector.clone());
let search_strategy =
SimilaritySingleEmbedding::default().with_top_k(nodes.len() as u64);
let result = test_context
.pgv_storage
.retrieve(&search_strategy, query)
.await
.expect("Retrieval should succeed");
if test_case.expected_in_results {
assert!(
result
.documents()
.iter()
.map(Document::content)
.collect::<Vec<_>>()
.contains(&test_case.chunk),
"Document should be found in results for field {field}",
);
}
}
}
}
}