Skip to content

Commit 7a239d5

Browse files
authored
v1.0.0: Intelligence — candle runtime, orchestrator, reranker, query expansion (#10)
Runtime migration from ONNX to candle (pure Rust ML). Adds LLM-powered search intelligence: research orchestrator with query expansion, cross-encoder reranking as 4th RRF lane, and adaptive lane weights per query intent. Intelligence is opt-in via engraph configure --enable-intelligence. 18 tasks, 271 tests, +3675/-945 lines across 18 files.
1 parent 99ec5d0 commit 7a239d5

18 files changed

Lines changed: 3675 additions & 945 deletions

Cargo.lock

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

Cargo.toml

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "engraph"
3-
version = "0.7.0"
3+
version = "1.0.0"
44
edition = "2024"
55
description = "Local knowledge graph for AI agents. Hybrid search + MCP server for Obsidian vaults."
66
license = "MIT"
@@ -20,12 +20,10 @@ anyhow = "1"
2020
rusqlite = { version = "0.32", features = ["bundled"] }
2121
tracing = "0.1"
2222
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
23-
ort = { version = "2.0.0-rc.12", features = ["ndarray"] }
2423
tokenizers = { version = "0.22", default-features = false, features = ["fancy-regex"] }
2524
sha2 = "0.10"
2625
ureq = "2.12"
2726
indicatif = "0.17"
28-
ndarray = "0.17"
2927
sqlite-vec = "0.1.8-alpha.1"
3028
zerocopy = { version = "0.7", features = ["derive"] }
3129
rayon = "1"
@@ -36,6 +34,14 @@ rmcp = { version = "1.2", features = ["transport-io"] }
3634
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
3735
notify = "7.0"
3836
notify-debouncer-full = "0.4"
37+
candle-core = "0.9"
38+
candle-nn = "0.9"
39+
candle-transformers = "0.9"
40+
41+
[features]
42+
default = []
43+
metal = ["candle-core/metal"]
44+
cuda = ["candle-core/cuda"]
3945

4046
[dev-dependencies]
4147
tempfile = "3"

assets/demo-search.tape

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
Output assets/demo-search.gif
2+
Set Shell zsh
3+
Set FontSize 14
4+
Set Width 1000
5+
Set Height 600
6+
Set Padding 20
7+
Set Theme "Catppuccin Mocha"
8+
Set TypingSpeed 40ms
9+
10+
Type "# Index an Obsidian vault"
11+
Enter
12+
Sleep 500ms
13+
14+
Type "engraph index ~/vault"
15+
Enter
16+
Sleep 3s
17+
18+
Type ""
19+
Enter
20+
Sleep 300ms
21+
22+
Type "# Search across notes — 3-lane hybrid (semantic + keyword + graph)"
23+
Enter
24+
Sleep 500ms
25+
26+
Type "engraph search 'how does authentication work' --explain"
27+
Enter
28+
Sleep 4s
29+
30+
Type ""
31+
Enter
32+
Sleep 300ms
33+
34+
Type "# Get rich context for an AI agent"
35+
Enter
36+
Sleep 500ms
37+
38+
Type "engraph context who 'Steve Barbera'"
39+
Enter
40+
Sleep 3s
41+
42+
Sleep 2s

src/config.rs

Lines changed: 99 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,21 @@
11
use anyhow::{Context, Result};
2-
use serde::Deserialize;
3-
use std::path::PathBuf;
2+
use serde::{Deserialize, Serialize};
3+
use std::path::{Path, PathBuf};
4+
5+
/// Model override configuration.
6+
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
7+
#[serde(default)]
8+
pub struct ModelConfig {
9+
/// Override embedding model URI (e.g., "hf:repo/file.gguf").
10+
pub embed: Option<String>,
11+
/// Override reranker model URI.
12+
pub rerank: Option<String>,
13+
/// Override expansion/orchestrator model URI.
14+
pub expand: Option<String>,
15+
}
416

517
/// Application configuration, loaded from `~/.engraph/config.toml` with CLI overrides.
6-
#[derive(Debug, Clone, Deserialize)]
18+
#[derive(Debug, Clone, Serialize, Deserialize)]
719
#[serde(default)]
820
pub struct Config {
921
/// Path to the Obsidian vault to index.
@@ -14,6 +26,10 @@ pub struct Config {
1426
pub exclude: Vec<String>,
1527
/// Number of files to process per embedding batch.
1628
pub batch_size: usize,
29+
/// Whether intelligence features are enabled. None = not yet configured.
30+
pub intelligence: Option<bool>,
31+
/// Model override URIs.
32+
pub models: ModelConfig,
1733
}
1834

1935
impl Default for Config {
@@ -23,6 +39,8 @@ impl Default for Config {
2339
top_n: 5,
2440
exclude: vec![".obsidian/".to_string()],
2541
batch_size: 64,
42+
intelligence: None,
43+
models: ModelConfig::default(),
2644
}
2745
}
2846
}
@@ -68,6 +86,34 @@ impl Config {
6886
let dir = Self::data_dir()?;
6987
crate::profile::load_vault_toml(&dir)
7088
}
89+
90+
/// Whether intelligence is enabled (defaults to false if not configured).
91+
pub fn intelligence_enabled(&self) -> bool {
92+
self.intelligence.unwrap_or(false)
93+
}
94+
95+
/// Save config to a specific path.
96+
pub fn save_to(&self, path: &Path) -> Result<()> {
97+
let content = toml::to_string_pretty(self).context("serializing config")?;
98+
std::fs::write(path, content).with_context(|| format!("writing {}", path.display()))?;
99+
Ok(())
100+
}
101+
102+
/// Load config from a specific path.
103+
pub fn load_from(path: &Path) -> Result<Self> {
104+
let contents =
105+
std::fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
106+
let config: Config =
107+
toml::from_str(&contents).with_context(|| format!("parsing {}", path.display()))?;
108+
Ok(config)
109+
}
110+
111+
/// Save to the default config path (`~/.engraph/config.toml`).
112+
pub fn save(&self) -> Result<()> {
113+
let path = Self::data_dir()?.join("config.toml");
114+
std::fs::create_dir_all(path.parent().unwrap())?;
115+
self.save_to(&path)
116+
}
71117
}
72118

