Skip to content

Commit 8459b31

Browse files
authored
Merge pull request #39 from HendrikReh/feat/apex-2gn-agentic-search
feat(rag-server): add routed agentic search foundation
2 parents cd08bef + e52f541 commit 8459b31

36 files changed

Lines changed: 3116 additions & 163 deletions

Justfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ integration-test: up
6565
CARGO_TARGET_DIR={{target_dir_itest}} cargo test -p rag-core --test integration_lifecycle -- --ignored --nocapture
6666
CARGO_TARGET_DIR={{target_dir_itest}} cargo test -p rag-core --test integration_retrieval -- --ignored --nocapture
6767

68+
# Offline benchmark for routed agentic search.
69+
agentic-eval: up
70+
CARGO_TARGET_DIR={{target_dir_itest}} cargo test -p rag-server --test agentic_eval -- --ignored --nocapture --test-threads=1
71+
6872
# Optional provider/native smoke tests.
6973
# These require extra local setup such as PDFium, Tesseract, or live LLM credentials.
7074
smoke-test: up
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Routed search milestone-1 graph:
2+
# - route the query deterministically
3+
# - fall back to a single-pass baseline answer for simple facts
4+
# - otherwise retrieve evidence and compose the answer from retrieved chunks
5+
6+
agent_id: agentic_search_v1
7+
description: >
8+
Routed search agent that deterministically selects between a single-pass
9+
baseline answer and an evidence-first composition branch.
10+
spec_version: "1.0"
11+
12+
required_tools:
13+
- retrieval.dense
14+
- retrieval.sparse
15+
- retrieval.hybrid
16+
- retrieval.fts
17+
- retrieval.expand_chunk_neighbors
18+
- retrieval.fetch_document
19+
20+
tasks:
21+
- route_query
22+
- baseline_answer
23+
- retrieve_evidence
24+
- compose_answer
25+
- final_answer
26+
27+
graph:
28+
start_task: route_query
29+
tasks:
30+
- route_query
31+
- baseline_answer
32+
- retrieve_evidence
33+
- compose_answer
34+
- final_answer
35+
edges:
36+
- { from: route_query, to: retrieve_evidence, condition_key: route_to_agentic_search }
37+
- { from: route_query, to: baseline_answer }
38+
- { from: retrieve_evidence, to: compose_answer }
39+
- { from: baseline_answer, to: final_answer }
40+
- { from: compose_answer, to: final_answer }

crates/agent-core/src/lib.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,22 @@
1212
//! - [`ports`] — Adapter traits (`RetrievalPort`, `ChatPort`, `ApprovalPort`)
1313
//! - [`types`] — Domain value types (`AgentState`, query/result structs)
1414
//! - [`classify`] — Deterministic query classification heuristics
15+
//! - [`route`] — Deterministic routed-search heuristics
1516
//! - [`runtime`] — `AgentRuntime` trait and the graph-flow-backed implementation
1617
1718
pub mod classify;
1819
pub mod ports;
20+
pub mod route;
1921
pub mod runtime;
2022
pub mod spec;
2123
pub mod types;
2224

23-
pub use ports::{ApprovalPort, ChatPort, RetrievalPort};
25+
pub use ports::{ApprovalPort, BaselineAnswerPort, ChatPort, RetrievalPort};
2426
pub use runtime::AgentRuntime;
2527
pub use spec::{
2628
AgentContextProfile, AgentDedupeMode, AgentGuardrailsProfile, AgentPolicies,
2729
AgentReactActionType, AgentReactConfig, AgentReactStopConditions, AgentRegistry,
2830
AgentRetrievalMode, AgentRetrievalProfile, AgentRetrievalStep, AgentSpec, AgentToolFilters,
2931
DefaultToolRegistry, GuardrailActionMode, ToolRegistry, substitute_placeholders,
3032
};
31-
pub use types::{AgentRunResult, AgentState, QueryType};
33+
pub use types::{AgentRunResult, AgentState, QueryType, RouteDecision, RoutePath};

crates/agent-core/src/ports.rs

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,67 @@
44
//! Implementations live outside this crate (typically in `rag-server`), keeping
55
//! agent-core free of concrete storage, LLM, or approval-UI dependencies.
66
7-
use crate::types::{CheckpointDecision, PendingCheckpoint, ScoredChunk};
7+
use crate::types::{CheckpointDecision, GroundedAnswer, PendingCheckpoint, ScoredChunk};
88

