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