use std::{
collections::HashMap,
sync::{Arc, Mutex},
};
use anyhow::{Context as _, Result};
use derive_builder::Builder;
use swiftide_core::{
indexing::{Chunk, EmbeddedField},
querying::search_strategies::HybridSearch,
};
use tera::Context;
use tokio::sync::RwLock;
pub mod node_cache;
pub mod persist;
pub mod retrieve;
const DEFAULT_INDEXING_SCHEMA: &str = include_str!("schema.sql");
const DEFAULT_UPSERT_QUERY: &str = include_str!("upsert.sql");
const DEFAULT_HYBRID_QUERY: &str = include_str!("hybrid_query.sql");
#[derive(Clone, Builder)]
#[builder(setter(into))]
pub struct Duckdb<T: Chunk = String> {
#[builder(setter(custom))]
connection: Arc<Mutex<duckdb::Connection>>,
#[builder(default = "swiftide".into())]
table_name: String,
#[builder(default = self.default_schema())]
schema: String,
#[builder(default)]
vectors: HashMap<EmbeddedField, usize>,
#[builder(default = "256")]
batch_size: usize,
#[builder(private, default = self.default_node_upsert_sql())]
node_upsert_sql: String,
#[builder(default = "swiftide_cache".into())]
cache_table: String,
#[builder(private, default = Arc::new(false.into()))]
cache_table_created: Arc<RwLock<bool>>,
#[builder(default = "String::new()")]
cache_key_prefix: String,
#[builder(default)]
#[allow(dead_code)]
upsert_vectors: bool,
#[builder(default)]
chunk_type: std::marker::PhantomData<T>,
}
impl<T: Chunk> std::fmt::Debug for Duckdb<T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Duckdb")
.field("connection", &"Arc<Mutex<duckdb::Connection>>")
.field("table_name", &self.table_name)
.field("batch_size", &self.batch_size)
.finish()
}
}
impl Duckdb<String> {
pub fn builder() -> DuckdbBuilder<String> {
DuckdbBuilder::<String>::default()
}
}
impl<T: Chunk> Duckdb<T> {
pub fn table_name(&self) -> &str {
&self.table_name
}
pub fn cache_table(&self) -> &str {
&self.cache_table
}
pub fn connection(&self) -> &Mutex<duckdb::Connection> {
&self.connection
}
pub fn create_vector_indices(&self) -> Result<()> {
let table_name = &self.table_name;
let mut conn = self.connection.lock().unwrap();
let tx = conn.transaction().context("Failed to start transaction")?;
{
for vector in self.vectors.keys() {
tx.execute(
&format!(
"CREATE INDEX IF NOT EXISTS idx_{vector} ON {table_name} USING hnsw ({vector}) WITH (metric = 'cosine')",
),
[],
)
.context("Could not create index")?;
}
}
tx.commit().context("Failed to commit transaction")?;
Ok(())
}
pub async fn lazy_create_cache(&self) -> anyhow::Result<()> {
if !*self.cache_table_created.read().await {
let mut lock = self.cache_table_created.write().await;
let conn = self.connection.lock().unwrap();
conn.execute(
&format!(
"CREATE TABLE IF NOT EXISTS {} (uuid TEXT PRIMARY KEY, path TEXT)",
self.cache_table
),
[],
)
.context("Could not create table")?;
conn.execute(
&format!(
"CREATE INDEX IF NOT EXISTS idx_path ON {} (path)",
self.cache_table
),
[],
)
.context("Could not create index")?;
*lock = true;
}
Ok(())
}
pub fn node_key(&self, node: &swiftide_core::indexing::Node<T>) -> String {
let cache_id = node.parent_id().unwrap_or_else(|| node.id());
self.node_key_for_id(cache_id)
}
pub fn node_key_for_id(&self, id: uuid::Uuid) -> String {
format!("{}.{}", self.cache_key_prefix, id)
}
async fn insert_cache_key(&self, key: String, path: String) {
if let Err(err) = self
.lazy_create_cache()
.await
.context("failed to create cache table")
{
tracing::error!("Failed to create cache table: {:#}", err);
return;
}
let sql = format!(
"INSERT INTO {} (uuid, path) VALUES (?, ?) ON CONFLICT (uuid) DO NOTHING",
self.cache_table
);
let lock = self.connection.lock().unwrap();
let mut stmt = match lock
.prepare(&sql)
.context("Failed to prepare duckdb statement for cache set")
{
Ok(stmt) => stmt,
Err(err) => {
tracing::error!(
"Failed to prepare duckdb statement for cache set: {:#}",
err
);
return;
}
};
if let Err(err) = stmt
.execute([key, path])
.context("failed to insert into cache table")
{
tracing::error!("Failed to insert into cache table: {:#}", err);
}
}
fn hybrid_query_sql(
&self,
search_strategy: &HybridSearch,
query: &str,
embedding: &[f32],
) -> Result<String> {
let table_name = &self.table_name;
let (field_name, embedding_size) = self
.vectors
.iter()
.next()
.context("No vectors configured")?;
if self.vectors.len() > 1 {
tracing::warn!(
"Multiple vectors configured, but only the first one will be used: {:?}",
self.vectors
);
}
let embedding = embedding
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(",");
let context = Context::from_value(serde_json::json!({
"table_name": table_name,
"top_n": search_strategy.top_n(),
"top_k": search_strategy.top_k(),
"embedding_name": field_name,
"embedding_size": embedding_size,
"query": wrap_and_escape(query),
"embedding": embedding,
}))?;
let rendered = tera::Tera::one_off(DEFAULT_HYBRID_QUERY, &context, false)?;
Ok(rendered)
}
}
fn wrap_and_escape(s: &str) -> String {
let quote = '\'';
let mut buf = String::new();
buf.push(quote);
let chars = s.chars();
for ch in chars {
if ch == quote {
buf.push(ch);
}
buf.push(ch);
}
buf.push(quote);
buf
}
impl<T: Chunk> DuckdbBuilder<T> {
pub fn connection(&mut self, connection: impl Into<duckdb::Connection>) -> &mut Self {
self.connection = Some(Arc::new(Mutex::new(connection.into())));
self
}
pub fn with_vector(&mut self, field: EmbeddedField, size: usize) -> &mut Self {
self.vectors
.get_or_insert_with(HashMap::new)
.insert(field, size);
self
}
fn default_schema(&self) -> String {
let mut context = Context::default();
context.insert("table_name", &self.table_name);
context.insert("vectors", &self.vectors.clone().unwrap_or_default());
tera::Tera::one_off(DEFAULT_INDEXING_SCHEMA, &context, false)
.expect("Could not render schema; infalllible")
}
fn default_node_upsert_sql(&self) -> String {
let mut context = Context::default();
context.insert("table_name", &self.table_name);
context.insert("vectors", &self.vectors.clone().unwrap_or_default());
context.insert("upsert_vectors", &self.upsert_vectors);
context.insert(
"vector_field_names",
&self
.vectors
.as_ref()
.map(|v| v.keys().collect::<Vec<_>>())
.unwrap_or_default(),
);
tracing::info!("Rendering upsert sql");
tera::Tera::one_off(DEFAULT_UPSERT_QUERY, &context, false)
.expect("could not render upsert query; infallible")
}
}