Skip to content

Commit 16d35b0

Browse files
committed
feat: add ctxgraph-embed + ctxgraph-mcp with RRF fusion search (v0.3)
1 parent 32ccbff commit 16d35b0

File tree

25 files changed

+1741
-4019
lines changed

25 files changed

+1741
-4019
lines changed

Cargo.lock

Lines changed: 705 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ resolver = "2"
33
members = ["crates/*"]
44

55
[workspace.package]
6-
version = "0.2.0"
6+
version = "0.3.0"
77
edition = "2024"
88
license = "MIT"
99
repository = "https://github.com/rohansx/ctxgraph"
@@ -22,3 +22,4 @@ toml = "0.8"
2222
gline-rs = "1.0"
2323
ort = "=2.0.0-rc.9"
2424
ndarray = "0.16"
25+
fastembed = "4"

README.md

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,6 @@ Output: Entities → Postgres (Component), SQLite (Component), billing (Service
9090

9191
Entity types and relation labels are fully configurable via `ctxgraph.toml`. Define what matters to your domain — Person, Component, Decision, Policy, whatever fits.
9292

93-
For higher accuracy, optional tiers add coreference resolution, fuzzy dedup, and LLM-powered contradiction detection. Each tier is additive — Tier 1 alone covers 85%+ of structured text.
94-
9593
### Bi-Temporal History
9694

9795
Every relationship tracks two time dimensions:

crates/ctxgraph-cli/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ name = "ctxgraph"
1111
path = "src/main.rs"
1212

1313
[dependencies]
14-
ctxgraph = { version = "0.2.0", path = "../ctxgraph-core" }
14+
ctxgraph = { version = "0.3.0", path = "../ctxgraph-core" }
1515
clap = { workspace = true }
1616
serde_json = { workspace = true }
1717
chrono = { workspace = true }

crates/ctxgraph-core/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ thiserror = { workspace = true }
1414
uuid = { workspace = true }
1515
rusqlite = { workspace = true }
1616
toml = { workspace = true }
17-
ctxgraph-extract = { path = "../ctxgraph-extract", version = "0.2.0", optional = true }
17+
ctxgraph-extract = { path = "../ctxgraph-extract", version = "0.3.0", optional = true }
1818

1919
[features]
2020
default = ["extract"]

crates/ctxgraph-core/src/error.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ pub enum CtxGraphError {
2323
#[error("extraction error: {0}")]
2424
Extraction(String),
2525

26+
#[error("embed error: {0}")]
27+
Embed(String),
28+
2629
#[error("io error: {0}")]
2730
Io(#[from] std::io::Error),
2831
}

crates/ctxgraph-core/src/graph.rs

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,113 @@ impl Graph {
256256
.link_episode_entity(episode_id, entity_id, span_start, span_end)
257257
}
258258

259+
// ── Embeddings ──
260+
261+
/// Store an embedding for an episode. The embedding is serialized as
262+
/// little-endian f32 bytes.
263+
pub fn store_embedding(&self, episode_id: &str, embedding: &[f32]) -> Result<()> {
264+
let bytes: Vec<u8> = embedding
265+
.iter()
266+
.flat_map(|f| f.to_le_bytes())
267+
.collect();
268+
self.storage.store_episode_embedding(episode_id, &bytes)
269+
}
270+
271+
/// Store an embedding for an entity.
272+
pub fn store_entity_embedding(&self, entity_id: &str, embedding: &[f32]) -> Result<()> {
273+
let bytes: Vec<u8> = embedding
274+
.iter()
275+
.flat_map(|f| f.to_le_bytes())
276+
.collect();
277+
self.storage.store_entity_embedding(entity_id, &bytes)
278+
}
279+
280+
/// Load all episode embeddings as (episode_id, Vec<f32>) pairs.
281+
pub fn get_embeddings(&self) -> Result<Vec<(String, Vec<f32>)>> {
282+
let raw = self.storage.get_all_episode_embeddings()?;
283+
let result = raw
284+
.into_iter()
285+
.map(|(id, bytes)| {
286+
let floats: Vec<f32> = bytes
287+
.chunks_exact(4)
288+
.map(|c| f32::from_le_bytes(c.try_into().unwrap()))
289+
.collect();
290+
(id, floats)
291+
})
292+
.collect();
293+
Ok(result)
294+
}
295+
296+
/// Fused search using Reciprocal Rank Fusion (RRF) over FTS5 + semantic results.
297+
///
298+
/// `query_embedding` should be the pre-computed embedding for `query`.
299+
/// Returns episodes ranked by combined RRF score.
300+
pub fn search_fused(
301+
&self,
302+
query: &str,
303+
query_embedding: &[f32],
304+
limit: usize,
305+
) -> Result<Vec<FusedEpisodeResult>> {
306+
const K: f64 = 60.0;
307+
308+
// Accumulate RRF scores per episode id
309+
let mut scores: std::collections::HashMap<String, f64> = std::collections::HashMap::new();
310+
let mut episodes_map: std::collections::HashMap<String, Episode> =
311+
std::collections::HashMap::new();
312+
313+
// --- FTS5 ranked list ---
314+
// Fetch a generous pool for RRF (up to 10x limit or 200)
315+
let fts_pool = (limit * 10).max(200);
316+
let fts_results = self.storage.search_episodes(query, fts_pool);
317+
if let Ok(fts) = fts_results {
318+
for (rank, (episode, _)) in fts.into_iter().enumerate() {
319+
let rrf = 1.0 / (K + rank as f64 + 1.0);
320+
*scores.entry(episode.id.clone()).or_insert(0.0) += rrf;
321+
episodes_map.insert(episode.id.clone(), episode);
322+
}
323+
}
324+
325+
// --- Semantic (cosine similarity) ranked list ---
326+
let all_embeddings = self.get_embeddings()?;
327+
if !all_embeddings.is_empty() && !query_embedding.is_empty() {
328+
// Compute cosine similarities
329+
let mut semantic: Vec<(String, f32)> = all_embeddings
330+
.into_iter()
331+
.map(|(id, vec)| {
332+
let sim = cosine_similarity(query_embedding, &vec);
333+
(id, sim)
334+
})
335+
.collect();
336+
// Sort descending by similarity
337+
semantic.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
338+
339+
for (rank, (ep_id, _sim)) in semantic.into_iter().enumerate() {
340+
let rrf = 1.0 / (K + rank as f64 + 1.0);
341+
*scores.entry(ep_id.clone()).or_insert(0.0) += rrf;
342+
// Fetch episode if not already cached
343+
if !episodes_map.contains_key(&ep_id) {
344+
if let Ok(Some(ep)) = self.storage.get_episode(&ep_id) {
345+
episodes_map.insert(ep_id, ep);
346+
}
347+
}
348+
}
349+
}
350+
351+
// Sort by total RRF score descending
352+
let mut fused: Vec<(String, f64)> = scores.into_iter().collect();
353+
fused.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
354+
355+
let results = fused
356+
.into_iter()
357+
.take(limit)
358+
.filter_map(|(id, score)| {
359+
episodes_map.remove(&id).map(|episode| FusedEpisodeResult { episode, score })
360+
})
361+
.collect();
362+
363+
Ok(results)
364+
}
365+
259366
// ── Search ──
260367

261368
/// Search episodes via FTS5 full-text search.
@@ -319,3 +426,19 @@ impl Graph {
319426
self.storage.stats()
320427
}
321428
}
429+
430+
/// Compute cosine similarity between two f32 vectors.
431+
/// Returns 0.0 if either vector has zero magnitude.
432+
fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
433+
if a.len() != b.len() || a.is_empty() {
434+
return 0.0;
435+
}
436+
let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
437+
let mag_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
438+
let mag_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
439+
if mag_a == 0.0 || mag_b == 0.0 {
440+
0.0
441+
} else {
442+
dot / (mag_a * mag_b)
443+
}
444+
}