99
/// Hybrid retrieval capability.
1010
#[async_trait::async_trait]
1111
pub trait RetrievalPort: Send + Sync {
12-
/// Execute hybrid search (dense + sparse with RRF fusion).
12+
async fn search_dense(
13+
&self,
14+
collection: &str,
15+
query: &str,
16+
tenant: &str,
17+
limit: u64,
18+
) -> anyhow::Result<Vec<ScoredChunk>>;
19+
20+
async fn search_sparse(
21+
&self,
22+
collection: &str,
23+
query: &str,
24+
tenant: &str,
25+
limit: u64,
26+
) -> anyhow::Result<Vec<ScoredChunk>>;
27+
1328
async fn search_hybrid(
1429
&self,
1530
collection: &str,
1631
query: &str,
1732
tenant: &str,
1833
) -> anyhow::Result<Vec<ScoredChunk>>;
34+
35+
async fn search_fts(
36+
&self,
37+
collection: &str,
38+
query: &str,
39+
tenant: &str,
40+
limit: u64,
41+
) -> anyhow::Result<Vec<ScoredChunk>>;
42+
43+
async fn expand_chunk_neighbors(
44+
&self,
45+
tenant: &str,
46+
document_id: &str,
47+
chunk_index: i32,
48+
before: i32,
49+
after: i32,
50+
) -> anyhow::Result<Vec<ScoredChunk>>;
51+
52+
async fn fetch_document(
53+
&self,
54+
tenant: &str,
55+
document_id: &str,
56+
) -> anyhow::Result<serde_json::Value>;
57+
}
58+
59+
#[async_trait::async_trait]
60+
pub trait BaselineAnswerPort: Send + Sync {
61+
async fn answer_single_shot(
62+
&self,
63+
query: &str,
64+
collection: &str,
65+
tenant: &str,
66+
language: Option<&str>,
67+
) -> anyhow::Result<GroundedAnswer>;
1968
}
2069

2170
/// LLM summarization / answer generation capability.

