Skip to content
tnsaiDocs
GitHub

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.

ToRead
Declare knowledge and decide who sees itThis page
Build, fill and query a KnowledgeBase in codeKnowledgeBase API
Control how files, PDFs and Office documents are readFile formats
Pick a retrieval algorithmRetrieval strategies
Install embedding, reranking and other providersInstall retrieval providers
Configure embeddings and their prefixesEmbeddings
Keep vectors in Qdrant or pgvectorRemote vector stores
Retrieve from a knowledge graphGraph stores
Hand an action a source one unit at a timeOrdered consumption
Search the files an agent writes during a runAgent run files
Retrieve without vectors, by descending a treeVectorless reasoning
Split papers by their headingsSmart segmentation
Re-embed only the chunks that changedSelective re-embed
Index and search through tnsai-serverPipeline
See the configuration RAG resolvedDiagnostics

RAG spans three modules:

LayerModuleProvides
Primitivestnsai-core (com.tnsai.knowledge, com.tnsai.memory.advanced, com.tnsai.rag)KnowledgeBase, Document, EmbeddingFunction, BM25Index, VectorMemoryStore, HybridMemoryRetriever, KnowledgeEntry, KnowledgeSourceConfig
Strategiestnsai-rag (com.tnsai.intelligence.rag)RAGPipeline, RAGStrategy, RetrievedDocument, RAGContext
Service and HTTPtnsai-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 SEARCH or DIRECT @AgentSpec entry. Builder entries that the annotation does not declare join the chat scope too. On a class that carries both @RoleSpec and @AgentSpec, its @RoleSpec entries join the chat scope as well. A builder entry with the same name replaces one in place. A SEQUENTIAL entry or a GRAPH-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 only use, maxTokens and search. Setting a source member such as path there 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:

useWhat the model receives
SEARCH (the default)Ranked results per request, configured by @Search.
DIRECTThe 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.
SEQUENTIALThe 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, search included. What the file leaves out keeps the value of the layer below. An entry that changes use keeps a maxTokens or search from below only when the new use applies 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 ofLayers
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:

pathsource described byresolved
@AgentSpec(knowledge = ...)annotationsat init. A SEARCH entry needs an installed RetrievalEngineProvider
AgentBuilder.knowledge(...)a KnowledgeEntry over a KnowledgeSourceConfigat 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:

  • setChatKnowledgeBinding replaces only the binding of the kind its sources name, and setMemoryChatKnowledgeBinding sets the MEMORY one. A binding that searches MEMORY together with other knowledge is refused.
  • Each binding's topK is checked against the @Search of 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 other SEARCH binding and falls back to the MEMORY one.
  • invalidateChatKnowledge() advances the revision of every binding and returns the MEMORY binding's, and clearConversationHistory() advances both. To advance one binding alone, call that binding's own invalidate().
  • getRetrievalConfig(), AgentSpecExtractor.getRetrieval() and RagDiagnostics' type-level declaration give the settings of the other SEARCH entries, not of the MEMORY entry. KnowledgeDeclarations.withoutMemoryBesideSearch(entries) gives the entries those settings merge over, and ChatKnowledgeBindingValidator.searchesMemory(binding, entries) tells whether a binding searches the MEMORY knowledge.

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 fieldEffect
nameRequired. Unique within the declaring Agent or Role. Actions, the class config file and run-level overrides address the source by it.
typeSelects 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.
formatSelects AUTO detection or strict per-file parsing for FILE and URL sources.
pathSupplies the file, directory or URL.
connectionSupplies the database connection for a DATABASE source.
querySupplies the ingestion query template for a DATABASE source.
enabledIncludes or skips the source during binding. A disabled source cannot be named by an action.
selectKeeps only the blocks at and below a structural path, such as $.products for JSON. Strict per document.
includeIncludes matching FILE paths before ingestion.
excludeExcludes matching FILE paths before ingestion.
unitSelects AUTO, DOCUMENT, LINE, HEADING, or CHUNK slicing at ingest.
chunkContextSelects NONE, STRUCTURAL, or LLM context prepended for indexing. LINE rejects non-NONE.
chunkContextModelSelects the generator model for chunkContext = LLM.
useSEARCH (default), DIRECT or SEQUENTIAL. Settable on an action's entry.
maxTokensBudget for a DIRECT source, only valid with use = DIRECT. Settable on an action's entry.
searchRanking 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 fieldEffectDefault
strategySelects semantic, keyword, hybrid, graph, multi-query, hierarchical, temporal, or reasoning retrieval.SEMANTIC
queryParamSelects a named action parameter when compiled with -parameters. Unset keeps positional inference.positional
rerankEnables provider-backed reranking.false
rerankerModelSelects the installed reranker provider and model.""
contextWindowCaps the rendered retrieval context in tokens.4000
topKCaps results per source from the selected strategy.5
topNCaps results after reranking.3
minScoreDrops results below the relevance threshold.0.5
deduplicateEnables post-fusion duplicate removal.true
dedupeThresholdSets the duplicate similarity threshold.0.9
queryExpansionSelects the query-expansion mode.NONE
queryExpansionModelSelects the installed query-expansion provider and model.""
navigatorModelSelects the installed TreeNavigator when strategy is REASONING. Required on that path. Blank or unmatched identifiers fail before onFailure.""
expandedQueriesCaps generated query variants.3
includeSpecControls source/score metadata in rendered context.true
contextFormatDefines the validated context template.[Source: ${source}]\n${content}\n
cacheEnables the bounded per-Role retrieval cache.true
cacheTTLSets cache lifetime in seconds.300
maxStalenessCaps stale-cache age for USE_CACHE, in seconds past expiry. -1 removes the ceiling.3600
onFailureSelects the runtime-failure policy: CONTINUE, FAIL, USE_CACHE or RETRY_SIMPLE.CONTINUE