Skip to content
tnsaijava agent framework

Knowledge Base & RAG

TnsAI provides a built-in Retrieval-Augmented Generation (RAG) system through the KnowledgeBase interface, Document model, and @KnowledgeSource annotation.

Package: com.tnsai.knowledge

The only shipped KnowledgeBase implementation is InMemoryKnowledgeBase. Pinecone, Weaviate, Milvus, Elasticsearch, and OpenSearch are not framework adapters for this interface. WeaviateTools / QdrantTools are tnsai-tools POJO toolkits (LLM-callable HTTP helpers), not KnowledgeBase backends.

Role and action RAG indexes live on VectorMemoryStore (an in-process VectorIndex). Optional Qdrant and pgvector indexes attach through their environment-backed adapters (see Qdrant / pgvector). They are not KnowledgeBase replacements.

@VectorMemory.provider resolves through com.tnsai.memory.vector.VectorStoreProvider. Core provides inmemory (InMemoryVectorStoreProvider), whose default searches a real VectorMemoryStore. A name with no SPI (qdrant, pgvector, milvus, …) throws IllegalStateException at agent init — there is no silent TF-IDF fallback. Optional Role Qdrant/pgvector adapters open from env URL; they do not register as VectorStoreProvider.

com.tnsai.intelligence.rag.vector.SelectiveReembedIndex is a hash-keyed local index that re-embeds dirty chunks only. It is not a VectorIndex backend and not a KnowledgeBase. See Selective re-embed.

KnowledgeBase Interface

KnowledgeBase is the programmatic document store. You can implement the interface yourself; TnsAI does not ship a second adapter.

Chat grounding uses ChatKnowledgeBinding and RetrievalEngine; the orchestrator does not call KnowledgeBase.search directly. Application code can call search on its own store. To ground agent chat, choose the declarative or application-supplied binding described under Integration with AgentBuilder.

Methods

These are the operations every KnowledgeBase implementation must support. The most important ones are addDocument (to ingest content) and search (to retrieve relevant context for an LLM call).

MethodSignatureDescription
addDocumentvoid addDocument(Document document)Adds a single document. Throws KnowledgeBaseException on failure.
addDocumentsdefault void addDocuments(List<Document> documents)Adds multiple documents. Default implementation iterates addDocument.
getDocumentOptional<Document> getDocument(String id)Retrieves a document by ID. Returns empty if not found.
removeDocumentboolean removeDocument(String id)Removes a document by ID. Returns true if removed, false if not found.
replaceDocumentsdefault void replaceDocuments(Collection<String> removeIds, List<Document> add)One generation: drop then add. Default walks sequentially. InMemoryKnowledgeBase holds the write lock and rolls back on add failure so readers never see a mix.
searchList<SearchResult> search(String query, int topK)Natural language search. Returns results ordered by relevance (highest first).
searchList<SearchResult> search(String query, int topK, Map<String, Object> filters)Search with metadata filtering. Filter entries are key-value pairs that must match.
searchByEmbeddingList<SearchResult> searchByEmbedding(float[] embedding, int topK)Similarity search using a pre-computed embedding vector.
sizeint size()Returns the total number of documents.
isEmptydefault boolean isEmpty()Returns true if the knowledge base has no documents. Delegates to size() == 0.
clearvoid clear()Removes all documents from the knowledge base.
containsdefault boolean contains(String id)Checks if a document with the given ID exists. Delegates to getDocument(id).isPresent().

Document

Document is an immutable value object representing a document or document chunk. Each document has:

  • id -- Unique identifier (auto-generated UUID if not specified)
  • content -- The text content (required, cannot be null or empty)
  • metadata -- Arbitrary key-value pairs for filtering and context (immutable copy)
  • embedding -- Optional vector representation for similarity search (defensive copy)

Factory Methods

The quickest way to create a Document is with the static of() methods. These are convenient for simple use cases where you do not need to set an explicit ID or embedding.

// Simple document (auto-generated ID)
Document doc = Document.of("This is the document content");

// Document with a single metadata entry
Document doc = Document.of("Product docs...", "source", "docs/product.md");

Builder

For full control over the document's ID, metadata, and embedding vector, use the builder. This is the recommended approach when you need to attach metadata for filtered searches or pre-computed embeddings for similarity search.

Document doc = Document.builder()
    .id("doc-001")                          // optional, UUID generated if omitted
    .content("Product documentation...")     // required
    .metadata("source", "docs/product.md")  // single entry
    .metadata("category", "documentation")  // chainable
    .metadata(Map.of("version", "2.0"))     // bulk metadata
    .embedding(embeddingVector)              // optional float[]
    .build();

Builder method content(String) throws NullPointerException if null. build() throws IllegalStateException if content is null or empty.

Accessors

These getter methods let you read the document's fields. Metadata is accessed through the getSpec methods, and embeddings are returned as defensive copies to preserve immutability.

