mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-29 17:09:31 +00:00
* feat: configurable hybrid search fusion strategy (#169) Add WeightedScore fusion as an alternative to the default RRF algorithm. Users can now tune search behavior via env vars (SEARCH_FUSION_STRATEGY, SEARCH_FTS_WEIGHT, SEARCH_VECTOR_WEIGHT, SEARCH_RRF_K) or by passing SearchConfig with the new fields. Default behavior (RRF, k=60) is unchanged. - Add FusionStrategy enum (Rrf/WeightedScore) to workspace::search - Add weighted_score_fusion() and fuse_results() dispatcher - Add config/search.rs with WorkspaceSearchConfig from env vars - Wire search defaults through Workspace struct - Update both postgres and libsql backends to use fuse_results() - Add 7 new tests (4 fusion + 3 config) Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: swap default search weights to match issue #169 spec (0.7 vector / 0.3 FTS) The issue spec says "0.7/0.3 (vector/keyword) for weighted mode" but our defaults had fts_weight=0.7, vector_weight=0.3 (inverted). Also fixes the misleading docstring on weighted_score_fusion that claimed 1/rank normalizes to [0,1]. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: validate weight inputs and update stale doc comments - Reject NaN, infinite, and negative values for SEARCH_FTS_WEIGHT and SEARCH_VECTOR_WEIGHT with a clear ConfigError - Fix module-level docs that incorrectly claimed WeightedScore "normalizes per-method scores to [0,1]" - Update SearchResult.score doc from "Combined RRF score" to strategy-agnostic "Combined fusion score" Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: validate weight setters against NaN/inf/negative values with_fts_weight() and with_vector_weight() now silently ignore non-finite (NaN, ±inf) and negative values, matching the env var validation already in place for SEARCH_FTS_WEIGHT / SEARCH_VECTOR_WEIGHT. Values > 1.0 remain valid since weights are normalized internally. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: use crate-wide ENV_MUTEX in search config tests Replace the module-local `ENV_MUTEX` in `search.rs` with a shared `crate::config::helpers::ENV_MUTEX` to prevent cross-module env races when `cargo test` runs tests in parallel. Addresses copilot review comment. Tracked in #245. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: per-strategy weight defaults to match issue #169 spec RRF mode now defaults to 0.5/0.5 (fts/vector) and WeightedScore defaults to 0.3/0.7, matching the acceptance criteria in #169. Previously both modes used 0.3/0.7 uniformly. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: reject both weights=0 in weighted fusion mode When both SEARCH_FTS_WEIGHT and SEARCH_VECTOR_WEIGHT are 0.0 under WeightedScore strategy, all scores would be 0.0, producing arbitrary ordering. RRF mode is unaffected since it ignores weights entirely. Addresses Copilot review comment. The other comment (rrf_k=0 division by zero) is a false positive — ranks are 1-based, so k=0 just gives inverse-rank scoring with no infinity. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: clarify weight doc comments and error key - SearchConfig field docs: clarify that Default always uses 0.5, per-strategy defaults only apply via WorkspaceSearchConfig::resolve() - WorkspaceSearchConfig field docs: same clarification - Error key for both-weights-zero now references both env vars Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: remove broken intra-doc links to pub(crate) resolve() WorkspaceSearchConfig::resolve is pub(crate), so linking to it from public field docs triggers rustdoc private_intra_doc_links warnings. Switch to plain-text references. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: add document_path to weighted_score_fusion results The weighted_score_fusion function was missing the document_path field added in a recent main branch commit, causing a compile error after rebase. Co-Authored-By: Claude Opus 4.6 <[email protected]> * chore: trigger CI re-check after rebase * fix: resolve pre-existing staging fmt and clippy issues - Fix import ordering in cli/mod.rs (cargo fmt) - Fix line wrapping in tools/mcp/auth.rs (cargo fmt) - Move path_routing_tests before MemoryTreeTool to fix clippy::items_after_test_module [skip-regression-check] * fix: remove duplicate path_routing_tests module after rebase [skip-regression-check] --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
506 lines
16 KiB
Rust
506 lines
16 KiB
Rust
//! Database repository for workspace persistence.
|
|
//!
|
|
//! All workspace data is stored in PostgreSQL:
|
|
//! - Documents in `memory_documents` table
|
|
//! - Chunks in `memory_chunks` table (with FTS and vector indexes)
|
|
|
|
use chrono::{DateTime, Utc};
|
|
use deadpool_postgres::Pool;
|
|
use pgvector::Vector;
|
|
use uuid::Uuid;
|
|
|
|
use crate::error::WorkspaceError;
|
|
|
|
use crate::workspace::document::{MemoryChunk, MemoryDocument, WorkspaceEntry};
|
|
use crate::workspace::search::{RankedResult, SearchConfig, SearchResult, fuse_results};
|
|
|
|
/// Database repository for workspace operations.
|
|
pub struct Repository {
|
|
pool: Pool,
|
|
}
|
|
|
|
impl Repository {
|
|
/// Create a new repository with a connection pool.
|
|
pub fn new(pool: Pool) -> Self {
|
|
Self { pool }
|
|
}
|
|
|
|
/// Get a connection from the pool.
|
|
async fn conn(&self) -> Result<deadpool_postgres::Object, WorkspaceError> {
|
|
self.pool
|
|
.get()
|
|
.await
|
|
.map_err(|e| WorkspaceError::SearchFailed {
|
|
reason: format!("Failed to get connection: {}", e),
|
|
})
|
|
}
|
|
|
|
// ==================== Document Operations ====================
|
|
|
|
/// Get a document by its path.
|
|
pub async fn get_document_by_path(
|
|
&self,
|
|
user_id: &str,
|
|
agent_id: Option<Uuid>,
|
|
path: &str,
|
|
) -> Result<MemoryDocument, WorkspaceError> {
|
|
let conn = self.conn().await?;
|
|
|
|
let row = conn
|
|
.query_opt(
|
|
r#"
|
|
SELECT id, user_id, agent_id, path, content,
|
|
created_at, updated_at, metadata
|
|
FROM memory_documents
|
|
WHERE user_id = $1 AND agent_id IS NOT DISTINCT FROM $2 AND path = $3
|
|
"#,
|
|
&[&user_id, &agent_id, &path],
|
|
)
|
|
.await
|
|
.map_err(|e| WorkspaceError::SearchFailed {
|
|
reason: format!("Query failed: {}", e),
|
|
})?;
|
|
|
|
match row {
|
|
Some(row) => Ok(self.row_to_document(&row)),
|
|
None => Err(WorkspaceError::DocumentNotFound {
|
|
doc_type: path.to_string(),
|
|
user_id: user_id.to_string(),
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// Get a document by ID.
|
|
pub async fn get_document_by_id(&self, id: Uuid) -> Result<MemoryDocument, WorkspaceError> {
|
|
let conn = self.conn().await?;
|
|
|
|
let row = conn
|
|
.query_opt(
|
|
r#"
|
|
SELECT id, user_id, agent_id, path, content,
|
|
created_at, updated_at, metadata
|
|
FROM memory_documents WHERE id = $1
|
|
"#,
|
|
&[&id],
|
|
)
|
|
.await
|
|
.map_err(|e| WorkspaceError::SearchFailed {
|
|
reason: format!("Query failed: {}", e),
|
|
})?;
|
|
|
|
match row {
|
|
Some(row) => Ok(self.row_to_document(&row)),
|
|
None => Err(WorkspaceError::DocumentNotFound {
|
|
doc_type: "unknown".to_string(),
|
|
user_id: "unknown".to_string(),
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// Get or create a document by path.
|
|
pub async fn get_or_create_document_by_path(
|
|
&self,
|
|
user_id: &str,
|
|
agent_id: Option<Uuid>,
|
|
path: &str,
|
|
) -> Result<MemoryDocument, WorkspaceError> {
|
|
// Try to get existing document first
|
|
match self.get_document_by_path(user_id, agent_id, path).await {
|
|
Ok(doc) => return Ok(doc),
|
|
Err(WorkspaceError::DocumentNotFound { .. }) => {}
|
|
Err(e) => return Err(e),
|
|
}
|
|
|
|
// Create new document
|
|
let conn = self.conn().await?;
|
|
let id = Uuid::new_v4();
|
|
let now = Utc::now();
|
|
let metadata = serde_json::json!({});
|
|
|
|
conn.execute(
|
|
r#"
|
|
INSERT INTO memory_documents (id, user_id, agent_id, path, content, metadata, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, '', $5, $6, $7)
|
|
ON CONFLICT (user_id, agent_id, path) DO NOTHING
|
|
"#,
|
|
&[&id, &user_id, &agent_id, &path, &metadata, &now, &now],
|
|
)
|
|
.await
|
|
.map_err(|e| WorkspaceError::SearchFailed {
|
|
reason: format!("Insert failed: {}", e),
|
|
})?;
|
|
|
|
// Fetch the document (might have been created by concurrent request)
|
|
self.get_document_by_path(user_id, agent_id, path).await
|
|
}
|
|
|
|
/// Update a document's content.
|
|
pub async fn update_document(&self, id: Uuid, content: &str) -> Result<(), WorkspaceError> {
|
|
let conn = self.conn().await?;
|
|
|
|
conn.execute(
|
|
"UPDATE memory_documents SET content = $2, updated_at = NOW() WHERE id = $1",
|
|
&[&id, &content],
|
|
)
|
|
.await
|
|
.map_err(|e| WorkspaceError::SearchFailed {
|
|
reason: format!("Update failed: {}", e),
|
|
})?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Delete a document by its path.
|
|
pub async fn delete_document_by_path(
|
|
&self,
|
|
user_id: &str,
|
|
agent_id: Option<Uuid>,
|
|
path: &str,
|
|
) -> Result<(), WorkspaceError> {
|
|
let conn = self.conn().await?;
|
|
|
|
// First get the document to delete its chunks
|
|
let doc = self.get_document_by_path(user_id, agent_id, path).await?;
|
|
self.delete_chunks(doc.id).await?;
|
|
|
|
// Delete the document
|
|
conn.execute(
|
|
r#"
|
|
DELETE FROM memory_documents
|
|
WHERE user_id = $1 AND agent_id IS NOT DISTINCT FROM $2 AND path = $3
|
|
"#,
|
|
&[&user_id, &agent_id, &path],
|
|
)
|
|
.await
|
|
.map_err(|e| WorkspaceError::SearchFailed {
|
|
reason: format!("Delete failed: {}", e),
|
|
})?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// List files and directories in a directory path.
|
|
///
|
|
/// Returns immediate children (not recursive).
|
|
/// Empty string lists the root directory.
|
|
pub async fn list_directory(
|
|
&self,
|
|
user_id: &str,
|
|
agent_id: Option<Uuid>,
|
|
directory: &str,
|
|
) -> Result<Vec<WorkspaceEntry>, WorkspaceError> {
|
|
let conn = self.conn().await?;
|
|
|
|
let rows = conn
|
|
.query(
|
|
"SELECT path, is_directory, updated_at, content_preview FROM list_workspace_files($1, $2, $3)",
|
|
&[&user_id, &agent_id, &directory],
|
|
)
|
|
.await
|
|
.map_err(|e| WorkspaceError::SearchFailed {
|
|
reason: format!("List directory failed: {}", e),
|
|
})?;
|
|
|
|
Ok(rows
|
|
.iter()
|
|
.map(|row| {
|
|
let updated_at: Option<DateTime<Utc>> = row.get("updated_at");
|
|
WorkspaceEntry {
|
|
path: row.get("path"),
|
|
is_directory: row.get("is_directory"),
|
|
updated_at,
|
|
content_preview: row.get("content_preview"),
|
|
}
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
/// List all file paths in the workspace (flat list).
|
|
pub async fn list_all_paths(
|
|
&self,
|
|
user_id: &str,
|
|
agent_id: Option<Uuid>,
|
|
) -> Result<Vec<String>, WorkspaceError> {
|
|
let conn = self.conn().await?;
|
|
|
|
let rows = conn
|
|
.query(
|
|
r#"
|
|
SELECT path FROM memory_documents
|
|
WHERE user_id = $1 AND agent_id IS NOT DISTINCT FROM $2
|
|
ORDER BY path
|
|
"#,
|
|
&[&user_id, &agent_id],
|
|
)
|
|
.await
|
|
.map_err(|e| WorkspaceError::SearchFailed {
|
|
reason: format!("List paths failed: {}", e),
|
|
})?;
|
|
|
|
Ok(rows.iter().map(|row| row.get("path")).collect())
|
|
}
|
|
|
|
/// List all documents for a user.
|
|
pub async fn list_documents(
|
|
&self,
|
|
user_id: &str,
|
|
agent_id: Option<Uuid>,
|
|
) -> Result<Vec<MemoryDocument>, WorkspaceError> {
|
|
let conn = self.conn().await?;
|
|
|
|
let rows = conn
|
|
.query(
|
|
r#"
|
|
SELECT id, user_id, agent_id, path, content,
|
|
created_at, updated_at, metadata
|
|
FROM memory_documents
|
|
WHERE user_id = $1 AND agent_id IS NOT DISTINCT FROM $2
|
|
ORDER BY updated_at DESC
|
|
"#,
|
|
&[&user_id, &agent_id],
|
|
)
|
|
.await
|
|
.map_err(|e| WorkspaceError::SearchFailed {
|
|
reason: format!("Query failed: {}", e),
|
|
})?;
|
|
|
|
Ok(rows.iter().map(|r| self.row_to_document(r)).collect())
|
|
}
|
|
|
|
fn row_to_document(&self, row: &tokio_postgres::Row) -> MemoryDocument {
|
|
MemoryDocument {
|
|
id: row.get("id"),
|
|
user_id: row.get("user_id"),
|
|
agent_id: row.get("agent_id"),
|
|
path: row.get("path"),
|
|
content: row.get("content"),
|
|
created_at: row.get("created_at"),
|
|
updated_at: row.get("updated_at"),
|
|
metadata: row.get("metadata"),
|
|
}
|
|
}
|
|
|
|
// ==================== Chunk Operations ====================
|
|
|
|
/// Delete all chunks for a document.
|
|
pub async fn delete_chunks(&self, document_id: Uuid) -> Result<(), WorkspaceError> {
|
|
let conn = self.conn().await?;
|
|
|
|
conn.execute(
|
|
"DELETE FROM memory_chunks WHERE document_id = $1",
|
|
&[&document_id],
|
|
)
|
|
.await
|
|
.map_err(|e| WorkspaceError::ChunkingFailed {
|
|
reason: format!("Delete failed: {}", e),
|
|
})?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Insert a chunk.
|
|
pub async fn insert_chunk(
|
|
&self,
|
|
document_id: Uuid,
|
|
chunk_index: i32,
|
|
content: &str,
|
|
embedding: Option<&[f32]>,
|
|
) -> Result<Uuid, WorkspaceError> {
|
|
let conn = self.conn().await?;
|
|
let id = Uuid::new_v4();
|
|
|
|
let embedding_vec = embedding.map(|e| Vector::from(e.to_vec()));
|
|
|
|
conn.execute(
|
|
r#"
|
|
INSERT INTO memory_chunks (id, document_id, chunk_index, content, embedding)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
"#,
|
|
&[&id, &document_id, &chunk_index, &content, &embedding_vec],
|
|
)
|
|
.await
|
|
.map_err(|e| WorkspaceError::ChunkingFailed {
|
|
reason: format!("Insert failed: {}", e),
|
|
})?;
|
|
|
|
Ok(id)
|
|
}
|
|
|
|
/// Update a chunk's embedding.
|
|
pub async fn update_chunk_embedding(
|
|
&self,
|
|
chunk_id: Uuid,
|
|
embedding: &[f32],
|
|
) -> Result<(), WorkspaceError> {
|
|
let conn = self.conn().await?;
|
|
let embedding_vec = Vector::from(embedding.to_vec());
|
|
|
|
conn.execute(
|
|
"UPDATE memory_chunks SET embedding = $2 WHERE id = $1",
|
|
&[&chunk_id, &embedding_vec],
|
|
)
|
|
.await
|
|
.map_err(|e| WorkspaceError::EmbeddingFailed {
|
|
reason: format!("Update failed: {}", e),
|
|
})?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Get chunks without embeddings for backfilling.
|
|
pub async fn get_chunks_without_embeddings(
|
|
&self,
|
|
user_id: &str,
|
|
agent_id: Option<Uuid>,
|
|
limit: usize,
|
|
) -> Result<Vec<MemoryChunk>, WorkspaceError> {
|
|
let conn = self.conn().await?;
|
|
|
|
let rows = conn
|
|
.query(
|
|
r#"
|
|
SELECT c.id, c.document_id, c.chunk_index, c.content, c.created_at
|
|
FROM memory_chunks c
|
|
JOIN memory_documents d ON d.id = c.document_id
|
|
WHERE d.user_id = $1 AND d.agent_id IS NOT DISTINCT FROM $2
|
|
AND c.embedding IS NULL
|
|
LIMIT $3
|
|
"#,
|
|
&[&user_id, &agent_id, &(limit as i64)],
|
|
)
|
|
.await
|
|
.map_err(|e| WorkspaceError::SearchFailed {
|
|
reason: format!("Query failed: {}", e),
|
|
})?;
|
|
|
|
Ok(rows
|
|
.iter()
|
|
.map(|row| MemoryChunk {
|
|
id: row.get("id"),
|
|
document_id: row.get("document_id"),
|
|
chunk_index: row.get("chunk_index"),
|
|
content: row.get("content"),
|
|
embedding: None,
|
|
created_at: row.get("created_at"),
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
// ==================== Search Operations ====================
|
|
|
|
/// Perform hybrid search combining FTS and vector similarity.
|
|
pub async fn hybrid_search(
|
|
&self,
|
|
user_id: &str,
|
|
agent_id: Option<Uuid>,
|
|
query: &str,
|
|
embedding: Option<&[f32]>,
|
|
config: &SearchConfig,
|
|
) -> Result<Vec<SearchResult>, WorkspaceError> {
|
|
let fts_results = if config.use_fts {
|
|
self.fts_search(user_id, agent_id, query, config.pre_fusion_limit)
|
|
.await?
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
let vector_results = if config.use_vector {
|
|
if let Some(embedding) = embedding {
|
|
self.vector_search(user_id, agent_id, embedding, config.pre_fusion_limit)
|
|
.await?
|
|
} else {
|
|
Vec::new()
|
|
}
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
Ok(fuse_results(fts_results, vector_results, config))
|
|
}
|
|
|
|
/// Full-text search using PostgreSQL ts_rank_cd.
|
|
async fn fts_search(
|
|
&self,
|
|
user_id: &str,
|
|
agent_id: Option<Uuid>,
|
|
query: &str,
|
|
limit: usize,
|
|
) -> Result<Vec<RankedResult>, WorkspaceError> {
|
|
let conn = self.conn().await?;
|
|
|
|
let rows = conn
|
|
.query(
|
|
r#"
|
|
SELECT c.id as chunk_id, c.document_id, d.path as document_path, c.content,
|
|
ts_rank_cd(c.content_tsv, plainto_tsquery('english', $3)) as rank
|
|
FROM memory_chunks c
|
|
JOIN memory_documents d ON d.id = c.document_id
|
|
WHERE d.user_id = $1 AND d.agent_id IS NOT DISTINCT FROM $2
|
|
AND c.content_tsv @@ plainto_tsquery('english', $3)
|
|
ORDER BY rank DESC
|
|
LIMIT $4
|
|
"#,
|
|
&[&user_id, &agent_id, &query, &(limit as i64)],
|
|
)
|
|
.await
|
|
.map_err(|e| WorkspaceError::SearchFailed {
|
|
reason: format!("FTS query failed: {}", e),
|
|
})?;
|
|
|
|
Ok(rows
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, row)| RankedResult {
|
|
chunk_id: row.get("chunk_id"),
|
|
document_id: row.get("document_id"),
|
|
document_path: row.get("document_path"),
|
|
content: row.get("content"),
|
|
rank: (i + 1) as u32,
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
/// Vector similarity search using pgvector cosine distance.
|
|
async fn vector_search(
|
|
&self,
|
|
user_id: &str,
|
|
agent_id: Option<Uuid>,
|
|
embedding: &[f32],
|
|
limit: usize,
|
|
) -> Result<Vec<RankedResult>, WorkspaceError> {
|
|
let conn = self.conn().await?;
|
|
let embedding_vec = Vector::from(embedding.to_vec());
|
|
|
|
let rows = conn
|
|
.query(
|
|
r#"
|
|
SELECT c.id as chunk_id, c.document_id, d.path as document_path, c.content,
|
|
1 - (c.embedding <=> $3) as similarity
|
|
FROM memory_chunks c
|
|
JOIN memory_documents d ON d.id = c.document_id
|
|
WHERE d.user_id = $1 AND d.agent_id IS NOT DISTINCT FROM $2
|
|
AND c.embedding IS NOT NULL
|
|
ORDER BY c.embedding <=> $3
|
|
LIMIT $4
|
|
"#,
|
|
&[&user_id, &agent_id, &embedding_vec, &(limit as i64)],
|
|
)
|
|
.await
|
|
.map_err(|e| WorkspaceError::SearchFailed {
|
|
reason: format!("Vector query failed: {}", e),
|
|
})?;
|
|
|
|
Ok(rows
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(i, row)| RankedResult {
|
|
chunk_id: row.get("chunk_id"),
|
|
document_id: row.get("document_id"),
|
|
document_path: row.get("document_path"),
|
|
content: row.get("content"),
|
|
rank: (i + 1) as u32,
|
|
})
|
|
.collect())
|
|
}
|
|
}
|