crates/ctxgraph-core/src/storage/migrations.rs

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@ use rusqlite::Connection;
22

33
use crate::error::Result;
44

5-
const MIGRATIONS: &[(&str, &str)] = &[(
5+
const MIGRATIONS: &[(&str, &str)] = &[
6+
(
67
"001_initial",
78
r#"
89
-- Episodes: raw events
@@ -144,6 +145,13 @@ const MIGRATIONS: &[(&str, &str)] = &[(
144145
VALUES (new.rowid, new.fact, new.relation);
145146
END;
146147
"#,
148+
),
149+
(
150+
"002_entity_embeddings",
151+
r#"
152+
-- Add embedding column to entities table (episodes already has it from 001)
153+
-- We use a Rust-side check since SQLite ALTER TABLE ADD COLUMN is not idempotent
154+
"#,
147155
)];
148156

149157
pub fn run_migrations(conn: &Connection) -> Result<()> {
@@ -162,7 +170,20 @@ pub fn run_migrations(conn: &Connection) -> Result<()> {
162170
)?;
163171

164172
if !already_applied {
165-
conn.execute_batch(sql)?;
173+
// Migration 002: add embedding column to entities if not present
174+
if *version == "002_entity_embeddings" {
175+
let has_col: bool = {
176+
let mut col_stmt = conn.prepare(
177+
"SELECT COUNT(*) FROM pragma_table_info('entities') WHERE name = 'embedding'",
178+
)?;
179+
col_stmt.query_row([], |row| row.get::<_, i64>(0)).map(|n| n > 0)?
180+
};
181+
if !has_col {
182+
conn.execute_batch("ALTER TABLE entities ADD COLUMN embedding BLOB;")?;
183+
}
184+
} else {
185+
conn.execute_batch(sql)?;
186+
}
166187
conn.execute(
167188
"INSERT INTO _migrations (version, applied_at) VALUES (?1, ?2)",
168189
rusqlite::params![version, chrono::Utc::now().to_rfc3339()],

crates/ctxgraph-core/src/storage/sqlite.rs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,39 @@ impl Storage {
365365
Ok(results)
366366
}
367367

368+
// ── Embeddings ──
369+
370+
/// Store a raw f32 embedding blob for an episode.
371+
pub fn store_episode_embedding(&self, episode_id: &str, data: &[u8]) -> Result<()> {
372+
self.conn.execute(
373+
"UPDATE episodes SET embedding = ?1 WHERE id = ?2",
374+
params![data, episode_id],
375+
)?;
376+
Ok(())
377+
}
378+
379+
/// Store a raw f32 embedding blob for an entity.
380+
pub fn store_entity_embedding(&self, entity_id: &str, data: &[u8]) -> Result<()> {
381+
self.conn.execute(
382+
"UPDATE entities SET embedding = ?1 WHERE id = ?2",
383+
params![data, entity_id],
384+
)?;
385+
Ok(())
386+
}
387+
388+
/// Load all episode embeddings as (id, raw bytes) pairs.
389+
pub fn get_all_episode_embeddings(&self) -> Result<Vec<(String, Vec<u8>)>> {
390+
let mut stmt = self.conn.prepare(
391+
"SELECT id, embedding FROM episodes WHERE embedding IS NOT NULL",
392+
)?;
393+
let rows = stmt
394+
.query_map([], |row| {
395+
Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
396+
})?
397+
.collect::<std::result::Result<Vec<_>, _>>()?;
398+
Ok(rows)
399+
}
400+
368401
// ── Stats ──
369402

370403
pub fn stats(&self) -> Result<GraphStats> {

crates/ctxgraph-core/src/types.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,13 @@ pub struct SearchResult {
159159
pub score: f64,
160160
}
161161

162+
/// Per-episode result from fused (RRF) search.
163+
#[derive(Debug, Clone, Serialize, Deserialize)]
164+
pub struct FusedEpisodeResult {
165+
pub episode: Episode,
166+
pub score: f64,
167+
}
168+
162169
/// Graph-wide statistics.
163170
#[derive(Debug, Clone, Serialize, Deserialize)]
164171
pub struct GraphStats {

0 commit comments

Comments
 (0)