Tutorial: Declarative Action RAG
Use @KnowledgeSource to declare a corpus and @Retrieval to ground a specific action. TnsAI loads the corpus lazily, retrieves matching documents before the action executor runs, and adds the formatted context to the request sent by an ActionType.LLM action.
This tutorial describes the shipped Maven Central 0.14.1 runtime.
Sections headed TnsAI 0.14.0 name when those additions landed; they
are in the published 0.14.1 artifacts.
Prerequisites
- TnsAI 0.14.1
tnsai-core,tnsai-llm, andtnsai-intelligenceon the runtime classpath- A directory containing UTF-8 text files
- An LLM provider configured for the example; it reads
OPENAI_API_KEY - Java parameter metadata enabled in your build (
-parameters)
tnsai-core owns the annotations and the optional RetrievalSpi contract.
tnsai-intelligence supplies the service implementation, source loader,
indexes, strategies, and context assembly. Without tnsai-intelligence, action
dispatch continues without retrieval.
A complete action-RAG example
Save the following as ResearchAgentDemo.java. The research/ directory is
resolved from the JVM working directory. Put one or more .md or .txt files
there before running the example.
TnsAI discovers action argument names through Java reflection. Enable parameter
metadata in the consuming Maven project so the invocation key question
matches the method parameter:
<properties>
<maven.compiler.parameters>true</maven.compiler.parameters>
</properties>The equivalent direct compiler flag is javac -parameters.
import com.tnsai.agents.Agent;
import com.tnsai.annotations.ActionSpec;
import com.tnsai.annotations.AgentSpec;
import com.tnsai.annotations.KnowledgeSource;
import com.tnsai.annotations.Retrieval;
import com.tnsai.annotations.RoleSpec;
import com.tnsai.enums.ActionType;
import com.tnsai.llm.LLMClient;
import com.tnsai.llm.providers.OpenAIClient;
import com.tnsai.models.role.RoleIdentity;
import com.tnsai.roles.Role;
import java.util.List;
import java.util.Map;
public final class ResearchAgentDemo {
@RoleSpec(
name = "Researcher",
description = "Answers questions from a local research corpus"
)
@KnowledgeSource(
name = "research-corpus",
path = "research"
)
public static final class ResearchRole extends Role {
@Override
public RoleIdentity getIdentity() {
return new RoleIdentity(
"Researcher",
"Answer questions from trusted documents",
"research"
);
}
@ActionSpec(
type = ActionType.LLM,
description = "Answer with evidence from the research corpus"
)
@Retrieval(
strategy = Retrieval.Strategy.HYBRID,
sources = "research-corpus",
topK = 5,
minScore = 0.0,
contextWindow = 2_000,
contextFormat = "[Source: ${source}]\n${content}\n\n",
onFailure = Retrieval.FallbackAction.FAIL
)
public String answer(String question) {
// ActionType.LLM returns the LLM executor's result. This method
// body is not invoked unless an ActionResult parameter is added.
return question;
}
}
@AgentSpec(description = "Retrieval-grounded research assistant")
public static final class ResearchAgent extends Agent {
private final ResearchRole role = new ResearchRole();
@Override
protected LLMClient getLLM() {
return new OpenAIClient("gpt-4o-mini");
}
@Override
protected List<Role> getRoles() {
return List.of(role);
}
}
public static void main(String[] args) {
Agent agent = new ResearchAgent();
Object answer = agent.executeAction(
"answer",
Map.of("question", "How does retrieval ground this answer?")
);
System.out.println(answer);
}
}@KnowledgeSource.type defaults to FILE, so the example does not need to set
it explicitly. onFailure = FAIL prevents a retrieval transport failure from
silently producing an ungrounded LLM request. An empty corpus or a query with
no matching documents is not a transport failure; applications that require
evidence for every answer should also enforce a positive retrieved-document
count in their execution policy.
How the context reaches the LLM
For the answer dispatch, the runtime follows this path:
- It validates and sanitizes the action parameters.
RetrievalSpiselects the first declaredStringparameter as the query.- It resolves
HYBRIDforresearch-corpus, searches the indexes, fuses the vector and keyword ranks, and applies score and result limits. - It deduplicates results by default, optionally reranks them, and caches the resulting document list by default.
- It renders the documents with
contextFormatinsidecontextWindowand writes the result to the action context. LLMRoleExecutorXML-escapes the rendered block inside the canonical<tnsai-memory>boundary, adds the untrusted-context disclosure to the system prompt, and sends the request to the configured client.
The generated request has this shape:
Answer with evidence from the research corpus
Retrieved context:
<tnsai-memory>
<context>[Source: /absolute/path/research/retrieval.md]
Retrieval runs before the LLM executor.</context>
</tnsai-memory>
Input:
question: How does retrieval ground this answer?The action context carries four reserved keys together after successful retrieval:
| Key | Value |
|---|---|
_rag_context | Rendered, token-bounded context text |
_rag_document_count | Number of documents included in that text |
_rag_stale_fallback | Whether USE_CACHE supplied expired results |
_rag_context_truncated | Whether contextWindow shortened or dropped results |
The LLM executor splices context only when the document count is positive and the rendered text is non-blank. A custom executor can inspect the same keys. The annotated action method itself does not receive the context map.
In TnsAI 0.14.0, the default LLM executor also labels stale-cache
evidence and context-window truncation in the heading. If truncation removes
every matched document, dispatch fails with a validation error before the LLM
is called. Those consumer behaviors ship in Maven Central 0.14.1.
TnsAI 0.14.0: per-dispatch action context
Action dispatch can carry the same request-local isolation and lifecycle data
as chat retrieval. Build a fresh ChatRetrievalContext when a request needs an
explicit tenant/session scope, cancellation token, deadline, run or episode
identity, source revision, or exact document metadata filters, then pass it to
an action overload:
CancellationToken cancellation = CancellationToken.create();
ChatRetrievalContext requestContext = ChatRetrievalContext.builder()
.scope("tenant-a", "conversation-42")
.runId("run-7")
.currentEpisode("episode-3")
.cancellationToken(cancellation)
.timeout(Duration.ofSeconds(3))
.metadataFilters(Map.of("tier", "approved"))
.build();
ActionResponse response = agent.executeAction(
ActionRequest.of(
"answer",
Map.of("question", "How does retrieval ground this answer?")
),
requestContext
);The same context-bearing surface exists for untyped action dispatch, typed and
untyped role action dispatch, tool-streaming, pre-rendered retrieval streaming,
and consumer-based event chat. The session-publisher event overload does not
take an application context. Agent fills missing scope and agent-name values
from the active entry context; DefaultRetrievalSpi carries the context into
the unified engine and adds canonical owner/run/agent/simulation/episode/source
and binding-revision metadata.
The four reserved action keys above remain the compatibility boundary between
retrieval and action executors. They are cleared at the start of each dispatch
before the optional SPI runs, so reusing a caller map cannot leak prior RAG
evidence. LLMRoleExecutor applies the canonical fence and disclosure; custom
executors must treat _rag_context as untrusted data themselves.
ChatKnowledgeBinding is a separate concern: it owns a chat knowledge source
and its invalidation/close lifecycle. ChatRetrievalContext is request-local
and belongs to one chat, stream, event, or action call. Cancellation remains
cooperative, and application retrievers must enforce the absolute deadline in
their own backend I/O when a strict wall-clock bound is required.
Source declarations
@KnowledgeSource describes ingestion, not query-time ranking:
| Field | Default | 0.14.1 behavior |
|---|---|---|
name | required | Exact, case-sensitive source identifier used by @Retrieval.sources |
type | FILE | Selects a SourceLoader; only FILE has a bundled loader |
format | AUTO | Selects AUTO detection or a strict per-file format |
path | "" | File or directory used by the file loader |
connection | "" | Available to an application-provided DATABASE loader |
query | "" | Available to an application-provided DATABASE loader |
enabled | true | Disabled sources are not loaded and cannot be selected |
include | empty | Includes matching FILE paths before ingestion |
exclude | empty | Excludes matching FILE paths before ingestion |
The bundled file loader walks directories recursively, applies include /
exclude, and runs the canonical FILE ingestion service. Small documents
stay intact; larger documents split into stable, format-aware chunks.
URL, DATABASE, and MEMORY are extension points for application-provided
SourceLoader implementations. A loader runs when the Role binding is created
and materializes documents into local indexes. A remote index queried on every
request belongs behind RetrievalEngineProvider, not SourceLoader.
Bindings are cached by Role class and source configuration for the JVM lifetime. When a file corpus changes, invalidate it so the next dispatch re-ingests the source:
RoleRagBinding.invalidate(ResearchRole.class);cacheTTL controls query-result caching against the existing binding; it does
not refresh files.
TnsAI 0.14.0 source additions
TnsAI 0.14.0 adds format, include, and exclude to @KnowledgeSource.
format defaults to AUTO; the canonical FILE ingestion service performs
bounded content detection, applies path selectors, and keeps small documents
intact while splitting larger documents into stable, format-aware chunks.
These fields and chunking behavior ship in Maven Central 0.14.1.
The live @Retrieval surface
The following matrix is checked against the 0.14.1 Retrieval annotation and
RetrievalConfig.from(...). All 21 fields are wired to the runtime model.
| Field | Default | 0.14.1 behavior |
|---|---|---|
strategy | SEMANTIC | Chooses one of the eight live strategy paths |
sources | all | Restricts retrieval to exact source names |
queryParam | "" | Names the action argument used as the query; blank keeps first-String order |
rerank | false | Runs a discovered Reranker after retrieval and deduplication |
rerankerModel | "" | Required provider-owned identifier when reranking is enabled |
contextWindow | 4000 | Token budget for the complete rendered context |
topK | 5 | Maximum results requested from the selected strategy |
topN | 3 | Maximum results retained after reranking |
minScore | 0.5 | Filters results below a score from 0.0 to 1.0 |
deduplicate | true | Removes similar results after fusion and before reranking |
dedupeThreshold | 0.9 | Similarity threshold used when deduplication is enabled |
queryExpansion | NONE | Selects synonym, multi-query, HyDE, or step-back expansion |
queryExpansionModel | "" | Required provider-owned identifier when expansion is enabled |
navigatorModel | "" | Required TreeNavigator identifier when strategy is REASONING |
expandedQueries | 3 | Maximum generated variants in addition to the original query |
includeSpec | true | Controls whether source and score placeholders expose metadata |
contextFormat | source + content | Supports ${content}, ${source}, and ${score} only |
cache | true | Enables the bounded per-Role retrieval-result cache |
cacheTTL | 300 | Fresh cache lifetime in seconds |
maxStaleness | 3600 | Extra stale lifetime available to USE_CACHE; -1 is unbounded |
onFailure | CONTINUE | CONTINUE, FAIL, USE_CACHE, or one keyword RETRY_SIMPLE |
Configuration errors, including unknown source names or missing providers, are
not softened by onFailure. Provider or retrieval failures that occur while
serving a valid configuration follow the selected failure policy.
Strategy requirements
All eight Retrieval.Strategy values are live; none silently falls back to
SEMANTIC:
| Strategy | Runtime path and requirements |
|---|---|
SEMANTIC | Vector similarity over the binding's vector store |
KEYWORD | BM25 keyword retrieval |
HYBRID | Reciprocal-rank fusion of semantic and keyword results |
GRAPH | Uses a bundled FILE-corpus, KnowledgeTools, or Neo4j adapter |
MULTI_QUERY | Expands through a discovered QueryExpander, then retrieves semantically and fuses results |
HIERARCHICAL | Expands vector hits through valid parent metadata supplied by the loader |
TEMPORAL | Applies time decay using ISO-8601 timestamp metadata supplied by the loader |
REASONING | Descends a valid document hierarchy through an installed TreeNavigator serving navigatorModel |
TnsAI does not bundle a reranker, query-expansion model provider, or tree
navigator. Enabling rerank, a non-NONE queryExpansion, MULTI_QUERY, or
REASONING therefore requires an installed provider that serves the configured
model identifier.
Named query and reasoning configuration
queryParam selects a named action argument and requires consumer classes
compiled with Java's -parameters flag. navigatorModel selects the model used
by the installed tree navigator for REASONING; a blank identifier is a
configuration error, while an unmatched identifier is provider unavailable.
Both cases fail before onFailure applies. The two fields and the REASONING
strategy shipped in 0.14.0 and remain available in 0.14.1.
Effective retrieval order
The action-level document pipeline is:
query selection
-> optional query expansion
-> base strategy retrieval and multi-query fusion
-> deduplication
-> optional reranking and topN
-> optional result-cache storage/reuse
-> context formatting and contextWindow bounding
-> LLM prompt splicecontextFormat, includeSpec, and contextWindow affect rendered prompt text,
not which documents are stored in the result cache.
Action RAG and chat RAG are different contracts
@Retrieval grounds a named action dispatched through executeAction. It can
select strategies, sources, scoring, expansion, reranking, caching, formatting,
and failure behavior. Put it on an action method for a per-action policy or on
the Role class as the fallback for actions without a method-level declaration.
A builder-supplied RetrievalConfig has the highest precedence.
@ChatKnowledge instead configures the agent's conversation path. It belongs
on an Agent class, selects one @KnowledgeSource declared on that same class,
and exposes only source, topK, and enabled:
import com.tnsai.agents.Agent;
import com.tnsai.annotations.AgentSpec;
import com.tnsai.annotations.ChatKnowledge;
import com.tnsai.annotations.KnowledgeSource;
@AgentSpec(description = "Support chat")
@KnowledgeSource(name = "support-docs", path = "support")
@ChatKnowledge(source = "support-docs", topK = 5)
public abstract class SupportAgent extends Agent {}@ChatKnowledge resolves the source into the existing chat-level
KnowledgeBase contract. It does not apply the action-level @Retrieval
strategy pipeline. Conversely, @Retrieval does not automatically ground an
ordinary agent.chat(...) turn.
TnsAI 0.14.0: declarative embedding provider
Declarative Role bindings discover one process-wide EmbeddingFunction from
the application classpath. A provider can call a local model or remote
embedding service; this small class only shows the installable contract:
package com.example.tnsai.docs;
import com.tnsai.memory.advanced.EmbeddingFunction;
public final class ApplicationEmbeddingProvider implements EmbeddingFunction {
public ApplicationEmbeddingProvider() {}
@Override
public float[] embed(String text) {
float length = text == null ? 0.0f : text.length();
return new float[] {length, 1.0f};
}
}Register that class in this exact application resource:
# src/main/resources/META-INF/services/com.tnsai.memory.advanced.EmbeddingFunction
com.example.tnsai.docs.ApplicationEmbeddingProviderZero installed providers keep the deterministic 128-dimensional hash fallback. Exactly one provider is cached process-wide and shared by vector-backed declarative strategies and embedding-based deduplication. Multiple providers are a configuration error; the runtime never selects one by classpath order.
Provider calls admit one request at a time with no wait queue. Applications must control concurrency and configure backend-native timeouts. Vectors must be non-null, non-empty, finite, no wider than 16,384 values, and keep one stable dimension. The runtime snapshots provider-owned buffers before returning them. See RAG strategies for the exact failure and validation contract.
The lower-level programmatic strategy API remains separate: callers construct
a VectorMemoryStore with an explicit EmbeddingFunction, then pass that store
to VectorRAGStrategy or HybridRAGStrategy. That store does not use the
declarative ServiceLoader selection.
TnsAI 0.14.0: named retrieval queries
Use @Retrieval(queryParam = "question") or
RetrievalConfig.builder().queryParam("question") when an action has multiple
String parameters. Consumer classes must be compiled with Java's -parameters
flag; otherwise reflection exposes names such as arg0 and named binding is a
configuration error before onFailure applies. Leaving queryParam blank
preserves the first-String positional rule.
Runtime references
@KnowledgeSourceand@Retrieval:tnsai-core/src/main/java/com/tnsai/annotations/RetrievalConfigandRetrievalSpi:tnsai-core/src/main/java/com/tnsai/rag/LLMRoleExecutorprompt splice:tnsai-core/src/main/java/com/tnsai/actions/executors/LLMRoleExecutor.javaDefaultRetrievalSpi,RoleRagBinding, andLocalFileSourceLoader:tnsai-intelligence/src/main/java/com/tnsai/intelligence/rag/binding/- Programmatic strategies:
tnsai-intelligence/src/main/java/com/tnsai/intelligence/rag/
Tutorial: Research Agent
Build an agent that takes a research question, searches the web, reads PDF sources, and produces a cited summary.
Tutorial: Declarative input/output guardrails
Validate, sanitise, and bound every action's parameters and return value through annotations alone — no per-action if (input.length() > N) throw … plumbing in your Role. The framework's InputGuardrailEnforcer and OutputGuardrailEnforcer enforce the contract at dispatch time, before and after the action body runs.