73119
#[cfg(test)]
@@ -138,4 +184,54 @@ batch_size = 128
138184
let cfg = Config::load().unwrap();
139185
assert_eq!(cfg.batch_size, 64);
140186
}
187+
188+
#[test]
189+
fn parse_intelligence_config() {
190+
let toml_str = r#"
191+
intelligence = true
192+
193+
[models]
194+
embed = "hf:ggml-org/embeddinggemma-300M-GGUF/embeddinggemma-300M-Q8_0.gguf"
195+
rerank = "hf:ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF/qwen3-reranker-0.6b-q8_0.gguf"
196+
"#;
197+
let cfg: Config = toml::from_str(toml_str).unwrap();
198+
assert_eq!(cfg.intelligence, Some(true));
199+
assert!(cfg.models.embed.is_some());
200+
assert!(cfg.models.rerank.is_some());
201+
assert!(cfg.models.expand.is_none());
202+
}
203+
204+
#[test]
205+
fn intelligence_defaults_to_none() {
206+
let cfg = Config::default();
207+
assert!(cfg.intelligence.is_none());
208+
assert!(cfg.models.embed.is_none());
209+
}
210+
211+
#[test]
212+
fn intelligence_false_disables_features() {
213+
let toml_str = r#"intelligence = false"#;
214+
let cfg: Config = toml::from_str(toml_str).unwrap();
215+
assert_eq!(cfg.intelligence, Some(false));
216+
assert!(!cfg.intelligence_enabled());
217+
}
218+
219+
#[test]
220+
fn test_config_roundtrip_with_intelligence() {
221+
let dir = tempfile::tempdir().unwrap();
222+
let config_path = dir.path().join("config.toml");
223+
224+
let mut cfg = Config::default();
225+
cfg.intelligence = Some(true);
226+
cfg.models.embed = Some("hf:custom/model/embed.gguf".into());
227+
228+
cfg.save_to(&config_path).unwrap();
229+
230+
let loaded = Config::load_from(&config_path).unwrap();
231+
assert_eq!(loaded.intelligence, Some(true));
232+
assert_eq!(
233+
loaded.models.embed,
234+
Some("hf:custom/model/embed.gguf".into())
235+
);
236+
}
141237
}

src/context.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -633,7 +633,7 @@ pub fn context_topic_with_search(
633633
params: &ContextParams,
634634
topic: &str,
635635
max_chars: usize,
636-
embedder: &mut crate::embedder::Embedder,
636+
embedder: &mut impl crate::llm::EmbedModel,
637637
) -> Result<ContextBundle> {
638638
let search_output = crate::search::search_internal(topic, 5, params.store, embedder)?;
639639
context_topic_from_results(params, topic, &search_output.results, max_chars)

0 commit comments

Comments
 (0)