TnsAI
Tutorials

Tutorial: Declarative RAG with @KnowledgeSource and @Retrieval

Wire knowledge sources to a Role and retrieve from them on every action call — through annotations alone. No manual Embedding embed = ...; vectorStore.search(query, k) plumbing in your role; the framework binds and dispatches.

Prerequisites

  • Installation
  • A directory of text files (.txt, .md, .json, .yaml, .csv) you want the role to ground its answers in
  • tnsai-intelligence on the runtime classpath (the SPI implementation lives there; tnsai-core ships only the SPI interface)

The role

Full source lives at tnsai-integration/src/main/java/com/tnsai/integration/scop/examples/ResearchRole.java and is exercised by ResearchRoleRagIntegrationTest. The shape:

@RoleSpec(
    name = "ResearchAgent",
    description = "Answers questions and summarises with citations from a local research corpus.",
    responsibilities = {
        @Responsibility(name = "QA", actions = {"answer"}),
        @Responsibility(name = "Summarisation", actions = {"summarise"})
    }
)
@KnowledgeSources({
    @KnowledgeSource(
        name = "research-corpus",
        type = KnowledgeSource.KnowledgeType.FILE,
        path = "target/_research-corpus/papers"),
    @KnowledgeSource(
        name = "research-faq",
        type = KnowledgeSource.KnowledgeType.FILE,
        path = "target/_research-corpus/faq")
})
public class ResearchRole extends Role {
    // ... actions below
}

Pattern 1 — Class-level @KnowledgeSource(s)

Declare every corpus the role should ingest at the class level. Phase 1 ships one source loader (KnowledgeType.FILE); each declaration is read once at first action invocation and the resulting documents are cached for the lifetime of the JVM keyed by Role class.

@KnowledgeSources({
    @KnowledgeSource(name = "research-corpus", type = FILE, path = "papers/"),
    @KnowledgeSource(name = "research-faq",    type = FILE, path = "faq/")
})

Each path may point to a single file or a directory; for a directory, every text file under it (recursively) is ingested as one document. Binary formats need their own loader (deferred — .pdf / .docx will get loaders in Phase 2).

Pattern 2 — Method-level @Retrieval per action

Pick the strategy + tuning knobs that suit each action. The dispatcher reads the annotation, runs the strategy against the role's binding, and injects the formatted context into the action's context map under RetrievalSpi.RETRIEVED_CONTEXT_KEY ("_rag_context") before the action body runs.

@ActionSpec(
    type = ActionType.LOCAL,
    description = "Answer a research question with citations from the corpus."
)
@Retrieval(
    strategy = Retrieval.Strategy.HYBRID,
    topK = 5,
    minScore = 0.0,
    contextFormat = "[Source: ${source}, score=${score}]\n${content}\n\n",
    onFailure = Retrieval.FallbackAction.CONTINUE
)
public String answer(@Param(name = "question") String question) {
    // The framework injects the formatted context under
    // _rag_context BEFORE this body runs. A real impl would read it
    // via the agent's context map.
    return "Answering: " + question;
}

The query for retrieval is the action's first String-typed parameter (Phase 1 convention; Phase 2 will add @Retrieval(queryParam = "...") for explicit selection).

Pattern 3 — Mixing strategies in one role

Different actions justify different retrieval shapes. answer benefits from HYBRID for proper-noun precision + paraphrase recall; summarise is a topic-descriptor lookup where SEMANTIC alone gives tighter focus.

@ActionSpec(type = ActionType.LOCAL, description = "Summarise corpus segments for a topic.")
@Retrieval(
    strategy = Retrieval.Strategy.SEMANTIC,
    topK = 3,
    contextFormat = "<<< ${source}\n${content}\n>>>\n",
    onFailure = Retrieval.FallbackAction.CONTINUE
)
public String summarise(@Param(name = "topic") String topic) {
    return "Summarising: " + topic;
}

Reading the injected context

Inside the action body (or in a downstream LLM executor), pull the formatted context from the framework's context map:

Map<String, Object> context = ...; // ActionExecutor passes this in
String retrieved = (String) context.get(RetrievalSpi.RETRIEVED_CONTEXT_KEY);
Integer count   = (Integer) context.get(RetrievalSpi.RETRIEVED_DOCUMENT_COUNT_KEY);