MethodReturn TypeDescription
getId()StringUnique document ID
getContent()StringDocument text content
getSpec()Map<String, Object>Unmodifiable metadata map
getSpec(String key)ObjectSingle metadata value, or null
getSpec(String key, Class<T> type)TType-safe metadata value, returns null if missing or wrong type
hasEmbedding()booleanWhether an embedding is present
getEmbedding()float[]Copy of embedding array, or null
getEmbeddingDimension()intEmbedding vector length, or 0 if none

Immutable Copy with Embedding

Since Document is immutable, attaching an embedding returns a new Document instance rather than modifying the original. This is useful when you compute embeddings separately after initial document creation.

// Attach an embedding to an existing document (returns a new Document)
Document withVector = doc.withEmbedding(embeddingVector);

Equality is based on id only.

SearchResult

SearchResult wraps a matched Document with a relevance score. Implements Comparable<SearchResult> -- natural ordering is by score descending (highest first).

MethodReturn TypeDescription
getDocument()DocumentThe matched document
getScore()doubleRelevance score (higher = more relevant)
getContent()StringConvenience: delegates to document.getContent()
getDocumentId()StringConvenience: delegates to document.getId()

Constructor: new SearchResult(Document document, double score) -- document cannot be null.

List<SearchResult> results = knowledgeBase.search("query", 5);
for (SearchResult result : results) {
    System.out.printf("Score: %.4f | %s%n", result.getScore(), result.getContent());
}

InMemoryKnowledgeBase

InMemoryKnowledgeBase is a thread-safe, in-memory implementation suitable for testing and small datasets. It provides:

  • ConcurrentHashMap storage for thread safety
  • TF-IDF keyword search with stop-word removal for search()
  • Cosine similarity for both TF-IDF vectors and raw embeddings (searchByEmbedding)
  • Metadata filtering support
KnowledgeBase kb = new InMemoryKnowledgeBase();

kb.addDocument(Document.of("Java is a programming language"));
kb.addDocument(Document.of("Python is also a programming language"));
kb.addDocument(Document.builder()
    .content("Rust is a systems programming language")
    .metadata("category", "systems")
    .build());

// Keyword search
List<SearchResult> results = kb.search("programming language", 5);

// Filtered search
List<SearchResult> filtered = kb.search("programming", 5,
    Map.of("category", "systems"));

// Embedding search
List<SearchResult> similar = kb.searchByEmbedding(queryEmbedding, 3);

InMemoryKnowledgeBase is the built-in store for this interface. Large or durable vector indexes are a different type: VectorMemoryStore, not a drop-in KnowledgeBase. See Strategies and Pipeline.

FILE ingest uses replaceDocuments as the atomic swap after a staged generation. See Pipeline — incremental indexing.

@KnowledgeSource Annotation

Package: com.tnsai.annotations

Declarative configuration for RAG knowledge sources. Can be applied to types (agent classes) or methods (individual actions). Repeatable via @KnowledgeSources.

Targets: ElementType.TYPE, ElementType.METHOD Retention: RetentionPolicy.RUNTIME

Fields

@KnowledgeSource says where documents come from. It does not say how they are searched -- result counts, score thresholds, caching and strategy are @Retrieval's, applied per action.

That split is enforced, not merely advised. A source loader runs once, when the agent's knowledge binding is built, and returns the documents it found; the framework indexes those and searches the index thereafter. Per-request result limits, score thresholds, caching, and strategies belong to @Retrieval.

FieldTypeDefaultDescription
nameString(required)Unique identifier. @Retrieval(sources = ...) selects sources by this name
typeKnowledgeTypeFILEWhich loader ingests this source
pathString""Directory or file to read (for FILE, URL)
connectionString""Database connection string (for DATABASE)
queryString""Query template with ${query} placeholder (for DATABASE)
enabledbooleantrueWhether this source is ingested at all

KnowledgeType Enum

type selects the loader. TnsAI bundles exactly one, for FILE.

ValueBundled loaderNotes
FILEyesText files on disk. The default.
URLnoDocuments fetched once from a remote URL or REST API
DATABASEnoRows ingested from a SQL or NoSQL database
MEMORYnoA snapshot of the agent's own memory

The three types without a bundled loader are extension points, not placeholders: the framework resolves them through the SourceLoader SPI and your application supplies the loader from an optional module, following the rule that integrations are never bundled into core.

A source whose type has no installed loader is skipped with a warning, and the binding still builds from whichever sources did load. Only when every enabled source is unloadable does binding fail, with an error naming the types and what is registered. The line is drawn there so you can install optional loaders one at a time -- but it does mean a missing loader costs you part of the corpus without failing anything. Read the warning.

Retrieval against a remote index is not a knowledge source. A vector database or a search API is queried per request, which the ingest-time SourceLoader seam cannot express at any level of effort. Those are served by com.tnsai.rag.RetrievalEngineProvider, which is invoked per retrieval. This is why VECTOR_DB and WEB_SEARCH are not in the table above.

What the FILE loader reads

LocalFileSourceLoader is text-only, and this list is exhaustive:

.txt · .md · .markdown · .json · .yaml · .yml · .csv

