Skip to content
tnsaiDocs
GitHub

Graph stores

Graph retrieval searches a knowledge graph instead of a document index. An action reaches a graph by naming a @Knowledge(type = Knowledge.Type.GRAPH) entry. The entry is a handle, not a corpus: nothing is loaded, and an installed GraphStoreProvider serves the graph for the Role.

Declare a graph handle

@RoleSpec(knowledge = {
    @Knowledge(name = "handbook", path = "knowledge/handbook"),
    @Knowledge(name = "graph", type = Knowledge.Type.GRAPH)
})
public final class HandbookGraphRole extends Role {
    @ActionSpec(
        type = ActionType.LLM,
        description = "Answer from the handbook graph",
        knowledge = @Knowledge(name = "graph"))
    public String answer(String question) {
        return question;
    }
}

Rules for a GRAPH entry:

  • The strategy is GRAPH. When neither the entry nor the action sets one, GRAPH applies; any other strategy fails at init.
  • use is SEARCH. DIRECT and SEQUENTIAL fail.
  • Location and loader members (path, connection, query, format, include, exclude, unit, chunkContext, chunkContextModel, select, maxTokens) fail.
  • An action names at most one GRAPH entry, and no document entry beside it.
  • Chat turns leave GRAPH entries out. Only actions search them.

Which provider serves the graph

Three providers ship in tnsai-rag:

ProviderNameServes a Role whenDefault
InMemoryGraphStoreProviderin-memoryAn enabled FILE source is in reach: the Role's own, or an agent FILE source with loaded documents. A Role FILE source that loaded nothing still claims, and the graph is empty.Yes
KnowledgeToolsGraphStoreProviderknowledge-toolstnsai-tools is on the classpath and no enabled FILE source is in reachYes
Neo4jGraphStoreProviderneo4jA Neo4j URI is set and the Neo4j driver is on the classpathNo

When a non-default provider (Neo4j, or one of your own) also claims the Role, the defaults are dropped and that provider serves the graph. Two or more non-default claimants are a configuration error.

When no provider claims the Role, the action fails with GraphCapabilityException. This happens before @Search.onFailure applies: graph retrieval never falls back to vector search.

Graph from FILE sources

InMemoryGraphStoreProvider builds nodes and edges from the Role's FILE documents, and each entity keeps a link to the chunks it came from. Node identities are folded with InMemoryGraphStore.identityKey: NFKC normalization, lower case in Locale.ROOT, everything except letters and digits replaced by spaces, and a leading English article removed. Auth Service and the auth service become one node. Letters outside ASCII are kept, so 東京 stays 東京. Turkish letters do not fold to ASCII: ılık stays ılık, and İstanbul becomes i stanbul, not istanbul. Hits carry the source name in-memory.

A source selection that names no FILE source leaves this provider silent.

Graph from KnowledgeTools

KnowledgeToolsGraphStoreProvider serves the triples that the KnowledgeTools tools (such as kg_add_triple) keep in memory. It is the same in-process data, not a copy. Every live KnowledgeTools instance is read at retrieval time, so a triple added after the Role was bound is still found. Hits carry the source name knowledge-tools.

To use it, keep the graph handle on a Role with no FILE knowledge, in an agent whose @AgentSpec declares none either. With FILE knowledge in reach, the in-memory provider serves the Role instead.

Query matching here is ASCII only: the query is lower-cased and split on everything that is not a-z or 0-9. A query with no ASCII letters or digits produces no tokens and matches nothing:

QueryTokens
Auth Serviceauth, service
İstanbuli, stanbul
ıslakslak
東京none

Neo4j

export TNSAI_GRAPH_NEO4J_URI=bolt://localhost:7687
# or -Dtnsai.graph.neo4j.uri=bolt://localhost:7687
export TNSAI_GRAPH_NEO4J_USER=neo4j
export TNSAI_GRAPH_NEO4J_PASSWORD=...

Add org.neo4j.driver to your runtime classpath; TnsAI does not bring it. When the URI is set but the driver is missing, the provider logs a warning and does not claim the Role.

Graph as a third hybrid stream

@Search(strategy = HYBRID) fuses BM25 and vector rankings. When a graph provider claims the Role, the graph ranking joins as a third stream:

  • One claiming provider: the three streams are fused.
  • No claiming provider: BM25 and vector are fused as before. HYBRID does not become vector only.
  • Two or more non-default claimants: a configuration error, before @Search.onFailure applies.

Programmatically, pass a GraphRAGStrategy as the third argument:

RAGStrategy twoStreams = new HybridRAGStrategy(bm25Index, vectorStore);
RAGStrategy threeStreams = new HybridRAGStrategy(bm25Index, vectorStore, graphStrategy);

Each stream is ranked on its own, then fused with unweighted reciprocal rank fusion (k = 60). Scores are scaled to 0.0 to 1.0 by the number of non-empty streams before RAGContext.minScore() applies. graphStream() reports which case applied.

Each hybrid hit records its streams in metadata:

KeyValues
retrievalStreamsbm25, vector, graph: the streams that found the hit
graphStreamfused, or skipped when no graph store was available