Skip to content
tnsaiDocs
GitHub

Tutorial: Declarative Action RAG

Declare a corpus with @Knowledge on the Role, then name it from the action that should be grounded. 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.

The dependency, module, and behavior information on this page targets TnsAI 0.18.6. Sections headed TnsAI 0.14.0 name when those additions landed, and they remain in the current artifacts.

Prerequisites

  • TnsAI 0.18.6
  • tnsai-core, tnsai-llm, and tnsai-rag on the runtime classpath
  • A directory containing UTF-8 text files
  • An LLM provider configured for the example, which reads OPENAI_API_KEY
  • Java parameter metadata enabled in your build (-parameters)

tnsai-core owns the annotations and the optional RetrievalSpi contract. tnsai-rag supplies the service implementation, source loader, indexes, strategies, and context assembly. Without tnsai-rag on the classpath, 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.accountability.AuthorityScope;
import com.tnsai.actions.ActionExecutor;
import com.tnsai.agents.Agent;
import com.tnsai.annotations.ActionSpec;
import com.tnsai.annotations.AgentSpec;
import com.tnsai.annotations.Knowledge;
import com.tnsai.annotations.RoleSpec;
import com.tnsai.annotations.Search;
import com.tnsai.enums.ActionType;
import com.tnsai.identity.AgentDescriptor;
import com.tnsai.identity.AgentPrincipal;
import com.tnsai.identity.LocalIdentityProvider;
import com.tnsai.llm.LLMClient;
import com.tnsai.llm.providers.OpenAIClient;
import com.tnsai.models.role.RoleIdentity;
import com.tnsai.roles.Role;

import java.time.Duration;
import java.util.List;
import java.util.Map;

public final class ResearchAgentDemo {

