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).
| Method | Signature | Description |
|---|---|---|
addDocument | void addDocument(Document document) | Adds a single document. Throws KnowledgeBaseException on failure. |
addDocuments | default void addDocuments(List<Document> documents) | Adds multiple documents. Default implementation iterates addDocument. |
getDocument | Optional<Document> getDocument(String id) | Retrieves a document by ID. Returns empty if not found. |
removeDocument | boolean removeDocument(String id) | Removes a document by ID. Returns true if removed, false if not found. |
replaceDocuments | default 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. |
search | List<SearchResult> search(String query, int topK) | Natural language search. Returns results ordered by relevance (highest first). |
search | List<SearchResult> search(String query, int topK, Map<String, Object> filters) | Search with metadata filtering. Filter entries are key-value pairs that must match. |
searchByEmbedding | List<SearchResult> searchByEmbedding(float[] embedding, int topK) | Similarity search using a pre-computed embedding vector. |
size | int size() | Returns the total number of documents. |
isEmpty | default boolean isEmpty() | Returns true if the knowledge base has no documents. Delegates to size() == 0. |
clear | void clear() | Removes all documents from the knowledge base. |
contains | default 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.
| Method | Return Type | Description |
|---|---|---|
getId() | String | Unique document ID |
getContent() | String | Document text content |
getSpec() | Map<String, Object> | Unmodifiable metadata map |
getSpec(String key) | Object | Single metadata value, or null |
getSpec(String key, Class<T> type) | T | Type-safe metadata value, returns null if missing or wrong type |
hasEmbedding() | boolean | Whether an embedding is present |
getEmbedding() | float[] | Copy of embedding array, or null |
getEmbeddingDimension() | int | Embedding 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).
| Method | Return Type | Description |
|---|---|---|
getDocument() | Document | The matched document |
getScore() | double | Relevance score (higher = more relevant) |
getContent() | String | Convenience: delegates to document.getContent() |
getDocumentId() | String | Convenience: 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.
| Field | Type | Default | Description |
|---|---|---|---|
name | String | (required) | Unique identifier. @Retrieval(sources = ...) selects sources by this name |
type | KnowledgeType | FILE | Which loader ingests this source |
path | String | "" | Directory or file to read (for FILE, URL) |
connection | String | "" | Database connection string (for DATABASE) |
query | String | "" | Query template with ${query} placeholder (for DATABASE) |
enabled | boolean | true | Whether this source is ingested at all |
KnowledgeType Enum
type selects the loader. TnsAI bundles exactly one, for FILE.
| Value | Bundled loader | Notes |
|---|---|---|
FILE | yes | Text files on disk. The default. |
URL | no | Documents fetched once from a remote URL or REST API |
DATABASE | no | Rows ingested from a SQL or NoSQL database |
MEMORY | no | A 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
SourceLoaderseam cannot express at any level of effort. Those are served bycom.tnsai.rag.RetrievalEngineProvider, which is invoked per retrieval. This is whyVECTOR_DBandWEB_SEARCHare 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-ingestsWhen 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(...), supplyretrieval(...), and name it withchatKnowledge(name). This is the canonical path. - A corpus the application owns and keeps mutating — a session's
uploaded documents, a workspace index.
KnowledgeSourceConfigis a declarative descriptor and cannot wrap a live object, so this case usesliveChatKnowledge(name)withChatKnowledgeBinding.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.
Related pages
- Agent-level action RAG
@AgentSpec.knowledge()/retrieval()(not thisKnowledgeBase). - Embedding prefixes Matryoshka helper on
VectorMemoryStore. - Strategies — retrieval algorithms and installable
EmbeddingFunctionproviders. - Controlled write-back package-private
overlay. Not a
KnowledgeBasebackend and not a public write API. - Pipeline — file ingest, chunking, and hybrid search.
RAG
Previous Page
RAG Strategy SPI
TnsAI 0.14.1 provides eight retrieval strategies in com.tnsai.intelligence.rag. You can construct them directly for an application-owned pipeline or select the matching mode through @Retrieval. Those entry points share strategy names, but they do not share every default or provider requirement.