if (count != null && count > 0) {
    promptBuilder.append("Reference material:\n").append(retrieved).append("\n\n");
}

The two-key pattern (formatted text + document count) lets callers distinguish "retrieval ran but found nothing" (count == 0) from "retrieval was skipped" (key absent).

What's enforced today

Annotation fieldPhase 1 behaviour
@KnowledgeSource.typeFILE only (URL / VECTOR_DB / DATABASE / WEB_SEARCH deferred)
@KnowledgeSource.pathDirectory or single file; .txt / .md / .markdown / .json / .yaml / .yml / .csv
@KnowledgeSource.enabled = falseSource skipped
@KnowledgeSource.nameUsed as document-id prefix and in ${source} template
@KnowledgeSources (@Repeatable container)All entries ingested in declaration order
@Retrieval.strategySEMANTIC / KEYWORD / HYBRID live; GRAPH / MULTI_QUERY / HIERARCHICAL / TEMPORAL fall back to SEMANTIC + log
@Retrieval.topK / .minScore
@Retrieval.contextFormat${content} / ${source} / ${score} placeholders
@Retrieval.onFailureCONTINUE (default — log + skip) and FAIL (rethrow); USE_CACHE / RETRY_SIMPLE degrade to CONTINUE
Method-level vs class-level resolutionMethod wins; both can be absent
@Retrieval.rerank / .queryExpansion / .deduplicate / .cachePhase 2
@Retrieval(queryParam = "...") explicit query selectionPhase 2
@MemorySpec / @VectorMemory runtime resolutionPhase 2
Embedding model selectionPhase 2 (Phase 1 uses a deterministic-hash function for testability)
ChunkingPhase 2 (Phase 1 ingests files whole)
@ContextCompactionPhase 2

Where it sits in the pipeline

Inside ActionExecutor.executeInternal:

1. Approval check
2. Security access control + parameter encryption
3. @BeforeAction transformation
4. ActionValidator.validateParameters
5. ActionContract.validateInvariants + preconditions
6. Invariant precondition checks
7. @InputGuardrail enforcement (if tnsai-core has #171's Phase 1 wired)
8. ► RetrievalSpi.find().ifPresent(spi -> spi.enforce(...))  ← @Retrieval here
9. Action body execution (LOCAL / WEB_SERVICE / LLM / MCP_TOOL)
10. @OutputGuardrail enforcement
11. Postcondition + post-execution invariants

The RetrievalSpi.find() lookup is cached in an AtomicReference so the discovery cost is paid at most once per JVM. If tnsai-intelligence isn't on the classpath, the optional returns empty and the pipeline silently skips — so a consumer that doesn't want declarative RAG simply doesn't depend on intelligence.

Wiring the role into an Agent

Agent agent = AgentBuilder.create()
    .role(new ResearchRole())
    .llm(new OpenAIClient("gpt-4o-mini"))
    .build();

Object answer = agent.executeAction(
    "answer",
    Map.of("question", "What is RAG?"));

No corpus pre-load call needed — RoleRagBinding.forRole(ResearchRole.class) runs lazily on the first action invocation that triggers retrieval.

Reference

  • @KnowledgeSource annotation — tnsai-core/src/main/java/com/tnsai/annotations/KnowledgeSource.java
  • @KnowledgeSources (@Repeatable container) — tnsai-core/src/main/java/com/tnsai/annotations/KnowledgeSources.java
  • @Retrieval annotation — tnsai-core/src/main/java/com/tnsai/annotations/Retrieval.java
  • RetrievalSpitnsai-core/src/main/java/com/tnsai/rag/RetrievalSpi.java
  • RoleRagBinding / DefaultRetrievalSpi / LocalFileSourceLoadertnsai-intelligence/src/main/java/com/tnsai/intelligence/rag/binding/
  • ResearchRole cookbook — tnsai-integration/src/main/java/com/tnsai/integration/scop/examples/ResearchRole.java
  • ResearchRoleRagIntegrationTesttnsai-integration/src/test/java/com/tnsai/integration/scop/examples/

On this page