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-intelligenceon the runtime classpath (the SPI implementation lives there;tnsai-coreships 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 field | Phase 1 behaviour |
|---|---|
@KnowledgeSource.type | FILE only (URL / VECTOR_DB / DATABASE / WEB_SEARCH deferred) |
@KnowledgeSource.path | Directory or single file; .txt / .md / .markdown / .json / .yaml / .yml / .csv |
@KnowledgeSource.enabled = false | Source skipped |
@KnowledgeSource.name | Used as document-id prefix and in ${source} template |
@KnowledgeSources (@Repeatable container) | All entries ingested in declaration order |
@Retrieval.strategy | SEMANTIC / KEYWORD / HYBRID live; GRAPH / MULTI_QUERY / HIERARCHICAL / TEMPORAL fall back to SEMANTIC + log |
@Retrieval.topK / .minScore | ✓ |
@Retrieval.contextFormat | ${content} / ${source} / ${score} placeholders |
@Retrieval.onFailure | CONTINUE (default — log + skip) and FAIL (rethrow); USE_CACHE / RETRY_SIMPLE degrade to CONTINUE |
| Method-level vs class-level resolution | Method wins; both can be absent |
@Retrieval.rerank / .queryExpansion / .deduplicate / .cache | Phase 2 |
@Retrieval(queryParam = "...") explicit query selection | Phase 2 |
@MemorySpec / @VectorMemory runtime resolution | Phase 2 |
| Embedding model selection | Phase 2 (Phase 1 uses a deterministic-hash function for testability) |
| Chunking | Phase 2 (Phase 1 ingests files whole) |
@ContextCompaction | Phase 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 invariantsThe 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
@KnowledgeSourceannotation —tnsai-core/src/main/java/com/tnsai/annotations/KnowledgeSource.java@KnowledgeSources(@Repeatablecontainer) —tnsai-core/src/main/java/com/tnsai/annotations/KnowledgeSources.java@Retrievalannotation —tnsai-core/src/main/java/com/tnsai/annotations/Retrieval.javaRetrievalSpi—tnsai-core/src/main/java/com/tnsai/rag/RetrievalSpi.javaRoleRagBinding/DefaultRetrievalSpi/LocalFileSourceLoader—tnsai-intelligence/src/main/java/com/tnsai/intelligence/rag/binding/ResearchRolecookbook —tnsai-integration/src/main/java/com/tnsai/integration/scop/examples/ResearchRole.javaResearchRoleRagIntegrationTest—tnsai-integration/src/test/java/com/tnsai/integration/scop/examples/