PDF and DOCX are not included. They need a loader of their own, and none is bundled today. How that shows up depends on how you declared the source:

  • A directory -- unsupported files are filtered out before they are read. They are simply absent from the corpus, while the loaded N document(s) line still counts whatever text files sat beside them, so nothing looks wrong.
  • A single file -- the filter does not guard this path, so the loader tries to read the binary as text and logs failed to read file. That reads like a permissions or I/O problem, but it is a format problem.

The loader also does no chunking: one file becomes one document however long it is. Since @Retrieval.topK counts documents, on a corpus of whole files it counts files -- topK = 5 over five long manuals is the entire corpus, and contextWindow then truncates mid-document. Until the framework chunks for you, splitting large files into smaller ones on disk is what makes those knobs mean what they say.

Annotation Examples

These examples show how to attach knowledge sources to an agent class or an individual action method. Multiple sources combine on the same class through the repeatable annotation pattern.

// On a class -- multiple sources via @Repeatable
@KnowledgeSource(name = "product-docs", path = "knowledge/products")
@KnowledgeSource(name = "faq", path = "knowledge/faq")
public class SupportAgent extends Agent { ... }

// How the corpus is searched is @Retrieval's, per action
@ActionSpec(type = ActionType.LLM, description = "Answer question")
@Retrieval(sources = "product-docs", topK = 3, minScore = 0.8)
public String answerQuestion(String question) {
    // Relevant context is automatically retrieved before the LLM call
}

// A type whose loader your application registers through the SourceLoader SPI
@KnowledgeSource(
    name = "customer-data",
    type = KnowledgeType.DATABASE,
    connection = "jdbc:postgresql://localhost/mydb",
    query = "SELECT content FROM docs WHERE content ILIKE '%${query}%' LIMIT 10"
)

Refreshing a corpus

A binding -- its documents, its indexes and its retrieval-result cache -- is built once per agent class per JVM and kept for the process lifetime. Nothing re-reads your knowledge sources on its own, and @Retrieval.cacheTTL does not cover it: an expired cache entry is recomputed against the same index and returns the same documents.

When every source owned by the Role may have changed, invalidate the whole binding:

RoleRagBinding.invalidate(SupportAgent.class);   // next dispatch re-ingests

When only one enabled source changed, use its exact, case-sensitive name:

RoleRagBinding.invalidate(SupportAgent.class, "product-docs");

That overload rebuilds bindings that contain product-docs and resets only that source's sequential cursor. Sibling bindings and cursors are preserved. A null, blank, disabled, or unknown source name fails loudly. Retrieval already in flight completes against the snapshot it started on.

Integration with AgentBuilder

KnowledgeBase is a programmatic store. Agent chat accepts two binding paths, depending on what describes the source:

  • A source you can declare — a directory, a file set, a query. Declare it with addKnowledgeSource(...), supply retrieval(...), and name it with chatKnowledge(name). This is the canonical path.
  • A corpus the application owns and keeps mutating — a session's uploaded documents, a workspace index. KnowledgeSourceConfig is a declarative descriptor and cannot wrap a live object, so this case uses liveChatKnowledge(name) with ChatKnowledgeBinding.live(...). This is the current path for application-supplied live sources.

⚠️ Reaching for chatKnowledge(name) with a runtime corpus does not leave chat ungrounded — it makes build() throw, because the named source is never found. That is the signal to use the live path instead.

Full RAG Example

This end-to-end example shows the complete RAG workflow: creating a knowledge base, adding documents with metadata, searching for relevant context, building an augmented prompt, and sending it to the agent. It also demonstrates filtered search to narrow results by metadata category.

// 1. Create and populate knowledge base
KnowledgeBase kb = new InMemoryKnowledgeBase();
kb.addDocuments(List.of(
    Document.builder()
        .content("Product X supports features A, B, and C.")
        .metadata("source", "product-docs")
        .metadata("category", "features")
        .build(),
    Document.builder()
        .content("Pricing starts at $99/month for the Basic plan.")
        .metadata("source", "pricing-page")
        .metadata("category", "pricing")
        .build(),
    Document.builder()
        .content("Enterprise plan includes SSO and dedicated support.")
        .metadata("source", "pricing-page")
        .metadata("category", "pricing")
        .build()
));

// 2. Search for relevant context
String query = "What features does Product X have?";
List<SearchResult> context = kb.search(query, 3);

// 3. Build augmented prompt
String augmentedPrompt = "Context:\n" +
    context.stream()
        .map(r -> r.getContent())
        .collect(Collectors.joining("\n")) +
    "\n\nQuestion: " + query;

// 4. Send to agent
String answer = agent.chat(augmentedPrompt);

// Or use filtered search for specific categories
List<SearchResult> pricingResults = kb.search("plan cost", 3,
    Map.of("category", "pricing"));

Embedding prefixes

EmbeddingFunctions.matryoshka keeps an L2-normalized prefix of a Matryoshka-compatible EmbeddingFunction. VectorMemoryStore pins the first admitted dimension and rejects mixed add/query vectors. See Embedding prefixes.