crates/agent-core/src/route.rs

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
use crate::types::{QueryClass, RetrievalProfileId, RouteDecision, RoutePath};
2+
3+
pub fn route_query(query: &str) -> RouteDecision {
4+
let lower = query.to_ascii_lowercase();
5+
6+
let has_compare =
7+
lower.contains("compare ") || lower.contains("tradeoff") || lower.contains("vs ");
8+
let has_how_to =
9+
lower.contains("how do i") || lower.contains("runbook") || lower.contains("steps");
10+
let has_ambiguity = lower.contains("which ") || lower.contains("difference between");
11+
let has_exploratory = lower.contains("find ")
12+
|| lower.contains("show me")
13+
|| lower.contains("search for")
14+
|| lower.contains("overview of")
15+
|| lower.contains("documents about")
16+
|| lower.contains("docs about");
17+
let has_time = lower.contains("today") || lower.contains("latest") || lower.contains("current");
18+
19+
// QueryClass uses first-match precedence. We still retain every matched
20+
// heuristic in `reasons` below so run output shows the full signal set
21+
// that the deterministic router observed.
22+
let (selected_path, query_class, retrieval_profile, needs_multi_hop) = if has_compare {
23+
(
24+
RoutePath::AgenticSearch,
25+
QueryClass::MultiHopResearch,
26+
RetrievalProfileId::BroadThenExpand,
27+
true,
28+
)
29+
} else if has_how_to {
30+
(RoutePath::AgenticSearch, QueryClass::Procedural, RetrievalProfileId::LexicalFirst, false)
31+
} else if has_ambiguity {
32+
(
33+
RoutePath::AgenticSearch,
34+
QueryClass::AmbiguityDisambiguation,
35+
RetrievalProfileId::LexicalFirst,
36+
false,
37+
)
38+
} else if has_exploratory {
39+
(
40+
RoutePath::AgenticSearch,
41+
QueryClass::ExploratorySearch,
42+
RetrievalProfileId::BroadThenExpand,
43+
false,
44+
)
45+
} else {
46+
(RoutePath::SinglePassRag, QueryClass::SimpleFact, RetrievalProfileId::SimpleHybrid, false)
47+
};
48+
49+
let mut reasons = Vec::new();
50+
if has_compare {
51+
reasons.push("compare".to_string());
52+
}
53+
if has_how_to {
54+
reasons.push("procedural".to_string());
55+
}
56+
if has_ambiguity {
57+
reasons.push("ambiguity".to_string());
58+
}
59+
if has_exploratory {
60+
reasons.push("exploratory".to_string());
61+
}
62+
if reasons.is_empty() {
63+
reasons.push("default".to_string());
64+
}
65+
if has_time {
66+
reasons.push("time_sensitive".to_string());
67+
}
68+
69+
RouteDecision {
70+
selected_path,
71+
query_class,
72+
retrieval_profile,
73+
ambiguity: has_ambiguity,
74+
needs_multi_hop,
75+
needs_high_evidence: has_compare || has_how_to || has_exploratory,
76+
time_sensitive: has_time,
77+
normalized_filters: Vec::new(),
78+
reasons,
79+
}
80+
}
81+
82+
#[cfg(test)]
83+
mod tests {
84+
use super::*;
85+
86+
#[test]
87+
fn simple_fact_routes_to_single_pass_rag() {
88+
let decision = route_query("What is Rust?");
89+
assert_eq!(decision.selected_path, RoutePath::SinglePassRag);
90+
assert_eq!(decision.query_class, QueryClass::SimpleFact);
91+
assert_eq!(decision.retrieval_profile, RetrievalProfileId::SimpleHybrid);
92+
}
93+
94+
#[test]
95+
fn multi_hop_query_routes_to_agentic_search() {
96+
let decision = route_query("Compare Rust and Python tradeoffs for async services");
97+
assert_eq!(decision.selected_path, RoutePath::AgenticSearch);
98+
assert_eq!(decision.query_class, QueryClass::MultiHopResearch);
99+
assert_eq!(decision.retrieval_profile, RetrievalProfileId::BroadThenExpand);
100+
assert!(decision.needs_multi_hop);
101+
}
102+
103+
#[test]
104+
fn procedural_query_prefers_lexical_first() {
105+
let decision = route_query("How do I rotate API keys in the auth runbook?");
106+
assert_eq!(decision.selected_path, RoutePath::AgenticSearch);
107+
assert_eq!(decision.query_class, QueryClass::Procedural);
108+
assert_eq!(decision.retrieval_profile, RetrievalProfileId::LexicalFirst);
109+
}
110+
111+
#[test]
112+
fn multi_signal_queries_use_first_match_precedence() {
113+
let decision = route_query("Compare steps to rotate API keys");
114+
assert_eq!(decision.selected_path, RoutePath::AgenticSearch);
115+
assert_eq!(decision.query_class, QueryClass::MultiHopResearch);
116+
assert_eq!(decision.retrieval_profile, RetrievalProfileId::BroadThenExpand);
117+
assert_eq!(decision.reasons, vec!["compare".to_string(), "procedural".to_string()]);
118+
}
119+
120+
#[test]
121+
fn exploratory_query_routes_to_agentic_search() {
122+
let decision = route_query("Find documents about rollback timing for release incidents");
123+
assert_eq!(decision.selected_path, RoutePath::AgenticSearch);
124+
assert_eq!(decision.query_class, QueryClass::ExploratorySearch);
125+
assert_eq!(decision.retrieval_profile, RetrievalProfileId::BroadThenExpand);
126+
}
127+
}

crates/agent-core/src/runtime/graph_flow/keys.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ pub const QUERY: &str = "query";
66
pub const COLLECTION: &str = "collection";
77
pub const TENANT: &str = "tenant";
88
pub const QUERY_TYPE: &str = "query_type";
9+
pub const ROUTE_DECISION: &str = "route_decision";
10+
pub const ROUTE_TO_AGENTIC_SEARCH: &str = "route_to_agentic_search";
911
pub const SEARCH_RESULTS: &str = "search_results";
1012
pub const SUMMARY: &str = "summary";
1113
pub const FINAL_ANSWER: &str = "final_answer";

0 commit comments

Comments
 (0)