    @RoleSpec(
        name = "Researcher",
        description = "Answers questions from a local research corpus",
        knowledge = @Knowledge(
            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",
            knowledge = @Knowledge(
                name = "research-corpus",
                search = @Search(
                    strategy = Search.Strategy.HYBRID,
                    topK = 5,
                    minScore = 0.0,
                    contextWindow = 2_000,
                    contextFormat = "[Source: ${source}]\n${content}\n\n",
                    onFailure = Search.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();

        public ResearchAgent(AgentPrincipal principal, AuthorityScope scope) {
            super("research-agent", null, null, wired(principal, scope));
        }

        private static ActionExecutor wired(AgentPrincipal principal, AuthorityScope scope) {
            ActionExecutor executor = new ActionExecutor();
            executor.setPrincipal(principal);
            executor.setDefaultScope(scope);
            return executor;
        }

        @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) {
        AgentPrincipal principal = new LocalIdentityProvider().issue(
            AgentDescriptor.builder()
                .agentClass(ResearchAgent.class.getName())
                .systemPrompt("Answer questions from a local research corpus.")
                .toolNames(List.of("answer"))
                .model("openai:gpt-4o-mini")
                .build());
        Agent agent = new ResearchAgent(
            principal, AuthorityScope.unrestricted(Duration.ofHours(1)));
        Object answer = agent.executeAction(
            "answer",
            Map.of("question", "How does retrieval ground this answer?")
        );
        System.out.println(answer);
    }
}

@Knowledge.type defaults to FILE and use defaults to SEARCH, so the example sets neither. The Role declares the source once. The action names it and sets only its @Search settings. 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.

Source loading failures and empty results (0.17.3)

The bundled FILE and HTTP/HTTPS loaders distinguish an unsuccessful load from a source that was read successfully but produced no documents. A declared FILE source that is missing or unreadable, or an HTTP request that fails, raises RetrievalException with reason SOURCE_LOADING. A successfully read empty source and a successful retrieval with no matching documents are valid empty results, and they do not trigger onFailure.

For a source-loading failure, onFailure = FAIL propagates the typed failure. onFailure = CONTINUE skips retrieval context and records a retrieved-document count of zero, allowing the action to continue without retrieved evidence. Choose CONTINUE only when that ungrounded continuation is acceptable to your application. Invalid source configuration, including an invalid format, remains a CONFIGURATION failure above the onFailure policy, and CONTINUE does not hide it. Cancellation also continues to propagate.

How the context reaches the LLM

For the answer dispatch, the runtime follows this path:

  1. It validates and sanitizes the action parameters.
  2. RetrievalSpi selects the first declared String parameter as the query.
  3. It resolves HYBRID for research-corpus, searches the indexes, fuses the vector and keyword ranks, and applies score and result limits.
  4. It deduplicates results by default, optionally reranks them, and caches the resulting document list by default.
  5. It renders the documents with contextFormat inside contextWindow and writes the result to the action context.
  6. LLMRoleExecutor XML-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 five reserved keys together after retrieval:

KeyValue
_rag_contextRendered, token-bounded context text
_rag_document_countNumber of documents included in that text
_rag_stale_fallbackWhether USE_CACHE supplied expired results
_rag_context_truncatedWhether contextWindow shortened or dropped results
_rag_knowledge_usageWhat each named source gave the action, one KnowledgeUsage row per enabled source

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.

The default LLM executor 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.

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.

From 0.18.6 the context also carries a turn's raw message, its conversation and its source locations as components, set with the builder's turnMessage(String), conversationId(String) and sourcePaths(Map) and empty when left out. ChatKnowledgeBinding.retrieve copies the three into the RetrievalRequest, searches the turn message instead of the rendered message when there is one, and reads the chat's DIRECT sources from the locations sourcePaths names. A cached result is kept apart per turn message, conversation and location. Code that calls the canonical constructor of ChatRetrievalContext or RetrievalRequest, or matches either with a record pattern, has to add the three components.

The 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

The source members of @Knowledge describe ingestion, not query-time ranking. They are set where the source is declared, on @RoleSpec or @AgentSpec, and an action's entry names the source and may set only use, maxTokens, and search.

MemberDefaultBehavior
namerequiredExact, case-sensitive source identifier that actions name
typeFILEFILE and URL have bundled loaders, and DATABASE needs an application SourceLoader
formatAUTOSelects AUTO detection or a strict per-file format
path""File, directory, or URL read by the loader
connection""Available to an application-provided DATABASE loader
query""Available to an application-provided DATABASE loader
enabledtrueDisabled sources are not loaded and cannot be named
includeemptyIncludes matching FILE paths before ingestion
excludeemptyExcludes matching FILE paths before ingestion

The remaining members (select, unit, chunkContext, chunkContextModel, use, maxTokens) are listed in the RAG field reference. MEMORY is an application-supplied chat corpus and GRAPH is a graph-store handle for actions, and neither is a loaded file corpus.

The bundled file loader walks directories recursively, applies include / exclude, and runs the canonical FILE ingestion service. Small documents stay intact, and larger documents split into stable, format-aware chunks.

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 and does not refresh files.

The @Search surface

@Search is the search member of a SEARCH entry. Every member is an array whose empty default means "not given". Write a single value as usual (topK = 8), and more than one value fails at startup. An action's own @Knowledge(name = ..., search = @Search(...)) replaces the declared @Search as a whole, so repeat every declared member the action should keep. Several SEARCH sources in one scope run through one retrieval engine, so their members merge field by field: a member set by one source applies to all of them, and two sources setting it to different values fail at startup. A scope is a class's whole declaration (all SEARCH entries of its @RoleSpec and @AgentSpec together) and, again, the entries one action names after its overrides. Sources that need different values belong on the actions' entries, not on the declaration.

MemberUnset meansBehavior
strategySEMANTICChooses one of the eight live strategy paths
queryParampositionalNames the action argument used as the query, and unset keeps first-String order
rerankfalseRuns a discovered Reranker after retrieval and deduplication
rerankerModelnoneRequired provider-owned identifier when reranking is enabled
contextWindow4000Token budget for the complete rendered context
topK5Maximum results requested from the selected strategy
topN3Maximum results retained after reranking
minScore0.5Filters results below a score from 0.0 to 1.0
deduplicatetrueRemoves similar results after fusion and before reranking
dedupeThreshold0.9Similarity threshold used when deduplication is enabled
queryExpansionNONESelects synonym, multi-query, HyDE, or step-back expansion
queryExpansionModelnoneRequired provider-owned identifier when expansion is enabled
navigatorModelnoneRequired TreeNavigator identifier when strategy is REASONING
expandedQueries3Maximum generated variants in addition to the original query
includeSpectrueControls whether source and score placeholders expose metadata
contextFormatsource + contentSupports ${content}, ${source}, and ${score} only
cachetrueEnables the bounded per-Role retrieval-result cache
cacheTTL300Fresh cache lifetime in seconds
maxStaleness3600Extra stale lifetime available to USE_CACHE, and -1 is unbounded
onFailureCONTINUECONTINUE, 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 Search.Strategy values are live, and none silently falls back to SEMANTIC:

StrategyRuntime path and requirements
SEMANTICVector similarity over the binding's vector store
KEYWORDBM25 keyword retrieval
HYBRIDReciprocal-rank fusion of semantic and keyword results
GRAPHUses a bundled FILE-corpus, KnowledgeTools, or Neo4j adapter
MULTI_QUERYExpands through a discovered QueryExpander, then retrieves semantically and fuses results
HIERARCHICALExpands vector hits through valid parent metadata supplied by the loader
TEMPORALApplies time decay using ISO-8601 timestamp metadata supplied by the loader
REASONINGDescends 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.

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 splice

contextFormat, includeSpec, and contextWindow affect rendered prompt text, not which documents are stored in the result cache.

Action RAG and chat RAG are different contracts

Action knowledge grounds a named action dispatched through executeAction. The action sees only the entries its @ActionSpec(knowledge = ...) names, with their @Search settings. It is valid on LLM actions, where the context is spliced into the prompt, and on LOCAL actions, where it is pulled before the body runs. Any other action type that names knowledge fails at startup.

Chat knowledge is declared on the Agent class instead. Every enabled SEARCH or DIRECT @AgentSpec entry grounds the agent's conversation path, and chat needs no action to name it. SEQUENTIAL and GRAPH entries reach only the actions that name them:

import com.tnsai.agents.Agent;
import com.tnsai.annotations.AgentSpec;
import com.tnsai.annotations.Knowledge;
import com.tnsai.annotations.Search;

@AgentSpec(
    description = "Support chat",
    knowledge = @Knowledge(name = "support-docs", path = "support",
                           search = @Search(topK = 5)))
public abstract class SupportAgent extends Agent {}

A Role's entries do not ground an ordinary agent.chat(...) turn. Chat sees only the @AgentSpec entries. See RAG for the chat paths.

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, and 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.ApplicationEmbeddingProvider

Zero 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, and 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 @Search(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 unset preserves the first-String positional rule. A queryParam set where the source is declared applies to every action that names that source.

Runtime references

  • @Knowledge, @Search, and @ActionSpec: tnsai-core/src/main/java/com/tnsai/annotations/
  • RetrievalConfig and RetrievalSpi: tnsai-core/src/main/java/com/tnsai/rag/
  • LLMRoleExecutor prompt splice: tnsai-core/src/main/java/com/tnsai/actions/executors/LLMRoleExecutor.java
  • DefaultRetrievalSpi, RoleRagBinding, and LocalFileSourceLoader: tnsai-rag/src/main/java/com/tnsai/intelligence/rag/binding/
  • Programmatic strategies: tnsai-rag/src/main/java/com/tnsai/intelligence/rag/