Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/core/src/db/schema.surql
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,9 @@ DEFINE INDEX IF NOT EXISTS idx_embedding_node ON TABLE embedding COLUMNS node;
DEFINE INDEX IF NOT EXISTS idx_embedding_stale ON TABLE embedding COLUMNS stale;
-- Unique constraint: one embedding per (node, model, chunk_index) combination
DEFINE INDEX IF NOT EXISTS idx_embedding_unique ON TABLE embedding COLUMNS node, model_name, chunk_index UNIQUE;
-- MTREE vector index for fast semantic similarity search (Issue #776)
-- Uses cosine distance for nomic-embed-text-v1.5 embeddings
DEFINE INDEX IF NOT EXISTS idx_embedding_vector ON TABLE embedding COLUMNS vector MTREE DIMENSION 768 DIST COSINE TYPE F32;

-- ============================================================================
-- Schema Version Tracking
Expand Down
17 changes: 13 additions & 4 deletions packages/core/src/db/surreal_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4655,15 +4655,20 @@ where
similarity: f64,
}

// Query to get best similarity per node across all chunks
// Query using KNN operator for MTREE-indexed vector search (Issue #776)
// The <|K|> operator leverages the MTREE index for fast approximate nearest neighbor search.
// We fetch more candidates (limit * 3) to account for:
// 1. Multiple chunks per node (grouped later)
// 2. Threshold filtering (some may not meet similarity threshold)
// Note: SurrealDB doesn't support HAVING, so we use a subquery with WHERE
let knn_limit = limit * 3; // Fetch more candidates for grouping/filtering
let query = r#"
SELECT * FROM (
SELECT
node,
math::max(vector::similarity::cosine(vector, $query_vector)) AS similarity
FROM embedding
WHERE stale = false
WHERE stale = false AND vector <|$knn_limit|> $query_vector
GROUP BY node
)
WHERE similarity > $threshold
Expand All @@ -4677,6 +4682,7 @@ where
.bind(("query_vector", query_vector.to_vec()))
.bind(("threshold", min_similarity))
.bind(("limit", limit))
.bind(("knn_limit", knn_limit))
.await
.context("Failed to execute embedding search")?;

Expand Down Expand Up @@ -5041,13 +5047,16 @@ where

/// Create a stale embedding marker for a new root node
///
/// Creates an empty embedding record marked as stale to queue it for processing.
/// Creates an embedding record with a zero vector marked as stale to queue it for processing.
/// Used when a new root node is created that should be embedded.
///
/// Note: Uses a 768-dim zero vector instead of empty array to ensure compatibility
/// with MTREE vector index which requires consistent dimensions.
pub async fn create_stale_embedding_marker(&self, node_id: &str) -> Result<()> {
let query = r#"
CREATE embedding CONTENT {
node: type::thing('node', $node_id),
vector: [],
vector: array::repeat(0.0, 768),
dimension: 768,
model_name: 'nomic-embed-text-v1.5',
chunk_index: 0,
Expand Down
4 changes: 3 additions & 1 deletion packages/core/src/services/embedding_processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,9 @@ where
/// # Returns
/// A new EmbeddingProcessor instance with active background task
pub fn new(embedding_service: Arc<NodeEmbeddingService<C>>) -> Result<Self, NodeServiceError> {
tracing::info!("EmbeddingProcessor initializing (event-driven model with per-root debounce)");
tracing::info!(
"EmbeddingProcessor initializing (event-driven model with per-root debounce)"
);

let (trigger_tx, mut trigger_rx) = mpsc::channel::<()>(10);
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
Expand Down
11 changes: 9 additions & 2 deletions packages/core/src/services/embedding_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,10 @@ where
// Get stale root IDs from embedding table, filtered by debounce duration
let stale_ids = self
.store
.get_stale_embedding_root_ids(Some(batch_size as i64), self.config.debounce_duration_secs)
.get_stale_embedding_root_ids(
Some(batch_size as i64),
self.config.debounce_duration_secs,
)
.await
.map_err(|e| {
NodeServiceError::query_failed(format!("Failed to query stale embeddings: {}", e))
Expand Down Expand Up @@ -876,7 +879,11 @@ mod tests {
);

// Verify chunks cover the entire content
assert_eq!(chunks.first().unwrap().0, 0, "First chunk should start at 0");
assert_eq!(
chunks.first().unwrap().0,
0,
"First chunk should start at 0"
);
assert_eq!(
chunks.last().unwrap().1 as usize,
content.len(),
Expand Down
Loading