Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
9 changes: 4 additions & 5 deletions crates/tempyr-cli/src/commands/ask.rs
Original file line number Diff line number Diff line change
@@ -1,23 +1,22 @@
use crate::commands::semantic::SemanticSearchRuntime;
use crate::config::ProjectContext;
use tempyr_core::graph::Graph;
use tempyr_index::hybrid::{RetrievalConfig, hybrid_retrieve};
use tempyr_index::indexer::Index;
use tempyr_index::hybrid::RetrievalConfig;

pub fn run(
ctx: &ProjectContext,
question: &str,
root: Option<&str>,
json: bool,
) -> anyhow::Result<()> {
let index_path = ctx.queryable_index_path()?;
let graph = Graph::load_from_directory(&ctx.graph_dir, ctx.schema.clone())?;
let index = Index::open(&index_path)?;
let config = RetrievalConfig {
token_budget: 8000,
..RetrievalConfig::standard()
};

let results = hybrid_retrieve(&index, &graph, question, root, &config, None)?;
let mut semantic_search = SemanticSearchRuntime::new(ctx)?;
let results = semantic_search.hybrid_retrieve(&graph, question, root, config)?;

if results.is_empty() {
println!("No relevant context found for: {question}");
Expand Down
16 changes: 10 additions & 6 deletions crates/tempyr-cli/src/commands/context.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use crate::commands::semantic::SemanticSearchRuntime;
use crate::config::ProjectContext;
use tempyr_core::graph::Graph;
use tempyr_index::hybrid::{RetrievalConfig, hybrid_retrieve};
use tempyr_index::indexer::Index;
use tempyr_index::hybrid::RetrievalConfig;

pub fn run(
ctx: &ProjectContext,
Expand All @@ -10,15 +10,14 @@ pub fn run(
budget: usize,
json: bool,
) -> anyhow::Result<()> {
let index_path = ctx.queryable_index_path()?;
let graph = Graph::load_from_directory(&ctx.graph_dir, ctx.schema.clone())?;
let index = Index::open(&index_path)?;
let config = RetrievalConfig {
token_budget: budget,
..RetrievalConfig::standard()
};

let results = hybrid_retrieve(&index, &graph, query, root, &config, None)?;
let mut semantic_search = SemanticSearchRuntime::new(ctx)?;
let results = semantic_search.hybrid_retrieve(&graph, query, root, config)?;

if json {
let json_results: Vec<_> = results
Expand All @@ -29,6 +28,7 @@ pub fn run(
"combined_score": r.combined_score,
"structural_score": r.structural_score,
"bm25_score": r.bm25_score,
"vector_score": r.vector_score,
})
})
.collect();
Expand All @@ -50,8 +50,12 @@ pub fn run(
.bm25_score
.map(|s| format!(" bm25={s:.2}"))
.unwrap_or_default();
let vector = result
.vector_score
.map(|s| format!(" vec={s:.2}"))
.unwrap_or_default();
println!(
"{} (score={:.3}{structural}{bm25})",
"{} (score={:.3}{structural}{bm25}{vector})",
result.node_id, result.combined_score
);
}
Expand Down
1 change: 1 addition & 0 deletions crates/tempyr-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ pub(crate) mod process_utils;
pub mod rename;
pub mod render_cmd;
pub mod search;
pub mod semantic;
pub mod status_cmd;
pub mod traverse;
pub mod update;
Expand Down
77 changes: 75 additions & 2 deletions crates/tempyr-cli/src/commands/render_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use std::path::Path;

use chrono::NaiveDate;

use crate::commands::semantic::SemanticSearchRuntime;
use crate::config::ProjectContext;
use tempyr_core::graph::Graph;
use tempyr_core::temporal::TemporalFilter;
Expand All @@ -10,6 +11,61 @@ const BUILTIN_PRD: &str = include_str!("../../../../templates/prd.toml");
const BUILTIN_TDD: &str = include_str!("../../../../templates/tdd.toml");
const BUILTIN_TASK_PROMPT: &str = include_str!("../../../../templates/task-prompt.toml");

struct RenderSemanticSearch<'a> {
ctx: &'a ProjectContext,
graph: &'a Graph,
runtime: Option<SemanticSearchRuntime>,
}

impl<'a> RenderSemanticSearch<'a> {
fn new(ctx: &'a ProjectContext, graph: &'a Graph) -> Self {
Self {
ctx,
graph,
runtime: None,
}
}

fn runtime(&mut self) -> tempyr_render::Result<&mut SemanticSearchRuntime> {
if self.runtime.is_none() {
let runtime = SemanticSearchRuntime::new(self.ctx).map_err(render_error)?;
self.runtime = Some(runtime);
}
Ok(self.runtime.as_mut().expect("semantic runtime initialized"))
}
}

impl tempyr_render::SemanticSearchProvider for RenderSemanticSearch<'_> {
fn search(
&mut self,
request: &tempyr_render::SemanticSearchRequest,
) -> tempyr_render::Result<Vec<tempyr_render::SemanticSearchHit>> {
let graph = self.graph;
let results = self
.runtime()?
.vector_search(
graph,
&request.query,
request.max_results,
request.target_type.as_deref(),
request.min_similarity,
)
.map_err(render_error)?;

Ok(results
.into_iter()
.map(|result| tempyr_render::SemanticSearchHit {
node_id: result.node_id,
score: result.similarity,
})
.collect())
}
}

