
This system takes an unstructured text document, and uses an LLM of your choice to extract knowledge in the form of Subject-Predicate-Object (SPO) triplets, and visualizes the relationships as an interactive knowledge graph. Optional companion commands let you ask the
graph questions (graph-chat) and run everything from a local web interface (graph-serve).
Live examples (no install needed): https://robert-mcdermott.github.io/ai-knowledge-graph/
| The Industrial Revolutions | Marie Curie | The Apollo Program |
| Coffee, from farm to cup | La Alhambra (Spanish) |
.txt, .md, .rst, .pdf and .docx files or whole directories, in any language (extraction.language); large documents are split on sentence boundaries with overlap and extracted in parallelgraph-chat command): grounded, cited answers from the generated graphgraph-serve command): ingest, browse, explore and ask questions in the browsermax_completion_tokens fallback, environment-variable API keys and an on-disk response cachenetworkx, jinja2, requests (installed automatically)[web] for graph-serve (FastAPI, uvicorn), [pdf] and [docx] for those input formats,
[all] for everythinggit clone https://github.com/robert-mcdermott/ai-knowledge-graph.git
cd ai-knowledge-graph
uv sync --extra web # or: pip install -e ".[web]"
uv sync creates the environment and the generate-graph, graph-chat and graph-serve commands; prefix
commands with uv run (as below) or activate the environment. With pip, drop the uv run prefix.
uv run graph-serve --config config.toml --graphs data/samples --open
This opens a library with the five sample graphs from data/samples/; click one to explore it. Browsing works
without a reachable model; the Ask panel and the New graph form use the model configured in
config.toml. To render a sample to a static page instead:
uv run generate-graph --from-json data/samples/marie-curie.json --output marie-curie.html
Edit config.toml (model name, endpoint, API key; see Configuration), then:
uv run generate-graph --input your_text_file.txt --output knowledge_graph.html
That writes knowledge_graph.html (a self-contained interactive page), knowledge_graph.json (the triples)
and knowledge_graph.meta.json (community names). Keep personal settings in a file git ignores, such as
config-working.toml, and pass it with --config. From a checkout without installing, python generate-graph.py
works the same way.
uv run graph-chat knowledge_graph.json "How did the steam engine change cities?"
uv sync --extra dev --extra web # or: pip install -e ".[dev,web]"
uv run pytest -q # 170 tests, no LLM needed
uv run ruff check .
python scripts/build_docs.py # rebuild the GitHub Pages site from data/samples
The system is configured with a TOML file (config.toml by default, or --config other.toml).
Every key except model and base_url is optional; the values shown are the defaults.
[llm]
model = "gemma3" # any model name your endpoint accepts
api_key = "sk-1234" # or "env:OPENAI_API_KEY" to read it from the environment
base_url = "http://localhost:11434/v1/chat/completions" # any OpenAI-compatible chat completions URL
max_tokens = 32768 # reasoning models need a large budget, see note below
temperature = 0.2 # omit for models that only accept the default (e.g. gpt-5)
#reasoning_effort = "low" # passed through to servers/models that support it
#timeout = 300 # seconds per request
#max_retries = 3 # retries on 429/5xx/connection errors
#token_param = "auto" # auto-switches to max_completion_tokens for newer OpenAI models
#json_mode = false # request response_format = json_object
#concurrency = 4 # chunks extracted in parallel
#cache_dir = ".kg-cache" # cache LLM replies; re-running the same input is free ("" disables, or --no-cache)
#[llm.extra_body] # arbitrary extra request fields, e.g. Ollama's think switch (keep last in [llm])
#think = false
#[llm.extra_headers] # extra HTTP headers, e.g. OpenRouter attribution
[extraction]
language = "auto" # e.g. "Chinese" to get entity names and predicates in Chinese
[chunking]
chunk_size = 500 # target words per chunk; chunks end on sentence boundaries
overlap = 50 # words of trailing sentences repeated in the next chunk
[standardization]
enabled = true # merge case / stop-word / plural variants of the same entity
use_llm_for_entities = true # extra LLM pass that groups remaining variants (one call)
#merge_word_subsets = false # aggressive merging by shared words ("steam engine factories" -> "steam engine")
[inference]
enabled = true # master switch
use_llm_for_inference = true # master switch for the LLM methods below
#llm_bridge = true # link every isolated component to the main graph
#max_bridge_components = 20 # ...for up to this many components, batched
#bridge_groups_per_call = 5
#llm_hub = true # well-known relationships among the most central entities
#hub_entities = 25
#hub_max_new = 25
#taxonomy = true # rule: "quantum computing" is a "computing" (deterministic)
apply_transitive = false # rule: A->B->C => A->C for transitive predicate families only
#transitive_predicate_groups = ["is a", "part of", "located in", "led to"]
#transitive_max_hub_degree = 10 # never chain through nodes with more connections than this
#transitive_max_per_subject = 5
#lexical = false # rule: "related to" edges for names sharing a word (noisy)
#lexical_min_word_length = 5
#max_inferred_ratio = 0.5 # cap inferred edges at this fraction of extracted edges
[query] # used by the optional graph-chat command
hops = 2 # neighbourhood radius around the entities a question mentions
max_triples = 150 # facts sent to the model per question
max_seed_entities = 8
history_turns = 3 # previous Q&A pairs kept for follow-up questions
[visualization]
edge_smooth = false # or "dynamic", "continuous", "curvedCW", ... (true = "continuous")
name_communities = true # ask the LLM for a short name per community (one call)
show_inferred = true # whether inferred (dashed) relationships are visible when the page opens
theme = "light" # initial theme: "light" or "dark"
edge_labels = "all" # initial edge label mode: "all", "selection" or "none"
title_case = true # show "Steam Engine" for the entity "steam engine" (ids are unchanged)
collapse_parallel_edges = true # draw one edge per node pair with a "+N" badge; all predicates stay in the tooltip
Rule-based inference (apply_transitive, lexical) is off by default because in testing it generated
roughly 70 % of all edges and hid the relationships actually found in the text. Local overrides such as
config-*.toml and config.local.toml are ignored by git.
# OpenAI
[llm]
model = "gpt-4.1-mini"
api_key = "env:OPENAI_API_KEY"
base_url = "https://api.openai.com/v1/chat/completions"
# Google Gemini (OpenAI-compatible endpoint)
[llm]
model = "gemini-2.5-flash"
api_key = "env:GEMINI_API_KEY"
base_url = "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions"
# LM Studio / vLLM / LiteLLM proxy (any OpenAI-compatible server)
[llm]
model = "your-model-name"
api_key = "not-needed"
base_url = "http://localhost:1234/v1/chat/completions"
Models such as DeepSeek, Qwen3, gpt-5 and the o-series "think" before they answer, and that
hidden reasoning is charged against max_tokens. With a small budget the model can spend
everything on reasoning and return no answer at all. The generator detects this
(finish_reason = "length") and aborts with an explanation instead of silently producing an
empty graph. Set max_tokens to 16k–32k for such models, lower reasoning_effort, use
smaller chunks, or pass --continue-on-error to skip failed chunks and accept an
incomplete graph.
--input PATH [PATH ...]: Input file(s) or directories. Plain text (.txt, .md, .rst), .pdf (install the [pdf] extra) and .docx ([docx] extra). With several inputs every triple is tagged with its document--output FILE: Output HTML file for visualization (default: knowledge_graph.html)--config FILE: Path to config file (default: config.toml)--debug / --verbose: Show debug output, including raw LLM responses and standardization merges--quiet: Only warnings and errors on the console (the final summary is still printed)--no-standardize: Disable entity standardization--no-inference: Disable relationship inference--continue-on-error: Skip chunks whose LLM call fails or is truncated instead of aborting--from-json FILE: Re-render the visualization from a previously saved .json triples file (no LLM calls)--no-cache: Bypass the LLM response cache for this run--library-path DIR: Reference the vis-network library from DIR (copied there if missing) instead of embedding it, so many pages can share one copy, for example on GitHub Pages--export FORMATS: Extra outputs next to the HTML, comma-separated: json (always), csv, graphml (Gephi, yEd, Cytoscape), cypher (Neo4j MERGE script)--test: Generate sample visualization using test datausage: generate-graph [-h] [--test] [--config CONFIG] [--output OUTPUT]
[--input PATH [PATH ...]] [--from-json FILE] [--debug]
[--quiet] [--no-standardize] [--no-inference]
[--continue-on-error] [--no-cache] [--export FORMATS]
[--library-path DIR]
Knowledge Graph Generator and Visualizer
options:
-h, --help show this help message and exit
--test Generate a test visualization with sample data
--config CONFIG Path to configuration file
--output OUTPUT Output HTML file path
--input PATH [PATH ...]
Input file(s) or directories: .txt/.md/.rst, .pdf
(needs [pdf] extra), .docx (needs [docx] extra).
Required unless --test or --from-json is used
--from-json FILE Render a visualization from a previously saved triples
JSON file (no LLM calls)
--debug, --verbose Enable debug output (raw LLM responses and extracted
JSON)
--quiet Only show warnings and errors
--no-standardize Disable entity standardization
--no-inference Disable relationship inference
--continue-on-error Skip chunks whose LLM call fails or is truncated
instead of aborting
--no-cache Do not read or write the LLM response cache
(llm.cache_dir)
--export FORMATS Comma-separated extra outputs written next to the
HTML: json (always), csv, graphml, cypher
--library-path DIR Reference the vis-network library from DIR (copied
there if missing) instead of embedding it; useful when
publishing many pages, e.g. on GitHub Pages
usage: graph-chat [-h] [--config CONFIG] [--json] [--no-facts] graph [question]
positional arguments:
graph Triples JSON written by generate-graph (e.g. knowledge_graph.json)
question Question to answer; omit for an interactive session
options:
--config CONFIG Path to configuration file (uses its [llm] section)
--json Print the result as JSON (single-question mode)
--no-facts Do not list the cited facts after the answer
usage: graph-serve [-h] [--config CONFIG] [--graphs DIR] [--host HOST] [--port PORT] [--open]
options:
--config CONFIG Path to configuration file (uses [llm], [query], [visualization])
--graphs DIR Directory containing the .json graphs written by generate-graph
--host HOST Bind address (default 127.0.0.1; the server has no authentication)
--port PORT Port (default 8008)
--open Open the library in your browser
Command:
generate-graph --input data/industrial-revolution.txt --output industrial-revolution-kg.html
Console Output (gemma4 via Ollama, about 20 seconds; --quiet reduces this to the summary):
Using input text from file: data/industrial-revolution.txt
==================================================
PHASE 1: INITIAL TRIPLE EXTRACTION
==================================================
Processing text in 3 chunks (size: 500 words, overlap: 50 words)
Extracting with 3 parallel requests
Processing chunk 1/3 (490 words)
Processing chunk 2/3 (495 words)
Processing chunk 3/3 (76 words)
Chunk 1: 58 triples
Chunk 2: 79 triples
Chunk 3: 15 triples
Extracted a total of 152 triples from all chunks
==================================================
PHASE 2: ENTITY STANDARDIZATION
==================================================
Starting with 152 triples and 154 unique entities
Standardizing entity names across all triples...
Applied LLM-based entity standardization for 12 entity groups
Removed 7 self-referencing triples
Standardized 154 entities into 152 standard forms
After standardization: 145 triples and 133 unique entities
==================================================
PHASE 3: RELATIONSHIP INFERENCE
==================================================
Starting with 145 triples
Top 5 relationship types before inference:
- enabled: 11 occurrences
- used in: 8 occurrences
- altered: 8 occurrences
- invented: 7 occurrences
- involved: 7 occurrences
Inferring additional relationships between entities...
Identified 11 disconnected components in the graph
LLM bridging proposed 11 relationships for 10 isolated components
LLM hub enrichment proposed 25 relationships among the 25 most central entities
Within-community LLM inference proposed 10 relationships
Taxonomy rule proposed 3 relationships
Transitive inference proposed 2 relationships (skipped 3 paths through hub nodes)
Accepted 48 inferred relationships (llm_bridge: 11, llm_hub: 23, llm_within: 10, taxonomy: 2, transitive: 2); dropped 3 already-connected pairs
Top 5 relationship types after inference:
- enabled: 18 occurrences
- is a: 15 occurrences
- used in: 8 occurrences
- altered: 8 occurrences
- invented: 7 occurrences
Added 48 inferred relationships
Final knowledge graph: 189 triples
Saved raw knowledge graph data to industrial-revolution-kg.json
Processing 189 triples for visualization
Found 133 unique nodes
Found 48 inferred relationships
Detected 13 communities using Louvain method
Named 13 communities
Knowledge graph visualization saved to industrial-revolution-kg.html
Saved community names to industrial-revolution-kg.meta.json
Knowledge Graph Statistics:
Nodes: 133
Edges: 189
Communities: 13
To view the visualization, open the following file in your browser:
file:///path/to/industrial-revolution-kg.html
generate-graph is unchanged: text in, static HTML (and JSON) out. The optional graph-chat command
reads that JSON and answers questions from it, using the same [llm] settings:
uv run graph-chat knowledge_graph.json "How did the steam engine change cities?"
uv run graph-chat knowledge_graph.json # interactive session; Ctrl-D or an empty line quits
uv run graph-chat data/samples/apollo-program.json "Who flew on Apollo 13?"
For each question it finds the entities the question mentions, retrieves the surrounding subgraph (and the
shortest paths between the mentioned entities), and asks the model to answer only from those facts. The
reply lists the facts it relied on, each marked extracted (with the source sentence) or inferred
(with the method), so answers stay traceable to the document. If the graph does not contain an answer it
says so. Tuning lives under [query] in the config (hops, max_triples, max_seed_entities,
history_turns); --json prints the result as JSON for scripting.
graph-serve is a small local server over the same code: it lists the graphs in a directory, opens each one in
the explorer, and adds an Ask panel that answers questions from the graph with cited facts (click a fact to
jump to it in the graph). It needs the [web] extra (uv sync --extra web or pip install -e ".[web]").
Start it with the sample graphs ready to use:
uv run graph-serve --config config.toml --graphs data/samples --open
or point it at the directory where you write your own graphs (any --config file works, e.g. your local
config-working.toml):
uv run graph-serve --config config-working.toml --graphs ./out --open
The library page also has a New graph form: paste text or upload files (.txt, .md, .rst, .pdf,
.docx, several at once), watch the phases run, and land in the explorer when it finishes. It runs the same
pipeline as generate-graph and writes the same .json and .html into the graphs directory, so the result is
usable from the command line and graph-chat too. One generation runs at a time.
It binds to 127.0.0.1:8008 by default, has no accounts or authentication, and only reads and writes the graphs
directory, so it is meant for your own machine. Static HTML output is unchanged: the chat panel only exists in
served pages. --host 0.0.0.0 exposes it on your network if you put your own access control in front.
data/samples/ contains five short texts and the graphs generated from them (.json triples plus a
.meta.json sidecar with the LLM-generated community names), so you can try everything without an LLM or
an API key. They are also the live examples on GitHub Pages.
| Sample | Domain | What it exercises | Live |
|---|---|---|---|
industrial-revolution.txt | technology history | the original demo text: technologies, people, eras | open |
marie-curie.txt | biography | people, places, dates, organizations, works | open |
apollo-program.txt | program history | events, missions, organizations, many people | open |
coffee-supply-chain.txt | process description | concepts, technologies, products, places | open |
la-alhambra.txt | Spanish text | extraction.language = "Spanish": entity names and predicates in Spanish | open |
uv run generate-graph --from-json data/samples/marie-curie.json --output marie-curie.html # renders instantly
uv run graph-serve --config config.toml --graphs data/samples --open # browse all five
uv run graph-chat data/samples/apollo-program.json "Who flew on Apollo 13?" # needs your LLM
The graphs were generated with deepseek-v4.1-flash (reasoning_effort = "low", temperature = 0.2,
use_llm_for_entities = false because that model reasons without end over long entity lists) and the default
inference settings. They double as regression fixtures: tests/test_samples.py checks that they stay
well-formed, typed, sourced and connected. Regenerate them when the extraction prompt changes, then rebuild the
GitHub Pages site with python scripts/build_docs.py (it writes docs/index.html, docs/samples/*.html and a
shared docs/vendor/ copy of vis-network, using --library-path style pages so each is about 100 KB).
Next to the HTML page the generator writes a JSON file with the same base name containing every triple, and a
.meta.json sidecar with the community names so that --from-json and graph-serve show them without an LLM:
{
"subject": "james watt",
"predicate": "refined",
"object": "steam engine",
"subject_type": "person",
"object_type": "technology",
"source": "A key catalyst of the First Industrial Revolution was the refinement of the steam engine by Scottish engineer James Watt, ...",
"chunk": 1
}
With --export graphml,cypher,csv the same graph is also written as GraphML (typed nodes with community
and degree, edges with provenance), an idempotent Neo4j Cypher script (:Entity plus a label per entity
type, one relationship type per predicate; run it with cypher-shell -f graph.cypher), and a flat CSV.
Inferred triples carry "inferred": true and a "method" (llm_bridge, llm_hub, llm_within,
taxonomy, transitive or lexical); transitive triples also record the intermediate node in "via".
The JSON can be re-rendered at any time with generate-graph --from-json graph.json --output graph.html
(or python json_to_html.py graph.json graph.html), and the page itself can export the visible triples as
JSON or CSV.
generate-graph loads and validates the configuration (defaults applied, env: API keys resolved), reads the
input files (text encodings detected; PDF and DOCX through optional readers), and runs the pipeline below.
--from-json skips straight to the visualization step, and --test renders a small built-in graph.
llm.concurrency) to extract typed Subject-Predicate-Object triplets, each tagged with the sentence it came fromstandardization.use_llm_for_entities, one call): the LLM reviews the most frequent entities and groups the ones that refer to the same concept (e.g., "AI", "artificial intelligence", "AI system")inference.enabled):
max_inferred_ratio × the number of extracted triples. Every inferred triple records its method..meta.json community names, and any --export formatsThe second and third passes and community naming are optional and can be disabled in the configuration to minimize LLM usage. LLM replies are cached on disk, so re-running the same document with the same settings makes no API calls.
The generated HTML is a single self-contained file (vis-network is embedded) that works offline; --library-path
switches to one shared library copy when you publish many pages.
visualization.name_communities = false)./), and shareable links: the selected node is kept in the URL (#node=...).visualization.collapse_parallel_edges).visualization.title_case).visualization.edge_labels); cycle to none or selection only, which keeps dense graphs readable. Around a selected node only its own edges are labelled.visualization.theme), keyboard shortcuts (? for the list), responsive layout..
├── config.toml # Main configuration file for the system
├── generate-graph.py # Run from a checkout without installing
├── json_to_html.py # Re-render a saved .json graph (same as --from-json)
├── pyproject.toml # Project metadata, dependencies, tool config
├── requirements.txt # Pinned dependencies for 'pip' users
├── uv.lock # Lock file for 'uv' users
├── IMPROVEMENT_PLAN.md # Roadmap / task list
├── .github/workflows/ci.yml # Lint + tests on Python 3.11-3.13
├── data/ # Sample input text and screenshot
│ └── samples/ # Sample corpus: texts + generated graphs (no LLM needed to explore)
├── docs/ # GitHub Pages site: landing page + sample graphs (built by scripts/build_docs.py)
├── scripts/build_docs.py # Rebuilds docs/ from data/samples
├── tests/ # pytest suite (runs without an LLM)
└── src/knowledge_graph/ # Core package (installed as `knowledge_graph`)
├── __init__.py # Package initialization and version
├── config.py # Configuration loading, defaults and validation
├── entity_standardization.py # Entity standardization and relationship inference
├── exports.py # CSV, GraphML and Cypher exports
├── query.py # Optional graph-chat command (question answering over the JSON)
├── server.py # Optional graph-serve command (local web interface)
├── llm.py # LLM client (retries, truncation detection, cache) and JSON extraction
├── main.py # CLI, input handling and pipeline orchestration
├── text_utils.py # Sentence splitting, chunking and provenance lookup
├── visualization.py # Graph metrics, communities and page rendering
├── prompts/ # LLM prompts (extraction, entity resolution, inference, community naming)
└── templates/
├── graph.html.j2 # The interactive explorer page (Jinja2)
├── library.html.j2 # Graph library + new-graph form for graph-serve
├── job.html.j2 # Generation progress page for graph-serve
├── landing.html.j2 # GitHub Pages landing page (scripts/build_docs.py)
└── vendor/ # Embedded vis-network library
max_tokens to 32k, set reasoning_effort = "low", use smaller chunks, or switch to a non-reasoning model. --continue-on-error skips the failed chunk instead of aborting.max_tokens or temperature: the client switches to max_completion_tokens automatically; remove the temperature line for models that only accept the default.pip install "ai-knowledge-graph[pdf]" (or [docx], [all]). Scanned PDFs have no text layer and need OCR first. Text encodings are detected automatically..kg-cache/ to start fresh.inference.enabled and use_llm_for_inference are on; small models sometimes name the same concept differently across chunks, and a lower temperature helps.187 followers · starred Jun 2025
133 followers · starred May 2026
299 followers · starred Aug 2026
337 followers · starred Apr 2025

This system takes an unstructured text document, and uses an LLM of your choice to extract knowledge in the form of Subject-Predicate-Object (SPO) triplets, and visualizes the relationships as an interactive knowledge graph. Optional companion commands let you ask the
graph questions (graph-chat) and run everything from a local web interface (graph-serve).
Live examples (no install needed): https://robert-mcdermott.github.io/ai-knowledge-graph/
| The Industrial Revolutions | Marie Curie | The Apollo Program |
| Coffee, from farm to cup | La Alhambra (Spanish) |
.txt, .md, .rst, .pdf and .docx files or whole directories, in any language (extraction.language); large documents are split on sentence boundaries with overlap and extracted in parallelgraph-chat command): grounded, cited answers from the generated graphgraph-serve command): ingest, browse, explore and ask questions in the browsermax_completion_tokens fallback, environment-variable API keys and an on-disk response cachenetworkx, jinja2, requests (installed automatically)[web] for graph-serve (FastAPI, uvicorn), [pdf] and [docx] for those input formats,
[all] for everythinggit clone https://github.com/robert-mcdermott/ai-knowledge-graph.git
cd ai-knowledge-graph
uv sync --extra web # or: pip install -e ".[web]"
uv sync creates the environment and the generate-graph, graph-chat and graph-serve commands; prefix
commands with uv run (as below) or activate the environment. With pip, drop the uv run prefix.
uv run graph-serve --config config.toml --graphs data/samples --open
This opens a library with the five sample graphs from data/samples/; click one to explore it. Browsing works
without a reachable model; the Ask panel and the New graph form use the model configured in
config.toml. To render a sample to a static page instead:
uv run generate-graph --from-json data/samples/marie-curie.json --output marie-curie.html
Edit config.toml (model name, endpoint, API key; see Configuration), then:
uv run generate-graph --input your_text_file.txt --output knowledge_graph.html
That writes knowledge_graph.html (a self-contained interactive page), knowledge_graph.json (the triples)
and knowledge_graph.meta.json (community names). Keep personal settings in a file git ignores, such as
config-working.toml, and pass it with --config. From a checkout without installing, python generate-graph.py
works the same way.
uv run graph-chat knowledge_graph.json "How did the steam engine change cities?"
uv sync --extra dev --extra web # or: pip install -e ".[dev,web]"
uv run pytest -q # 170 tests, no LLM needed
uv run ruff check .
python scripts/build_docs.py # rebuild the GitHub Pages site from data/samples
The system is configured with a TOML file (config.toml by default, or --config other.toml).
Every key except model and base_url is optional; the values shown are the defaults.
[llm]
model = "gemma3" # any model name your endpoint accepts
api_key = "sk-1234" # or "env:OPENAI_API_KEY" to read it from the environment
base_url = "http://localhost:11434/v1/chat/completions" # any OpenAI-compatible chat completions URL
max_tokens = 32768 # reasoning models need a large budget, see note below
temperature = 0.2 # omit for models that only accept the default (e.g. gpt-5)
#reasoning_effort = "low" # passed through to servers/models that support it
#timeout = 300 # seconds per request
#max_retries = 3 # retries on 429/5xx/connection errors
#token_param = "auto" # auto-switches to max_completion_tokens for newer OpenAI models
#json_mode = false # request response_format = json_object
#concurrency = 4 # chunks extracted in parallel
#cache_dir = ".kg-cache" # cache LLM replies; re-running the same input is free ("" disables, or --no-cache)
#[llm.extra_body] # arbitrary extra request fields, e.g. Ollama's think switch (keep last in [llm])
#think = false
#[llm.extra_headers] # extra HTTP headers, e.g. OpenRouter attribution
[extraction]
language = "auto" # e.g. "Chinese" to get entity names and predicates in Chinese
[chunking]
chunk_size = 500 # target words per chunk; chunks end on sentence boundaries
overlap = 50 # words of trailing sentences repeated in the next chunk
[standardization]
enabled = true # merge case / stop-word / plural variants of the same entity
use_llm_for_entities = true # extra LLM pass that groups remaining variants (one call)
#merge_word_subsets = false # aggressive merging by shared words ("steam engine factories" -> "steam engine")
[inference]
enabled = true # master switch
use_llm_for_inference = true # master switch for the LLM methods below
#llm_bridge = true # link every isolated component to the main graph
#max_bridge_components = 20 # ...for up to this many components, batched
#bridge_groups_per_call = 5
#llm_hub = true # well-known relationships among the most central entities
#hub_entities = 25
#hub_max_new = 25
#taxonomy = true # rule: "quantum computing" is a "computing" (deterministic)
apply_transitive = false # rule: A->B->C => A->C for transitive predicate families only
#transitive_predicate_groups = ["is a", "part of", "located in", "led to"]
#transitive_max_hub_degree = 10 # never chain through nodes with more connections than this
#transitive_max_per_subject = 5
#lexical = false # rule: "related to" edges for names sharing a word (noisy)
#lexical_min_word_length = 5
#max_inferred_ratio = 0.5 # cap inferred edges at this fraction of extracted edges
[query] # used by the optional graph-chat command
hops = 2 # neighbourhood radius around the entities a question mentions
max_triples = 150 # facts sent to the model per question
max_seed_entities = 8
history_turns = 3 # previous Q&A pairs kept for follow-up questions
[visualization]
edge_smooth = false # or "dynamic", "continuous", "curvedCW", ... (true = "continuous")
name_communities = true # ask the LLM for a short name per community (one call)
show_inferred = true # whether inferred (dashed) relationships are visible when the page opens
theme = "light" # initial theme: "light" or "dark"
edge_labels = "all" # initial edge label mode: "all", "selection" or "none"
title_case = true # show "Steam Engine" for the entity "steam engine" (ids are unchanged)
collapse_parallel_edges = true # draw one edge per node pair with a "+N" badge; all predicates stay in the tooltip
Rule-based inference (apply_transitive, lexical) is off by default because in testing it generated
roughly 70 % of all edges and hid the relationships actually found in the text. Local overrides such as
config-*.toml and config.local.toml are ignored by git.
# OpenAI
[llm]
model = "gpt-4.1-mini"
api_key = "env:OPENAI_API_KEY"
base_url = "https://api.openai.com/v1/chat/completions"
# Google Gemini (OpenAI-compatible endpoint)
[llm]
model = "gemini-2.5-flash"
api_key = "env:GEMINI_API_KEY"
base_url = "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions"
# LM Studio / vLLM / LiteLLM proxy (any OpenAI-compatible server)
[llm]
model = "your-model-name"
api_key = "not-needed"
base_url = "http://localhost:1234/v1/chat/completions"
Models such as DeepSeek, Qwen3, gpt-5 and the o-series "think" before they answer, and that
hidden reasoning is charged against max_tokens. With a small budget the model can spend
everything on reasoning and return no answer at all. The generator detects this
(finish_reason = "length") and aborts with an explanation instead of silently producing an
empty graph. Set max_tokens to 16k–32k for such models, lower reasoning_effort, use
smaller chunks, or pass --continue-on-error to skip failed chunks and accept an
incomplete graph.
--input PATH [PATH ...]: Input file(s) or directories. Plain text (.txt, .md, .rst), .pdf (install the [pdf] extra) and .docx ([docx] extra). With several inputs every triple is tagged with its document--output FILE: Output HTML file for visualization (default: knowledge_graph.html)--config FILE: Path to config file (default: config.toml)--debug / --verbose: Show debug output, including raw LLM responses and standardization merges--quiet: Only warnings and errors on the console (the final summary is still printed)--no-standardize: Disable entity standardization--no-inference: Disable relationship inference--continue-on-error: Skip chunks whose LLM call fails or is truncated instead of aborting--from-json FILE: Re-render the visualization from a previously saved .json triples file (no LLM calls)--no-cache: Bypass the LLM response cache for this run--library-path DIR: Reference the vis-network library from DIR (copied there if missing) instead of embedding it, so many pages can share one copy, for example on GitHub Pages--export FORMATS: Extra outputs next to the HTML, comma-separated: json (always), csv, graphml (Gephi, yEd, Cytoscape), cypher (Neo4j MERGE script)--test: Generate sample visualization using test datausage: generate-graph [-h] [--test] [--config CONFIG] [--output OUTPUT]
[--input PATH [PATH ...]] [--from-json FILE] [--debug]
[--quiet] [--no-standardize] [--no-inference]
[--continue-on-error] [--no-cache] [--export FORMATS]
[--library-path DIR]
Knowledge Graph Generator and Visualizer
options:
-h, --help show this help message and exit
--test Generate a test visualization with sample data
--config CONFIG Path to configuration file
--output OUTPUT Output HTML file path
--input PATH [PATH ...]
Input file(s) or directories: .txt/.md/.rst, .pdf
(needs [pdf] extra), .docx (needs [docx] extra).
Required unless --test or --from-json is used
--from-json FILE Render a visualization from a previously saved triples
JSON file (no LLM calls)
--debug, --verbose Enable debug output (raw LLM responses and extracted
JSON)
--quiet Only show warnings and errors
--no-standardize Disable entity standardization
--no-inference Disable relationship inference
--continue-on-error Skip chunks whose LLM call fails or is truncated
instead of aborting
--no-cache Do not read or write the LLM response cache
(llm.cache_dir)
--export FORMATS Comma-separated extra outputs written next to the
HTML: json (always), csv, graphml, cypher
--library-path DIR Reference the vis-network library from DIR (copied
there if missing) instead of embedding it; useful when
publishing many pages, e.g. on GitHub Pages
usage: graph-chat [-h] [--config CONFIG] [--json] [--no-facts] graph [question]
positional arguments:
graph Triples JSON written by generate-graph (e.g. knowledge_graph.json)
question Question to answer; omit for an interactive session
options:
--config CONFIG Path to configuration file (uses its [llm] section)
--json Print the result as JSON (single-question mode)
--no-facts Do not list the cited facts after the answer
usage: graph-serve [-h] [--config CONFIG] [--graphs DIR] [--host HOST] [--port PORT] [--open]
options:
--config CONFIG Path to configuration file (uses [llm], [query], [visualization])
--graphs DIR Directory containing the .json graphs written by generate-graph
--host HOST Bind address (default 127.0.0.1; the server has no authentication)
--port PORT Port (default 8008)
--open Open the library in your browser
Command:
generate-graph --input data/industrial-revolution.txt --output industrial-revolution-kg.html
Console Output (gemma4 via Ollama, about 20 seconds; --quiet reduces this to the summary):
Using input text from file: data/industrial-revolution.txt
==================================================
PHASE 1: INITIAL TRIPLE EXTRACTION
==================================================
Processing text in 3 chunks (size: 500 words, overlap: 50 words)
Extracting with 3 parallel requests
Processing chunk 1/3 (490 words)
Processing chunk 2/3 (495 words)
Processing chunk 3/3 (76 words)
Chunk 1: 58 triples
Chunk 2: 79 triples
Chunk 3: 15 triples
Extracted a total of 152 triples from all chunks
==================================================
PHASE 2: ENTITY STANDARDIZATION
==================================================
Starting with 152 triples and 154 unique entities
Standardizing entity names across all triples...
Applied LLM-based entity standardization for 12 entity groups
Removed 7 self-referencing triples
Standardized 154 entities into 152 standard forms
After standardization: 145 triples and 133 unique entities
==================================================
PHASE 3: RELATIONSHIP INFERENCE
==================================================
Starting with 145 triples
Top 5 relationship types before inference:
- enabled: 11 occurrences
- used in: 8 occurrences
- altered: 8 occurrences
- invented: 7 occurrences
- involved: 7 occurrences
Inferring additional relationships between entities...
Identified 11 disconnected components in the graph
LLM bridging proposed 11 relationships for 10 isolated components
LLM hub enrichment proposed 25 relationships among the 25 most central entities
Within-community LLM inference proposed 10 relationships
Taxonomy rule proposed 3 relationships
Transitive inference proposed 2 relationships (skipped 3 paths through hub nodes)
Accepted 48 inferred relationships (llm_bridge: 11, llm_hub: 23, llm_within: 10, taxonomy: 2, transitive: 2); dropped 3 already-connected pairs
Top 5 relationship types after inference:
- enabled: 18 occurrences
- is a: 15 occurrences
- used in: 8 occurrences
- altered: 8 occurrences
- invented: 7 occurrences
Added 48 inferred relationships
Final knowledge graph: 189 triples
Saved raw knowledge graph data to industrial-revolution-kg.json
Processing 189 triples for visualization
Found 133 unique nodes
Found 48 inferred relationships
Detected 13 communities using Louvain method
Named 13 communities
Knowledge graph visualization saved to industrial-revolution-kg.html
Saved community names to industrial-revolution-kg.meta.json
Knowledge Graph Statistics:
Nodes: 133
Edges: 189
Communities: 13
To view the visualization, open the following file in your browser:
file:///path/to/industrial-revolution-kg.html
generate-graph is unchanged: text in, static HTML (and JSON) out. The optional graph-chat command
reads that JSON and answers questions from it, using the same [llm] settings:
uv run graph-chat knowledge_graph.json "How did the steam engine change cities?"
uv run graph-chat knowledge_graph.json # interactive session; Ctrl-D or an empty line quits
uv run graph-chat data/samples/apollo-program.json "Who flew on Apollo 13?"
For each question it finds the entities the question mentions, retrieves the surrounding subgraph (and the
shortest paths between the mentioned entities), and asks the model to answer only from those facts. The
reply lists the facts it relied on, each marked extracted (with the source sentence) or inferred
(with the method), so answers stay traceable to the document. If the graph does not contain an answer it
says so. Tuning lives under [query] in the config (hops, max_triples, max_seed_entities,
history_turns); --json prints the result as JSON for scripting.
graph-serve is a small local server over the same code: it lists the graphs in a directory, opens each one in
the explorer, and adds an Ask panel that answers questions from the graph with cited facts (click a fact to
jump to it in the graph). It needs the [web] extra (uv sync --extra web or pip install -e ".[web]").
Start it with the sample graphs ready to use:
uv run graph-serve --config config.toml --graphs data/samples --open
or point it at the directory where you write your own graphs (any --config file works, e.g. your local
config-working.toml):
uv run graph-serve --config config-working.toml --graphs ./out --open
The library page also has a New graph form: paste text or upload files (.txt, .md, .rst, .pdf,
.docx, several at once), watch the phases run, and land in the explorer when it finishes. It runs the same
pipeline as generate-graph and writes the same .json and .html into the graphs directory, so the result is
usable from the command line and graph-chat too. One generation runs at a time.
It binds to 127.0.0.1:8008 by default, has no accounts or authentication, and only reads and writes the graphs
directory, so it is meant for your own machine. Static HTML output is unchanged: the chat panel only exists in
served pages. --host 0.0.0.0 exposes it on your network if you put your own access control in front.
data/samples/ contains five short texts and the graphs generated from them (.json triples plus a
.meta.json sidecar with the LLM-generated community names), so you can try everything without an LLM or
an API key. They are also the live examples on GitHub Pages.
| Sample | Domain | What it exercises | Live |
|---|---|---|---|
industrial-revolution.txt | technology history | the original demo text: technologies, people, eras | open |
marie-curie.txt | biography | people, places, dates, organizations, works | open |
apollo-program.txt | program history | events, missions, organizations, many people | open |
coffee-supply-chain.txt | process description | concepts, technologies, products, places | open |
la-alhambra.txt | Spanish text | extraction.language = "Spanish": entity names and predicates in Spanish | open |
uv run generate-graph --from-json data/samples/marie-curie.json --output marie-curie.html # renders instantly
uv run graph-serve --config config.toml --graphs data/samples --open # browse all five
uv run graph-chat data/samples/apollo-program.json "Who flew on Apollo 13?" # needs your LLM
The graphs were generated with deepseek-v4.1-flash (reasoning_effort = "low", temperature = 0.2,
use_llm_for_entities = false because that model reasons without end over long entity lists) and the default
inference settings. They double as regression fixtures: tests/test_samples.py checks that they stay
well-formed, typed, sourced and connected. Regenerate them when the extraction prompt changes, then rebuild the
GitHub Pages site with python scripts/build_docs.py (it writes docs/index.html, docs/samples/*.html and a
shared docs/vendor/ copy of vis-network, using --library-path style pages so each is about 100 KB).
Next to the HTML page the generator writes a JSON file with the same base name containing every triple, and a
.meta.json sidecar with the community names so that --from-json and graph-serve show them without an LLM:
{
"subject": "james watt",
"predicate": "refined",
"object": "steam engine",
"subject_type": "person",
"object_type": "technology",
"source": "A key catalyst of the First Industrial Revolution was the refinement of the steam engine by Scottish engineer James Watt, ...",
"chunk": 1
}
With --export graphml,cypher,csv the same graph is also written as GraphML (typed nodes with community
and degree, edges with provenance), an idempotent Neo4j Cypher script (:Entity plus a label per entity
type, one relationship type per predicate; run it with cypher-shell -f graph.cypher), and a flat CSV.
Inferred triples carry "inferred": true and a "method" (llm_bridge, llm_hub, llm_within,
taxonomy, transitive or lexical); transitive triples also record the intermediate node in "via".
The JSON can be re-rendered at any time with generate-graph --from-json graph.json --output graph.html
(or python json_to_html.py graph.json graph.html), and the page itself can export the visible triples as
JSON or CSV.
generate-graph loads and validates the configuration (defaults applied, env: API keys resolved), reads the
input files (text encodings detected; PDF and DOCX through optional readers), and runs the pipeline below.
--from-json skips straight to the visualization step, and --test renders a small built-in graph.
llm.concurrency) to extract typed Subject-Predicate-Object triplets, each tagged with the sentence it came fromstandardization.use_llm_for_entities, one call): the LLM reviews the most frequent entities and groups the ones that refer to the same concept (e.g., "AI", "artificial intelligence", "AI system")inference.enabled):
max_inferred_ratio × the number of extracted triples. Every inferred triple records its method..meta.json community names, and any --export formatsThe second and third passes and community naming are optional and can be disabled in the configuration to minimize LLM usage. LLM replies are cached on disk, so re-running the same document with the same settings makes no API calls.
The generated HTML is a single self-contained file (vis-network is embedded) that works offline; --library-path
switches to one shared library copy when you publish many pages.
visualization.name_communities = false)./), and shareable links: the selected node is kept in the URL (#node=...).visualization.collapse_parallel_edges).visualization.title_case).visualization.edge_labels); cycle to none or selection only, which keeps dense graphs readable. Around a selected node only its own edges are labelled.visualization.theme), keyboard shortcuts (? for the list), responsive layout..
├── config.toml # Main configuration file for the system
├── generate-graph.py # Run from a checkout without installing
├── json_to_html.py # Re-render a saved .json graph (same as --from-json)
├── pyproject.toml # Project metadata, dependencies, tool config
├── requirements.txt # Pinned dependencies for 'pip' users
├── uv.lock # Lock file for 'uv' users
├── IMPROVEMENT_PLAN.md # Roadmap / task list
├── .github/workflows/ci.yml # Lint + tests on Python 3.11-3.13
├── data/ # Sample input text and screenshot
│ └── samples/ # Sample corpus: texts + generated graphs (no LLM needed to explore)
├── docs/ # GitHub Pages site: landing page + sample graphs (built by scripts/build_docs.py)
├── scripts/build_docs.py # Rebuilds docs/ from data/samples
├── tests/ # pytest suite (runs without an LLM)
└── src/knowledge_graph/ # Core package (installed as `knowledge_graph`)
├── __init__.py # Package initialization and version
├── config.py # Configuration loading, defaults and validation
├── entity_standardization.py # Entity standardization and relationship inference
├── exports.py # CSV, GraphML and Cypher exports
├── query.py # Optional graph-chat command (question answering over the JSON)
├── server.py # Optional graph-serve command (local web interface)
├── llm.py # LLM client (retries, truncation detection, cache) and JSON extraction
├── main.py # CLI, input handling and pipeline orchestration
├── text_utils.py # Sentence splitting, chunking and provenance lookup
├── visualization.py # Graph metrics, communities and page rendering
├── prompts/ # LLM prompts (extraction, entity resolution, inference, community naming)
└── templates/
├── graph.html.j2 # The interactive explorer page (Jinja2)
├── library.html.j2 # Graph library + new-graph form for graph-serve
├── job.html.j2 # Generation progress page for graph-serve
├── landing.html.j2 # GitHub Pages landing page (scripts/build_docs.py)
└── vendor/ # Embedded vis-network library
max_tokens to 32k, set reasoning_effort = "low", use smaller chunks, or switch to a non-reasoning model. --continue-on-error skips the failed chunk instead of aborting.max_tokens or temperature: the client switches to max_completion_tokens automatically; remove the temperature line for models that only accept the default.pip install "ai-knowledge-graph[pdf]" (or [docx], [all]). Scanned PDFs have no text layer and need OCR first. Text encodings are detected automatically..kg-cache/ to start fresh.inference.enabled and use_llm_for_inference are on; small models sometimes name the same concept differently across chunks, and a lower temperature helps.187 followers · starred Jun 2025
133 followers · starred May 2026
299 followers · starred Aug 2026
337 followers · starred Apr 2025