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
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));
}
}
Loading