fn render_error(err: anyhow::Error) -> tempyr_render::RenderError {
tempyr_render::RenderError::General(err.to_string())
}

pub fn run(
ctx: &ProjectContext,
template_name: &str,
Expand All @@ -35,8 +91,17 @@ pub fn run(
.tempyr_dir
.join("render")
.join(format!("{template_name}.toml"));
let mut semantic_search = RenderSemanticSearch::new(ctx, &graph);
let result = if local_path.exists() {
tempyr_render::render(&graph, &local_path, root_id, &temporal_filter)?
tempyr_render::render_with_options(
&graph,
&local_path,
root_id,
&temporal_filter,
tempyr_render::RenderOptions {
semantic_search: Some(&mut semantic_search),
},
)?
} else {
// Use built-in template
let template_toml = match template_name {
Expand All @@ -47,7 +112,15 @@ pub fn run(
"Unknown template: '{template_name}'. Available: prd, tdd, task-prompt (or place a custom template in .tempyr/render/)"
),
};
tempyr_render::render_from_str(&graph, template_toml, root_id, &temporal_filter)?
tempyr_render::render_from_str_with_options(
&graph,
template_toml,
root_id,
&temporal_filter,
tempyr_render::RenderOptions {
semantic_search: Some(&mut semantic_search),
},
)?
};

if let Some(output_path) = output {
Expand Down
62 changes: 62 additions & 0 deletions crates/tempyr-cli/src/commands/semantic.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
use crate::config::ProjectContext;

use tempyr_core::graph::Graph;
use tempyr_index::embeddings::{self, EmbeddingStore};
use tempyr_index::hybrid::{HybridResult, RetrievalConfig};
use tempyr_index::indexer::Index;
use tempyr_index::semantic::SemanticSearchEngine;
use tempyr_index::vector::VectorSearchResult;

/// Runtime state for commands that need semantic search over the graph.
pub struct SemanticSearchRuntime {
engine: SemanticSearchEngine,
runtime: tokio::runtime::Runtime,
}

impl SemanticSearchRuntime {
pub fn new(ctx: &ProjectContext) -> anyhow::Result<Self> {
let index_path = ctx.queryable_index_path()?;
let index = Index::open(&index_path)?;
let resolved = ctx.resolved_embedding_config()?;
let store_path = ctx.embedding_store_path(
&resolved.provider,
resolved.model.as_deref(),
Some(resolved.dimensions),
);
let store = EmbeddingStore::open_or_create(&store_path)?;
let provider = embeddings::create_provider_from_resolved(&resolved)?;
let runtime = tokio::runtime::Runtime::new()?;
let engine = SemanticSearchEngine::new(index, store, provider);

Ok(Self { engine, runtime })
}

pub fn vector_search(
&mut self,
graph: &Graph,
query: &str,
max_results: usize,
node_type: Option<&str>,
min_similarity: Option<f64>,
) -> anyhow::Result<Vec<VectorSearchResult>> {
Ok(self.runtime.block_on(self.engine.vector_search(
graph,
query,
max_results,
node_type,
min_similarity,
))?)
}

pub fn hybrid_retrieve(
&mut self,
graph: &Graph,
query: &str,
root: Option<&str>,
config: RetrievalConfig,
) -> anyhow::Result<Vec<HybridResult>> {
Ok(self
.runtime
.block_on(self.engine.hybrid_retrieve(graph, query, root, config))?)
}
}
72 changes: 5 additions & 67 deletions crates/tempyr-cli/src/commands/vsearch.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,6 @@
use crate::commands::semantic::SemanticSearchRuntime;
use crate::config::ProjectContext;
use tempyr_index::embeddings::{self, EmbeddingStore, InputType};
use tempyr_index::indexer::Index;

fn should_use_legacy_embeddings(
store_embedding_count: usize,
legacy_embedding_count: usize,
) -> bool {
legacy_embedding_count > 0 && legacy_embedding_count > store_embedding_count
}
use tempyr_core::graph::Graph;

pub fn run(
ctx: &ProjectContext,
Expand All @@ -16,44 +9,9 @@ pub fn run(
node_type: Option<&str>,
json: bool,
) -> anyhow::Result<()> {
let index_path = ctx.queryable_index_path()?;
let index = Index::open(&index_path)?;
let resolved = ctx.resolved_embedding_config()?;
let store_path = ctx.embedding_store_path(
&resolved.provider,
resolved.model.as_deref(),
Some(resolved.dimensions),
);
let store = EmbeddingStore::open_or_create(&store_path)?;

// Check if embeddings exist
let store_embedding_count = store.count_embeddings_for_index(&index, node_type)?;
let legacy_embedding_count = index.embedding_count_for_node_type(node_type)?;
let use_legacy_index_embeddings =
should_use_legacy_embeddings(store_embedding_count, legacy_embedding_count);
if store_embedding_count == 0 && legacy_embedding_count == 0 {
anyhow::bail!(
"No embeddings found. Run `tempyr index rebuild` with an embedding \
API key set in Tempyr's shared worktree env, `.env.local`, or \
your shell environment (VOYAGE_API_KEY or GEMINI_API_KEY)."
);
}

// Embed the query
let provider = embeddings::create_provider_from_resolved(&resolved)?;

let rt = tokio::runtime::Runtime::new()?;
let query_embeddings = rt.block_on(provider.embed(&[query.to_string()], InputType::Query))?;

if query_embeddings.is_empty() {
anyhow::bail!("Failed to embed query");
}

let results = if use_legacy_index_embeddings {
index.vector_search(&query_embeddings[0], max_results, node_type)?
} else {
store.vector_search(&index, &query_embeddings[0], max_results, node_type)?
};
let graph = Graph::load_from_directory(&ctx.graph_dir, ctx.schema.clone())?;
let mut semantic_search = SemanticSearchRuntime::new(ctx)?;
let results = semantic_search.vector_search(&graph, query, max_results, node_type, None)?;

if json {
let json_results: Vec<_> = results
Expand All @@ -78,23 +36,3 @@ pub fn run(

Ok(())
}

#[cfg(test)]
mod tests {
use super::should_use_legacy_embeddings;

#[test]
fn prefers_legacy_when_shared_store_is_empty() {
assert!(should_use_legacy_embeddings(0, 3));
}

#[test]
fn prefers_shared_store_when_coverage_is_equal() {
assert!(!should_use_legacy_embeddings(2, 2));
}

#[test]
fn prefers_shared_store_when_it_has_more_coverage() {
assert!(!should_use_legacy_embeddings(3, 2));
}
}
2 changes: 1 addition & 1 deletion crates/tempyr-index/src/health.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,7 @@ pub fn build_report(inputs: &HealthInputs<'_>) -> HealthReport {
}
if matches!(embedding.store_exists, Some(false)) && embedding.api_key_set != Some(false) {
warnings.push(format!(
"Embedding store does not exist at {}. Run `tempyr index rebuild` to populate embeddings.",
"Embedding store does not exist at {}. Run a vector-backed command or `tempyr index rebuild` to populate embeddings.",
embedding.store_path.as_ref().map(|p| p.display().to_string()).unwrap_or_default()
));
}
Expand Down
2 changes: 1 addition & 1 deletion crates/tempyr-index/src/hybrid.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ type ScoreMap = std::collections::HashMap<String, ScoreTriplet>;
/// Run the full hybrid retrieval pipeline.
///
/// Combines structural traversal (if root_id provided), BM25 full-text search,
/// and vector similarity (deferred — always None for now).
/// and vector similarity when a query embedding is provided.
pub fn hybrid_retrieve(
index: &Index,
graph: &Graph,
Expand Down
1 change: 1 addition & 0 deletions crates/tempyr-index/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ pub mod hybrid;
pub mod incremental;
pub mod indexer;
pub mod refresh;
pub mod semantic;
pub mod vector;

use thiserror::Error;
Expand Down
Loading