klieo_memory_pgvector/
client.rs1use crate::error::store_err;
4use dashmap::DashMap;
5use klieo_core::error::MemoryError;
6use sqlx_postgres::{PgPool, PgPoolOptions};
7use std::sync::Arc;
8use tokio::sync::OnceCell;
9
10const TABLE_VERSION_SUFFIX: &str = "_v1";
11const DEFAULT_TABLE_PREFIX: &str = "klieo_facts";
12const DEFAULT_MAX_CONNECTIONS: u32 = 5;
13const MAX_PREFIX_LEN: usize = 48;
14
15#[derive(Clone, Debug)]
21#[non_exhaustive]
22pub struct PgvectorConfig {
23 url: String,
24 table_prefix: String,
25 embedder_id: String,
26 max_connections: u32,
27}
28
29impl PgvectorConfig {
30 pub fn new(url: impl Into<String>) -> Self {
32 Self {
33 url: url.into(),
34 table_prefix: DEFAULT_TABLE_PREFIX.to_string(),
35 embedder_id: "default".to_string(),
36 max_connections: DEFAULT_MAX_CONNECTIONS,
37 }
38 }
39
40 pub fn with_table_prefix(mut self, prefix: impl Into<String>) -> Self {
43 self.table_prefix = prefix.into();
44 self
45 }
46
47 pub fn with_embedder_id(mut self, id: impl Into<String>) -> Self {
51 self.embedder_id = id.into();
52 self
53 }
54
55 pub fn with_max_connections(mut self, n: u32) -> Self {
57 self.max_connections = n;
58 self
59 }
60
61 pub fn embedder_id(&self) -> &str {
63 &self.embedder_id
64 }
65
66 pub(crate) fn url(&self) -> &str {
67 &self.url
68 }
69}
70
71#[derive(Clone)]
75pub(crate) struct PgvectorHandle {
76 pub(crate) pool: PgPool,
77 pub(crate) table: String,
78 bootstrapped: Arc<DashMap<u64, Arc<OnceCell<()>>>>,
79}
80
81impl PgvectorHandle {
82 pub(crate) async fn connect(cfg: &PgvectorConfig) -> Result<Self, MemoryError> {
83 let table = build_table_name(&cfg.table_prefix)?;
84 let pool = PgPoolOptions::new()
85 .max_connections(cfg.max_connections)
86 .connect(cfg.url())
87 .await
88 .map_err(store_err)?;
89 Ok(Self {
90 pool,
91 table,
92 bootstrapped: Arc::new(DashMap::new()),
93 })
94 }
95
96 pub(crate) async fn ensure_table(&self, vector_dim: u64) -> Result<(), MemoryError> {
97 let cell = self
98 .bootstrapped
99 .entry(vector_dim)
100 .or_insert_with(|| Arc::new(OnceCell::new()))
101 .clone();
102 cell.get_or_try_init(|| self.bootstrap_table(vector_dim))
103 .await?;
104 Ok(())
105 }
106
107 async fn bootstrap_table(&self, vector_dim: u64) -> Result<(), MemoryError> {
108 let table = &self.table;
112 let statements = [
113 "CREATE EXTENSION IF NOT EXISTS vector".to_string(),
114 format!(
115 "CREATE TABLE IF NOT EXISTS {table} (\
116 fact_id uuid PRIMARY KEY, \
117 text text NOT NULL, \
118 metadata jsonb NOT NULL DEFAULT 'null'::jsonb, \
119 embedding vector({vector_dim}) NOT NULL, \
120 scope_kind text NOT NULL, \
121 scope_value text NOT NULL)"
122 ),
123 format!(
124 "CREATE INDEX IF NOT EXISTS {table}_embedding_hnsw \
125 ON {table} USING hnsw (embedding vector_cosine_ops)"
126 ),
127 format!(
128 "CREATE INDEX IF NOT EXISTS {table}_scope ON {table} (scope_kind, scope_value)"
129 ),
130 ];
131 for sql in statements {
132 sqlx_core::query::query(&sql)
133 .execute(&self.pool)
134 .await
135 .map_err(store_err)?;
136 }
137 let actual_dim: Option<i32> = sqlx_core::query_scalar::query_scalar(
143 "SELECT a.atttypmod FROM pg_attribute a \
144 JOIN pg_class c ON a.attrelid = c.oid \
145 WHERE c.relname = $1 AND a.attname = 'embedding' AND NOT a.attisdropped",
146 )
147 .bind(table)
148 .fetch_optional(&self.pool)
149 .await
150 .map_err(store_err)?;
151 if let Some(actual) = actual_dim {
152 if actual > 0 && actual as u64 != vector_dim {
153 return Err(MemoryError::Embedding(format!(
154 "pgvector table `{table}` has embedding dimension {actual}, but the \
155 configured embedder produces {vector_dim}-dim vectors; a model change \
156 needs a new table or a re-index"
157 )));
158 }
159 }
160 Ok(())
161 }
162}
163
164fn build_table_name(prefix: &str) -> Result<String, MemoryError> {
168 if !is_valid_identifier(prefix) {
169 return Err(MemoryError::Store(format!(
170 "invalid table prefix `{prefix}`: expected a lowercase identifier \
171 matching [a-z_][a-z0-9_]* of at most {MAX_PREFIX_LEN} chars"
172 )));
173 }
174 Ok(format!("{prefix}{TABLE_VERSION_SUFFIX}"))
175}
176
177fn is_valid_identifier(s: &str) -> bool {
178 if s.is_empty() || s.len() > MAX_PREFIX_LEN {
179 return false;
180 }
181 let mut chars = s.chars();
182 let first_ok = chars
183 .next()
184 .map(|c| c.is_ascii_lowercase() || c == '_')
185 .unwrap_or(false);
186 first_ok
187 && s.chars()
188 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194
195 #[test]
196 fn valid_prefix_builds_versioned_table_name() {
197 assert_eq!(build_table_name("klieo_facts").unwrap(), "klieo_facts_v1");
198 assert_eq!(build_table_name("triage").unwrap(), "triage_v1");
199 }
200
201 #[test]
202 fn injection_prefix_is_rejected() {
203 assert!(build_table_name("facts; DROP TABLE users").is_err());
204 assert!(build_table_name("Facts").is_err()); assert!(build_table_name("1facts").is_err()); assert!(build_table_name("").is_err());
207 }
208
209 #[test]
210 fn overlong_prefix_is_rejected() {
211 let long = "a".repeat(MAX_PREFIX_LEN + 1);
212 assert!(build_table_name(&long).is_err());
213 }
214}