ARCANE · Grounding layer
Grounded Graph of Thought
An evidence layer that turns documents into a connected knowledge graph. Retrieval explains how every result was reached and follows document relationships that keyword search alone can miss.
Retrieval runs locally without API keys or a vector database. When paired with a generative model, the same graph checks each claim, searches for missing support, and cites only the evidence that backs the answer.
What it does
No vector database
Keyword scoring plus a walk along document links. Needs only numpy, with no API keys.
Explains every hit
Each result says how it was reached, such as a reference from Troubleshooting.
Live documents
Add, replace or remove a document at any time. Only that document's nodes change.
Verified reasoning
Every sentence is checked against the documents. Unsupported ones trigger a search, then a rewrite.
The document graph is a Python port of graph-of-thought. The verified reasoning and ARCANE tool specs are added on top.
Quick start
pip install gpbacay-arcanefrom gpbacay_arcane import DocumentGraphguide = """# User Guide## InstallationRun pip install gpbacay-arcane.## ConfigurationEdit config.json to customize settings.## DeploymentPush the build to the server.## TroubleshootingIf config fails, see the Configuration section."""graph = DocumentGraph()graph.add_document(guide, "User Guide") # the same title again replaces itfor hit in graph.search("config fails"): print(hit["title"], hit["edgeType"], round(hit["score"], 2))# Troubleshooting None 1.0 <- direct match# Configuration reference 0.78 <- "see the Configuration section"# Deployment next 0.34# Installation parent-child 0.23context = graph.retrieve("deploy") # "### Deployment [user-guide#3]\nPush the build..."Pass context to any language model as grounding. Each hit also has path, hops and lexicalScore, and graph.outline("user-guide") prints the table of contents with node ids.
How retrieval works
- Index. Each document gets a root node and one node per heading. Sections longer than 2,000 characters are split into chunk nodes.
#lines inside code blocks are ignored. - Seed. BM25 scores every section against the query. Words are reduced to their stems, so
deploymatches "Deployment". - Walk. Starting from the top 5 seeds, a Dijkstra search follows edges for up to 2 hops. Each hop multiplies the score by the edge weight and 0.85, and paths that fall below 0.1 stop.
- Rank. A section that matches the query and is also reached through the graph scores higher than either alone. A query only touches the part of the graph it activates.
| Edge | Meaning | Weight |
|---|---|---|
| parent-child | Heading hierarchy. Walking up to a parent counts half as much as walking down. | 0.8 |
| reference | A section's text names another section's title, as in "see Configuration". | 0.75 |
| next | Reading order between neighbouring sections and chunks. | 0.4 |
| semantic | Shared distinctive terms, or embedding similarity when you pass an embedder. Up to 5 per node. | 0.3 to 0.9 |
Grounded Graph-of-Thoughts reasoning
GroundedGraphOfThought follows Besta et al., 2023, except that the LLM never grades its own answers. Each thought's sentences are checked against the document graph when the thought is created, by how much of each sentence's rare-word weight its evidence covers. No LLM call is involved. That one check ranks candidates, decides whether a merge or rewrite is kept, turns each unsupported sentence into a graph search, and picks the citations.
from gpbacay_arcane import GroundedGraphOfThoughtfrom gpbacay_arcane.got import opsdef my_llm(prompt: str) -> str: ... # call any model (hosted, Ollama, local) and return its textgot = GroundedGraphOfThought(graph, llm=my_llm) # max_llm_calls=16, min_support=0.5result = got.reason("My config fails. What should I check?")result["answer"] # final answerresult["claims"] # each sentence with its support (0-1) and the sections backing itresult["unsupported"] # sentences the documents do not backresult["citations"] # only sections that back a supported sentenceresult["thoughts"] # the whole thought graph: ids, parents, evidence, groundingresult["llm_calls"], result["budget_exhausted"]graph.support("Set DATABASE_URL before starting.", ["user-guide#3"]) # (1.0, ["user-guide#3"], mass)# Optional entailment check: (claim, evidence) -> probability, e.g. an NLI modelgot = GroundedGraphOfThought(graph, llm=my_llm, verify=my_nli)# Custom plangot.reason(question, [ops.retrieve(), ops.generate(5), ops.keep_best(3), ops.aggregate(), ops.refine(3)])ops.retrieve(query=None)
Searches the graph for the question or a sub-query and attaches the hits as evidence.
ops.generate(k)
Branches each thought into k candidate answers in one LLM call.
ops.keep_best(n)
Keeps the n most grounded thoughts, weighted by how relevant their sections are to the question, and drops weak or off-topic ones while a better one exists.
ops.aggregate()
Merges several thoughts into one, the step a tree of thoughts can't do. The merge is kept only if it is at least as grounded as its best parent.
ops.refine(max_rounds)
Searches the graph with each unsupported sentence first, then asks the LLM to rewrite only what is still unsupported. A rewrite that lowers grounding is discarded.
The default plan makes 1 to 4 LLM calls: one to generate, one to merge if two candidates are grounded, and up to two rewrites if sentences stay unsupported after searching. Every call is capped by max_llm_calls, and budget_exhausted tells you when a step was skipped.
Embeddings
Without embeddings, semantic edges come from shared distinctive terms. Pass any text -> vector function and sections are linked by meaning instead, and the closest sections by meaning also become search seeds. Links and seeds need a cosine similarity of at least 0.3 by default; change it with min_similarity.
from gpbacay_arcane import DocumentGraphgraph = DocumentGraph(embed=my_sentence_embedder) # any text -> vector functiongraph.add_document(guide, "User Guide")Don't use arc1-tiny's embedder here yet. Its similarities don't follow meaning ("install the package with pip" vs "how to set up the library" scores -0.02), so it adds misleading links. Use a sentence-embedding model, or leave embed unset. See ARC 1 for what it is trained on.
Agent tools
got_tools(graph) returns ARCANE ToolSpecs, so an agent can search and read the graph itself. Give spec.schema_dict() to any tool-calling model and run its calls with execute_tools.
| got_search | Find sections about a topic, with snippets and how each was reached |
| got_read_node | Full text of a section, with its parent, children and links |
| got_outline | Table of contents of a document, with node ids |
| got_list_documents | Documents in the graph |
| got_add_document, got_remove_document | Change the graph. Only with writable=True |
| got_reason | Cited answer through Graph-of-Thoughts. Only when you pass a reasoner |
from gpbacay_arcane import got_toolsfrom gpbacay_arcane.tools import execute_toolstools = got_tools(graph) # read-only; writable=True adds add/removeschemas = [t.schema_dict() for t in tools] # {"name", "description", "parameters"}# After your model returns tool calls:execute_tools([{"name": "got_search", "arguments": {"query": "deploy"}}], tools)Use an LLM to drive the tools for now. The bundled arc1-tiny was not trained on these tools and rarely picks the right one. Fine-tune ARC 1 on graph-tool examples before handing them to Arc1Agent.
Saving and sharing graphs
graph.to_json() returns a plain dictionary and DocumentGraph.from_json(data) rebuilds the graph. The format is the same as the Node.js graph-of-thought v2 package, so a graph built in Python can be served by its MCP server, and the reverse.
import jsonjson.dump(graph.to_json(), open("graph.json", "w"))graph = DocumentGraph.from_json(json.load(open("graph.json")))Limitations
Markdown headings only
Plain text without headings is kept under the document root and split into chunks.
Simple stemming
Common suffixes are removed, but it is not a full stemmer, so some word forms still miss.
Size
Semantic linking compares each new section with every other section, which suits up to tens of thousands of sections.
Term links age
Term-based links use word rarity at the time a document is added. Re-add a document to refresh them.
Lexical verification
The check counts shared words, so an honest paraphrase scores lower and a negated claim ("never edit config.json") still matches. Pass verify= (an NLI model, for example) to catch contradictions.
Not benchmarked yet
The design is tested with scripted models only. Its effect on answer quality against a real LLM has not been measured.
Sequential LLM calls
Reasoning steps call the model one at a time, so a plan's latency is the sum of its calls.
Tool routing
Use an LLM to pick tools; arc1-tiny is not trained for them yet.
Source: gpbacay_arcane/got.py. See also the repository README.