RAG
Retrieval-augmented generation (RAG) grounds chat turns or action calls in knowledge the application owns, before the LLM runs. You declare each source once, by name, and choose per source how it reaches the model. Corpus loading, retrieval policy and invocation wiring stay separate, so the entry point and the strategy can be chosen independently.
| To | Read |
|---|---|
| Declare knowledge and decide who sees it | This page |
Build, fill and query a KnowledgeBase in code | KnowledgeBase API |
| Control how files, PDFs and Office documents are read | File formats |
| Pick a retrieval algorithm | Retrieval strategies |
| Install embedding, reranking and other providers | Install retrieval providers |
| Configure embeddings and their prefixes | Embeddings |
| Keep vectors in Qdrant or pgvector | Remote vector stores |
| Retrieve from a knowledge graph | Graph stores |
| Hand an action a source one unit at a time | Ordered consumption |
| Search the files an agent writes during a run | Agent run files |
| Retrieve without vectors, by descending a tree | Vectorless reasoning |
| Split papers by their headings | Smart segmentation |
| Re-embed only the chunks that changed | Selective re-embed |
Index and search through tnsai-server | Pipeline |
| See the configuration RAG resolved | Diagnostics |
RAG spans three modules:
| Layer | Module | Provides |
|---|---|---|
| Primitives | tnsai-core (com.tnsai.knowledge, com.tnsai.memory.advanced, com.tnsai.rag) | KnowledgeBase, Document, EmbeddingFunction, BM25Index, VectorMemoryStore, HybridMemoryRetriever, KnowledgeEntry, KnowledgeSourceConfig |
| Strategies | tnsai-rag (com.tnsai.intelligence.rag) | RAGPipeline, RAGStrategy, RetrievedDocument, RAGContext |
| Service and HTTP | tnsai-server (com.tnsai.server.rag) | RagService, FileIndexer, CodeChunker, HybridRetriever, retrieval streams |
Embedded agents typically use the core primitives plus the strategies.
tnsai-intelligence does not depend on tnsai-rag, so the two are
independent additions. tnsai-server adds HTTP endpoints, indexing and
stream-based retrieval.
Declarative (annotations)
Knowledge is declared in one place and used by name. A @Knowledge entry
in the knowledge member of @AgentSpec or @RoleSpec declares the
source: where it comes from, how it is sliced, and how it reaches the model.
Who sees it follows from where it is declared:
- Chat sees every enabled
SEARCHorDIRECT@AgentSpecentry. Builder entries that the annotation does not declare join the chat scope too. On a class that carries both@RoleSpecand@AgentSpec, its@RoleSpecentries join the chat scope as well. A builder entry with the same name replaces one in place. ASEQUENTIALentry or aGRAPH-type entry reaches only the actions that name it. - An action sees only the entries its
@ActionSpec(knowledge = ...)names. The action's entry carries the name and may set onlyuse,maxTokensandsearch. Setting a source member such aspaththere fails at startup.
Action knowledge is valid on LLM actions, where it 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.
Names are unique within one list: two entries with the same name in one
@AgentSpec or one @RoleSpec fail at startup, and so does a class that
carries both annotations and declares the same name in each. An @AgentSpec
entry whose name matches an entry of one of the agent's roles replaces that
entry for the role's actions.
Each entry reaches the model by its own use:
use | What the model receives |
|---|---|
SEARCH (the default) | Ranked results per request, configured by @Search. |
DIRECT | The whole source, within a shared token budget (maxTokens, or a quarter of the model's input window). A source that does not fit fails and never falls back to SEARCH. |
SEQUENTIAL | The source's next unit on each call (Ordered consumption). |
A non-Role SCOP object that carries @RoleSpec(knowledge = ...) runs the
same action path through SCOPBridge.executeAction.
SCOP chat grounding is
SCOPBridge.sendToLLM(ChatTurn)
with owners and a retrieval context. It reads each owner's @AgentSpec
knowledge, or its @RoleSpec knowledge when the class has no @AgentSpec.
A turn without owners or without a context stays ungrounded.
Do not use sendToLLM as a stand-in for action knowledge.
Named queryParam selection requires consumer classes compiled with Java's
-parameters flag. In Maven, set <maven.compiler.parameters>true</maven.compiler.parameters>.
With javac, pass -parameters. A queryParam declared on a Role or Agent
source applies to every action that names the source, so each of those
actions must expose a parameter of that name. If that contract differs by
action, set search = @Search(queryParam = ...) on the action's own
@Knowledge entry instead. A missing name is a configuration error before
onFailure applies.
package com.example.tnsai.docs;
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.AgentPrincipal;
import com.tnsai.llm.LLMClient;
import com.tnsai.llm.providers.AnthropicClient;
import com.tnsai.models.role.RoleIdentity;
import com.tnsai.roles.Role;
import java.util.List;
public final class RagTopicAnnotationsExample {
private RagTopicAnnotationsExample() {}
@AgentSpec(knowledge = @Knowledge(name = "faq", path = "knowledge/faq"))
public static final class SupportAgent extends Agent {
public SupportAgent(AgentPrincipal principal, AuthorityScope scope) {
super("support-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 AnthropicClient("claude-sonnet-4-20250514");
}
@Override
protected List<Role> getRoles() {
return List.of(Role.create(SupportRole.class));
}
}
@RoleSpec(knowledge = @Knowledge(
name = "product-docs",
path = "knowledge/products",
search = @Search(
strategy = Search.Strategy.HYBRID,
queryParam = "question",
topK = 5)))
public static final class SupportRole extends Role {
@Override
public RoleIdentity getIdentity() {
return new RoleIdentity("support", "Product support", "support");
}
@ActionSpec(
type = ActionType.LLM,
description = "Answer from product documentation",
knowledge = @Knowledge(name = "product-docs"))
public String answer(String question) {
return question;
}
}
}Configuration files and the run file
An agent or role class can name a configuration file with
@AgentSpec(config = @Config(path = ...)) or @RoleSpec(config = ...). The
file's knowledge object is keyed by knowledge name and layers over the
annotation entries:
- A name a lower layer declares is overridden member by member,
searchincluded. What the file leaves out keeps the value of the layer below. An entry that changesusekeeps amaxTokensorsearchfrom below only when the newuseapplies to it. - A name no lower layer declares is a full declaration. It follows the entries below, in name order.
- Every entry is validated as an annotation entry is.
A SCOP run file's Agents section is one more layer, above the
class files. A host passes it with
ClassConfig.useBaseDirectory(ClassConfig.RunFile). Each Agents.<Type>
entry, keyed by the agent type's simple or binary name, takes the shape of an
agent's file: llm, knowledge and roles. Below, the agent declares faq
and its role AnswererRole declares manual. The run file moves faq and
gives manual a direct budget:
{
"Agents": {
"AnswererAgent": {
"knowledge": {"faq": {"path": "knowledge/faq-v2"}},
"roles": {
"AnswererRole": {
"knowledge": {"manual": {"use": "DIRECT", "maxTokens": 4000}}
}
}
}
}
}The layers, lowest first:
| Knowledge of | Layers |
|---|---|
| An agent | @AgentSpec, the agent's own file, Agents.<Type> |
| A role | @RoleSpec, the roles section of its agent's file, the role's own file, Agents.<Type>.roles.<name> |
The run file's section is read once, when the host starts the run, so an
edit applies at the next run. A relative FILE path it gives resolves
against the run file's base directory. Knowledge given to a type with no
@AgentSpec, neither its own nor one it inherits, fails, and so does a key
that gives a role class's binary name: a role takes its run settings under
Agents.<Type>.roles.<name>. A section keyed by a superclass configures
only that class, not its subclasses.
Where a relative FILE path resolves
Every relative FILE path resolves against the base directory
ClassConfig.useBaseDirectory set, when one is set, and against the working
directory otherwise: one written in an annotation, one a @Config entry
keeps without giving path, one built with KnowledgeSourceConfig.file,
and a location passed to RoleRagBinding.forRole. Absolute paths and URLs
are kept as written. The path resolves when the source is read, so a base
directory set later applies from the next run. The loader, the readiness
check, the startup presence check and tnsai-server's FileIndexer all
resolve it the same way, though FileIndexer.index(Path, …) still resolves its
Path against the working directory.
KnowledgePaths.resolve(path) resolves a path as those readers do, and
KnowledgePaths.resolve(path, base) does it against a given base (the
working directory when base is null). Both return an absolute, normalized
path. ClassConfig.baseDirectory() returns the base directory in force, or
empty when there is none. Under a base directory, a @Config entry that
restates the annotation's relative path, or names its absolute path under
the base, names the same source, so a WHEN_CONFIGURED source named that
way is not configured and is skipped with a WARN naming the resolved path.
Inherited @AgentSpec
@AgentSpec is @Inherited, as @RoleSpec is. An
agent class without its own @AgentSpec takes its nearest superclass's
whole, knowledge included, and each concrete agent binds and indexes that
knowledge on its own. Fields are not merged: a class that declares its own
@AgentSpec replaces the superclass's entirely, and one on an interface is
not inherited. An unannotated subclass's getKnowledge() entries layer by
name over the inherited knowledge. See Agent fundamentals
for everything else a subclass takes.
Refresh a corpus
A Role's binding (its documents, indexes and retrieval-result cache) is built once per Role class per JVM and kept for the life of the process. Nothing re-reads your sources on its own, and @Search.cacheTTL doesn't help: an expired cache entry is recomputed against the same index.
When any of the Role's sources may have changed, invalidate the whole binding with com.tnsai.intelligence.rag.binding.RoleRagBinding. The next dispatch re-ingests every source, but reuses the vectors of chunks whose content and embedding provider are unchanged:
RoleRagBinding.invalidate(SupportRole.class);When one enabled source changed, pass its exact, case-sensitive name:
RoleRagBinding.invalidate(SupportRole.class, "product-docs");That rebuilds the bindings that contain product-docs and resets only that source's sequential cursor; other bindings and cursors are kept. A null, blank, disabled or unknown name fails. A retrieval already running finishes on the snapshot it started with.
Programmatic (builder)
The builder takes the same entries as KnowledgeEntry values. The
application composition root also supplies the required principal and
authority scope, and a liability sink if you want records:
package com.example.tnsai.docs;
import com.tnsai.accountability.AuthorityScope;
import com.tnsai.accountability.LiabilitySink;
import com.tnsai.agents.Agent;
import com.tnsai.agents.AgentBuilder;
import com.tnsai.annotations.Knowledge;
import com.tnsai.annotations.Search;
import com.tnsai.identity.AgentPrincipal;
import com.tnsai.llm.LLMClient;
import com.tnsai.rag.KnowledgeEntry;
import com.tnsai.rag.KnowledgeSourceConfig;
import com.tnsai.rag.SearchSettings;
import com.tnsai.roles.Role;
import java.util.Optional;
import java.util.OptionalInt;
public final class RagTopicBuilderExample {
private RagTopicBuilderExample() {}
public static Agent buildActionAgent(
LLMClient llmClient,
Role supportRole,
AgentPrincipal principal,
LiabilitySink liabilitySink,
AuthorityScope authorityScope
) {
return AgentBuilder.create()
.llm(llmClient)
.role(supportRole)
.knowledge(new KnowledgeEntry(
KnowledgeSourceConfig.file(
"product-docs", "knowledge/products"),
Knowledge.Use.SEARCH,
OptionalInt.empty(),
Optional.of(SearchSettings.builder()
.strategy(Search.Strategy.HYBRID)
.queryParam("question")
.topK(5)
.build())))
.actionKnowledge("answer", "product-docs")
.principal(principal)
.liabilitySink(liabilitySink)
.authorityScope(authorityScope)
.build();
}
public static Agent buildCanonicalChatAgent(
LLMClient llmClient,
Role supportRole,
AgentPrincipal principal,
LiabilitySink liabilitySink,
AuthorityScope authorityScope
) {
return AgentBuilder.create()
.llm(llmClient)
.role(supportRole)
.knowledge(KnowledgeEntry.search(
KnowledgeSourceConfig.file("faq", "knowledge/faq")))
.principal(principal)
.liabilitySink(liabilitySink)
.authorityScope(authorityScope)
.build();
}
}knowledge(KnowledgeEntry...) layers entries over the agent's
@AgentSpec knowledge by name. An entry whose name the annotation declares
replaces that entry, and any other entry joins the agent's chat scope. Repeated
calls add to the entries already given. KnowledgeEntry.search(config)
builds a SEARCH entry with the default settings, and the record constructor
takes the use, the DIRECT budget and the SearchSettings explicitly.
actionKnowledge(action, names...) names the knowledge one action uses and
replaces the list its @ActionSpec names. The action and the names are
checked when the agent first initializes, not by build(): an action the
agent does not have fails with the list of its actions.
knowledgeUsageListener(...) receives one KnowledgeUsage row per source
for every chat turn (scope "chat") and every action call that runs
knowledge (scope = the action name). knowledgeScope(...) shares
action-level source snapshots and ordered cursors with other agents and
bridges that use the same host-owned scope.
Three ways to ground chat
What separates them is what describes the source, and when it is resolved:
| path | source described by | resolved |
|---|---|---|
@AgentSpec(knowledge = ...) | annotations | at init. A SEARCH entry needs an installed RetrievalEngineProvider |
AgentBuilder.knowledge(...) | a KnowledgeEntry over a KnowledgeSourceConfig | at init, on the same terms |
a MEMORY entry + setChatKnowledgeBinding(...) | nothing (the application supplies the binding) | never. Chat grounds when the binding arrives |
KnowledgeSourceConfig is a declarative descriptor: path, format,
connection, query. It cannot wrap a live in-memory object, which is the
whole reason the third row exists.
Declaring a runtime corpus as a file or URL source does not leave
chat ungrounded. It makes startup fail. Agent chat knowledge creates its
retrieval engine at startup, and a missing RetrievalEngineProvider or an
unloadable source fails there. The answer for a corpus the application
owns is row three, not a workaround for rows one and two.
Application-supplied live sources
A MEMORY entry declares that chat grounds on a source the application
supplies at runtime. Nothing is loaded or indexed for it and initialization
builds no retrieval engine on its account, so no RetrievalEngineProvider
is required for it. The agent starts ungrounded and stays that way until a
binding arrives. A MEMORY entry is chat-only: declare it on @AgentSpec
or through AgentBuilder.knowledge(...), never on a role or an action. A
chat scope holds at most one MEMORY entry, and DIRECT entries may sit beside
it.
Other SEARCH entries may sit beside it too. Each chat turn then
searches both kinds, the other SEARCH knowledge first, then MEMORY, and
each kind has its own binding:
setChatKnowledgeBindingreplaces only the binding of the kind its sources name, andsetMemoryChatKnowledgeBindingsets theMEMORYone. A binding that searchesMEMORYtogether with other knowledge is refused.- Each binding's
topKis checked against the@Searchof its own kind, and each renders its evidence within its own context window, so a turn that searches both can carry up to twice the evidence of one. getChatKnowledgeBindings()returns the bindings a turn searches, in the order their evidence renders.getChatKnowledgeBinding()returns the otherSEARCHbinding and falls back to theMEMORYone.invalidateChatKnowledge()advances the revision of every binding and returns theMEMORYbinding's, andclearConversationHistory()advances both. To advance one binding alone, call that binding's owninvalidate().getRetrievalConfig(),AgentSpecExtractor.getRetrieval()andRagDiagnostics' type-level declaration give the settings of the otherSEARCHentries, not of theMEMORYentry.KnowledgeDeclarations.withoutMemoryBesideSearch(entries)gives the entries those settings merge over, andChatKnowledgeBindingValidator.searchesMemory(binding, entries)tells whether a binding searches theMEMORYknowledge.
A SCOP chat turn can also search an application source that no @Knowledge
declares, through ChatKnowledgeBinding.undeclared(...). See
Exact owner and isOwnedBy.
ChatKnowledgeBinding.live(ownerId, sourceName, topK, knowledgeBase)
adapts an application-owned corpus. It holds the corpus rather than
copying it, so documents added after binding are visible to retrieval,
and closing the binding does not close the corpus. That stays the
application's to manage, which matters when the same corpus is rebound
on every message. Retrieval is keyword-strategy, fail-open and uncached,
matching what KnowledgeBase.search actually does.
The name is load-bearing, and both halves must agree. The binding
must be owned by that agent, search only the declared entry, and carry that
entry's topK: the chat default of 5 unless the entry's @Search sets
one. Write the two halves together:
package com.example.tnsai.docs;
import com.tnsai.accountability.AuthorityScope;
import com.tnsai.accountability.LiabilitySink;
import com.tnsai.agents.Agent;
import com.tnsai.agents.AgentBuilder;
import com.tnsai.identity.AgentPrincipal;
import com.tnsai.knowledge.KnowledgeBase;
import com.tnsai.llm.LLMClient;
import com.tnsai.rag.ChatKnowledgeBinding;
import com.tnsai.rag.KnowledgeEntry;
import com.tnsai.rag.KnowledgeSourceConfig;
import com.tnsai.roles.Role;
public final class LiveChatKnowledgeExample {
private LiveChatKnowledgeExample() {}
// Both halves must name the same source, and the binding must carry the
// entry's topK (5 unless the entry sets one), or the agent refuses it.
private static final String SOURCE = "session-corpus";
public static Agent buildAgent(
LLMClient llmClient,
Role supportRole,
AgentPrincipal principal,
LiabilitySink liabilitySink,
AuthorityScope authorityScope
) {
return AgentBuilder.create()
.llm(llmClient)
.role(supportRole)
.knowledge(KnowledgeEntry.search(KnowledgeSourceConfig.memory(SOURCE)))
.principal(principal)
.liabilitySink(liabilitySink)
.authorityScope(authorityScope)
.build();
}
public static void groundChat(
Agent agent,
KnowledgeBase applicationOwnedCorpus
) {
agent.setChatKnowledgeBinding(
ChatKnowledgeBinding.live(
agent.getId(), SOURCE, 5, applicationOwnedCorpus));
}
}A refused binding is not quiet. setChatKnowledgeBinding throws
IllegalArgumentException when the owner, the source name or the topK
disagrees with the declaration, and IllegalStateException when the agent
has no enabled SEARCH chat entry at all or has already stopped or
failed. On a stopped or failed agent, a binding that passes its check is
closed before the exception is thrown. A binding set before the agent
initializes is held as a MEMORY or other SEARCH binding by the knowledge
declared when it is given, and checked again at start. If the knowledge at
start makes its source the other kind, the start fails with
IllegalStateException and the binding is closed. A binding given after
start is routed by the knowledge declared at that moment.
A start that fails closes the bindings given before it, and stop() closes
every chat binding even when closing one throws, leaving the agent
STOPPED.
Which one to use
Use chat knowledge when every conversational turn should be grounded in the same sources. It retrieves before the LLM call, fences retrieved content, and adds the matching disclosure to the system prompt.
Use action knowledge when retrieval belongs to specialized actions. It runs during action dispatch, and only for the actions that name the source.
Explicit builder values win over annotations. A builder entry replaces an
annotation entry with the same name. Other named entries remain available,
and actionKnowledge(...) replaces the list an @ActionSpec names.
For a corpus the application owns and keeps mutating, declare a MEMORY
entry and bind a KnowledgeBase to it with ChatKnowledgeBinding.live(...).
Upgrade maps the knowledge APIs that earlier releases
removed.
Field reference
@Knowledge and @Search are never placed on their own: @Knowledge goes in the knowledge member of @AgentSpec,
@RoleSpec or @ActionSpec, and @Search is the search member of a
@Knowledge entry.
Knowledge fields
@Knowledge field | Effect |
|---|---|
name | Required. Unique within the declaring Agent or Role. Actions, the class config file and run-level overrides address the source by it. |
type | Selects the loader. Default FILE. TnsAI bundles loaders for FILE and HTTP/HTTPS URL. DATABASE needs an application-supplied SourceLoader. MEMORY (application-supplied chat corpus) and GRAPH (a graph-store handle for actions) load nothing. |
format | Selects AUTO detection or strict per-file parsing for FILE and URL sources. |
path | Supplies the file, directory or URL. |
connection | Supplies the database connection for a DATABASE source. |
query | Supplies the ingestion query template for a DATABASE source. |
enabled | Includes or skips the source during binding. A disabled source cannot be named by an action. |
select | Keeps only the blocks at and below a structural path, such as $.products for JSON. Strict per document. |
include | Includes matching FILE paths before ingestion. |
exclude | Excludes matching FILE paths before ingestion. |
unit | Selects AUTO, DOCUMENT, LINE, HEADING, or CHUNK slicing at ingest. |
chunkContext | Selects NONE, STRUCTURAL, or LLM context prepended for indexing. LINE rejects non-NONE. |
chunkContextModel | Selects the generator model for chunkContext = LLM. |
use | SEARCH (default), DIRECT or SEQUENTIAL. Settable on an action's entry. |
maxTokens | Budget for a DIRECT source, only valid with use = DIRECT. Settable on an action's entry. |
search | Ranking settings for a SEARCH source. Settable on an action's entry, where it replaces the declared @Search as a whole, so repeat every member to keep. |
use, maxTokens and search are arrays whose empty default means "not
given". Write one value as usual. More than one value fails at startup.
Search fields
Every @Search member is an array whose empty default means "not given".
The default column is the value an unset member takes on an action. On a chat
turn an unset minScore is 0.0, and the other defaults are the same. Several SEARCH
sources in one scope run through one retrieval engine, so their @Search
members merge field by field, and two sources setting the same member to
different values fail at startup. A scope is one class's whole declaration
(the SEARCH entries of its @RoleSpec and @AgentSpec together) and, again,
the entries one action names. A disabled SEARCH entry still takes part in
the merge, so it can cause a conflict. GRAPH entries merge only in a scope
whose SEARCH entries are all GRAPH. Tune a source differently per action on the
action's entry, not on the declaration.
@Search field | Effect | Default |
|---|---|---|
strategy | Selects semantic, keyword, hybrid, graph, multi-query, hierarchical, temporal, or reasoning retrieval. | SEMANTIC |
queryParam | Selects a named action parameter when compiled with -parameters. Unset keeps positional inference. | positional |
rerank | Enables provider-backed reranking. | false |
rerankerModel | Selects the installed reranker provider and model. | "" |
contextWindow | Caps the rendered retrieval context in tokens. | 4000 |
topK | Caps results per source from the selected strategy. | 5 |
topN | Caps results after reranking. | 3 |
minScore | Drops results below the relevance threshold. | 0.5 |
deduplicate | Enables post-fusion duplicate removal. | true |
dedupeThreshold | Sets the duplicate similarity threshold. | 0.9 |
queryExpansion | Selects the query-expansion mode. | NONE |
queryExpansionModel | Selects the installed query-expansion provider and model. | "" |
navigatorModel | Selects the installed TreeNavigator when strategy is REASONING. Required on that path. Blank or unmatched identifiers fail before onFailure. | "" |
expandedQueries | Caps generated query variants. | 3 |
includeSpec | Controls source/score metadata in rendered context. | true |
contextFormat | Defines the validated context template. | [Source: ${source}]\n${content}\n |
cache | Enables the bounded per-Role retrieval cache. | true |
cacheTTL | Sets cache lifetime in seconds. | 300 |
maxStaleness | Caps stale-cache age for USE_CACHE, in seconds past expiry. -1 removes the ceiling. | 3600 |
onFailure | Selects the runtime-failure policy: CONTINUE, FAIL, USE_CACHE or RETRY_SIMPLE. | CONTINUE |
Related
- Retrieval strategies: what each
@Searchstrategy does. - Install retrieval providers: the providers a strategy needs.
- Grounded action tutorial: action knowledge end to end.
- SCOP Bridge: knowledge on SCOP objects and chat turns.
- Upgrade: mapping from the removed knowledge annotations.