DBpedia and Wikidata as Agent Tools
Two of the largest and most useful public knowledge graphs on the internet are DBpedia and Wikidata. Both are free to query, both use RDF as their data model, both speak SPARQL, and neither requires an API key. For a solo developer building anything that needs grounded factual data (people, places, organizations, dates, relationships), they are high-value data assets, and they compose beautifully with LangGraph agents. This chapter builds one small agent per KG and shows how to give the model SPARQL as a tool.
The example source code for this chapter is found in source-code/kg_agent.
I worked on a knowledge graph project at Google in 2013 after writing two books on the semantic web, linked data, and knowledge graphs.
The previous edition of this chapter used the old GPTSimpleVectorIndex / GPTTreeIndex classes from LlamaIndex to wrap SPARQL results in an embedding index and query them as text. That was a workable pattern in 2023, but both classes have long since been removed from LlamaIndex, and the modern replacement, a ReAct agent that calls SPARQL directly, is simpler and answers a wider range of questions. It is also faster: no embedding step, no index build, no round trip through a vector store. So, dear reader, this example was a LlamaIndex example in the old edition of this book and is a LangChain example in this new 2026 edition.
I am not going to teach SPARQL from scratch here. If you have never used it before, the short version is:
- RDF stores data as triples:
<subject> <predicate> <object>. - SPARQL queries look like SQL with pattern matching over triples:
SELECT ?x WHERE { ?x <predicate> <object> }finds every subject?xthat has the given predicate/object pair. - Every entity has a URI.
<http://dbpedia.org/resource/Germany>is the DBpedia URI for Germany. - Wikidata uses opaque QIDs instead of readable URIs: Q183 is Germany, Q80041 is Sedona, Arizona.
- Both endpoints have web query consoles: dbpedia.org/sparql and query.wikidata.org. Poking at them by hand is the fastest way to develop intuition.
For a deeper introduction, the “Linked Data, the Semantic Web, and Knowledge Graphs” chapter in my Hy Language book is free to read online at leanpub.com/hy-lisp-python/read. I have also written full books dedicated to SPARQL if the topic pulls you in.
The example
We define two agents, one per KG. Both live in source-code/kg_agent/. Each has two tools:
find_entity(name): search the KG by label and return candidate URIs (DBpedia) or QIDs (Wikidata).run_sparql(query): execute a SPARQL query and return the raw bindings as JSON.
The agent’s job is to first look up the entities its question mentions to get their identifiers, then write a SPARQL query using those identifiers, then execute it. Setup:
1 $ cd source-code/kg_agent
2 $ uv sync
3 $ ollama pull qwen3.5:4b
Both endpoints are public and require an internet connection but no credentials.
The DBpedia agent
We start with defining the DBPedia agent _dbpedia.py:
1 import json
2
3 from langchain_core.messages import SystemMessage
4 from langchain_core.tools import tool
5 from langchain_ollama import ChatOllama
6 from langgraph.prebuilt import create_react_agent
7 from SPARQLWrapper import JSON, SPARQLWrapper
8
9 ENDPOINT = "https://dbpedia.org/sparql"
10
11
12 def _query(sparql: str) -> list[dict]:
13 wrapper = SPARQLWrapper(ENDPOINT, agent="LangChain-book-example/1.0")
14 wrapper.setQuery(sparql)
15 wrapper.setReturnFormat(JSON)
16 return wrapper.query().convert()["results"]["bindings"]
17
18
19 @tool
20 def find_entity(name: str) -> str:
21 """Look up DBpedia entities whose English label matches `name`."""
22 sparql = f"""
23 SELECT DISTINCT ?s ?label ?comment WHERE {{
24 ?s rdfs:label {json.dumps(name)}@en .
25 OPTIONAL {{ ?s rdfs:comment ?comment . FILTER (lang(?comment) = 'en') }}
26 BIND ({json.dumps(name)} AS ?label)
27 }} LIMIT 5
28 """
29 try:
30 rows = _query(sparql)
31 except Exception as exc:
32 return f"Lookup failed: {exc}"
33
34 results = []
35 for row in rows:
36 results.append(
37 {
38 "uri": row["s"]["value"],
39 "label": row["label"]["value"],
40 "comment": row.get("comment", {}).get("value", ""),
41 }
42 )
43 return json.dumps(results, ensure_ascii=False)
44
45
46 @tool
47 def run_sparql(query: str) -> str:
48 """Run a SPARQL query against DBpedia."""
49 try:
50 rows = _query(query)
51 except Exception as exc:
52 return f"Query failed: {exc}"
53 return json.dumps(rows[:20], ensure_ascii=False)
54
55
56 SYSTEM_PROMPT = SystemMessage(
57 content=(
58 "You are an assistant that answers questions using DBpedia. "
59 "You have two tools:\n"
60 "- find_entity(name): search DBpedia for a URI matching an English label.\n"
61 "- run_sparql(query): execute a SPARQL query against DBpedia.\n\n"
62 "Procedure:\n"
63 "1. Identify the entities the question mentions.\n"
64 "2. Use find_entity on each entity to get its DBpedia URI.\n"
65 "3. Write a SPARQL query that uses those URIs to answer the question. "
66 "Wrap URIs in angle brackets.\n"
67 "4. Call run_sparql to execute the query.\n"
68 "5. Return a concise English answer using the query results.\n\n"
69 "Useful DBpedia prefixes are already known to the endpoint (dbo:, dbp:, dbr:, "
70 "rdfs:, foaf:). You do not need to declare them, but you can if you prefer."
71 )
72 )
73
74
75 def build_dbpedia_agent():
76 model = ChatOllama(model="qwen3.5:4b", temperature=0)
77 return create_react_agent(model, [find_entity, run_sparql], prompt=SYSTEM_PROMPT)
Three things to notice:
SPARQLWrapper handles the transport. It POSTs the query to the endpoint, requests JSON back, and parses the response. Setting the agent string is worth doing: the DBpedia endpoint occasionally rate-limits requests that use its default user-agent, and a custom one is polite besides.
find_entity uses json.dumps(name) to escape the search string. SPARQL string literals use quotes and are vulnerable to injection the same way SQL strings are. json.dumps produces a properly-escaped, double-quoted string, and SPARQL accepts JSON-style string literals.
run_sparql truncates results to 20 rows. The DBpedia endpoint can return thousands of rows for a broad query, which would blow past the model’s context window. Twenty is a compromise: enough to answer most questions, small enough that the model can inspect the results directly.
The driver script, 01_dbpedia_agent.py is short:
1 from langchain_core.messages import HumanMessage
2 from _dbpedia import build_dbpedia_agent
3
4 agent = build_dbpedia_agent()
5
6 question = "What countries border Germany?"
7
8 print(f"USER: {question}")
9
10 result = agent.invoke(
11 {"messages": [HumanMessage(content=question)]},
12 config={"recursion_limit": 30},
13 )
14
15 print(f"AGENT: {result['messages'][-1].content.strip()}")
Representative output:
1 $ uv run 01_dbpedia_agent.py
2 USER: What countries border Germany?
3 AGENT: Germany borders Denmark, Poland, the Czech Republic, Austria, Switzerland,
4 France, Luxembourg, Belgium, and the Netherlands.
Under the hood the agent has called find_entity("Germany") to get <http://dbpedia.org/resource/Germany>, then written a query along the lines of:
1 SELECT DISTINCT ?country ?label WHERE {
2 <http://dbpedia.org/resource/Germany> dbo:borders ?country .
3 ?country rdfs:label ?label .
4 FILTER (lang(?label) = 'en')
5 }
then executed it and summarized the results. Add .stream() from the “Building a ReAct Agent with LangGraph + Ollama” chapter’s streaming example if you want to watch the intermediate tool calls.
The Wikidata agent
Wikidata uses opaque QIDs instead of readable URIs, and its query patterns look slightly different. _wikidata.py is the same shape, with two differences worth calling out.
Entity search uses the wikibase:mwapi SERVICE. Wikidata’s SPARQL endpoint has direct access to the MediaWiki API through a special SERVICE block. This is much better than raw rdfs:label matching because it handles fuzzy search, redirects, and multiple languages transparently. The relevant SPARQL:
1 SELECT ?item ?itemLabel ?itemDescription WHERE {
2 SERVICE wikibase:mwapi {
3 bd:serviceParam wikibase:api "EntitySearch" .
4 bd:serviceParam wikibase:endpoint "www.wikidata.org" .
5 bd:serviceParam mwapi:search "Bill Clinton" .
6 bd:serviceParam mwapi:language "en" .
7 ?item wikibase:apiOutputItem mwapi:item .
8 }
9 SERVICE wikibase:label { bd:serviceParam wikibase:language "en". }
10 } LIMIT 5
The wikibase:label service at the end automatically populates ?itemLabel and ?itemDescription in English.
The system prompt calls out common properties. Wikidata’s properties are numbered (P31, P569, …) and there is no way for a model to guess them without looking them up. The prompt seeds the agent with the ones it is likely to need:
wdt:P31: instance ofwdt:P39: position heldwdt:P580/wdt:P582: start / end timewdt:P17: countrywdt:P569: date of birthwdt:P106: occupation
You could instead give the agent a third tool for looking up property IDs by name (the EntitySearch API returns properties as well as entities), and I have tried versions of this agent both ways. Baking common properties into the prompt keeps this example short; a real project would probably want the extra tool.
Here is the complete source code for _wikidata.py:
1 """A Wikidata SPARQL tool set and a ReAct agent built around it.
2
3 Same shape as `_dbpedia.py`, different endpoint and URI style.
4
5 Wikidata uses opaque QIDs (Q80041 for Sedona, Arizona) rather than
6 human-readable URIs, and its property URIs live under wdt: (e.g. wdt:P31
7 for "instance of"). The entity-search tool uses Wikidata's own search API
8 (SERVICE wikibase:mwapi) which handles fuzzy matching much better than
9 raw rdfs:label matching.
10 """
11
12 import json
13
14 from langchain_core.messages import SystemMessage
15 from langchain_core.tools import tool
16 from langchain_ollama import ChatOllama
17 from langgraph.prebuilt import create_react_agent
18 from SPARQLWrapper import JSON, SPARQLWrapper
19
20 ENDPOINT = "https://query.wikidata.org/sparql"
21
22
23 def _query(sparql: str) -> list[dict]:
24 wrapper = SPARQLWrapper(ENDPOINT, agent="LangChain-book-example/1.0")
25 wrapper.setQuery(sparql)
26 wrapper.setReturnFormat(JSON)
27 return wrapper.query().convert()["results"]["bindings"]
28
29
30 @tool
31 def find_entity(name: str) -> str:
32 """Look up Wikidata entities whose label matches `name` (fuzzy search).
33
34 Returns up to five candidate results as a JSON list of
35 {qid, label, description} objects. The `qid` (e.g. "Q80041") is the
36 piece to use in SPARQL — write it as wd:Q80041.
37 """
38 escaped = json.dumps(name)
39 sparql = f"""
40 SELECT ?item ?itemLabel ?itemDescription WHERE {{
41 SERVICE wikibase:mwapi {{
42 bd:serviceParam wikibase:api "EntitySearch" .
43 bd:serviceParam wikibase:endpoint "www.wikidata.org" .
44 bd:serviceParam mwapi:search {escaped} .
45 bd:serviceParam mwapi:language "en" .
46 ?item wikibase:apiOutputItem mwapi:item .
47 }}
48 SERVICE wikibase:label {{ bd:serviceParam wikibase:language "en". }}
49 }} LIMIT 5
50 """
51 try:
52 rows = _query(sparql)
53 except Exception as exc:
54 return f"Lookup failed: {exc}"
55
56 results = []
57 for row in rows:
58 uri = row["item"]["value"]
59 qid = uri.rsplit("/", 1)[-1]
60 results.append(
61 {
62 "qid": qid,
63 "label": row.get("itemLabel", {}).get("value", ""),
64 "description": row.get("itemDescription", {}).get("value", ""),
65 }
66 )
67 return json.dumps(results, ensure_ascii=False)
68
69
70 @tool
71 def run_sparql(query: str) -> str:
72 """Run a SPARQL query against Wikidata. Returns the raw bindings as JSON."""
73 try:
74 rows = _query(query)
75 except Exception as exc:
76 return f"Query failed: {exc}"
77 return json.dumps(rows[:20], ensure_ascii=False)
78
79
80 SYSTEM_PROMPT = SystemMessage(
81 content=(
82 "You are an assistant that answers questions using Wikidata. "
83 "You have two tools:\n"
84 "- find_entity(name): fuzzy-search Wikidata for a QID matching an English label.\n"
85 "- run_sparql(query): execute a SPARQL query against Wikidata.\n\n"
86 "Procedure:\n"
87 "1. Identify the entities the question mentions.\n"
88 "2. Use find_entity on each entity to get its QID (like Q80041).\n"
89 "3. Write a SPARQL query that references entities as wd:QID and "
90 "properties as wdt:PID.\n"
91 "4. Call run_sparql to execute the query.\n"
92 "5. Return a concise English answer using the query results.\n\n"
93 "Include this label helper at the end of any SELECT so labels come back "
94 "in English:\n"
95 " SERVICE wikibase:label { bd:serviceParam wikibase:language 'en'. }\n"
96 "Common properties: wdt:P31 (instance of), wdt:P39 (position held), "
97 "wdt:P580 (start time), wdt:P582 (end time), wdt:P17 (country), "
98 "wdt:P569 (date of birth), wdt:P106 (occupation)."
99 )
100 )
101
102
103 def build_wikidata_agent():
104 model = ChatOllama(model="qwen3.5:4b", temperature=0)
105 return create_react_agent(model, [find_entity, run_sparql], prompt=SYSTEM_PROMPT)
The second example using the WikiData agent is found in file 02_wikidata_agent.py:
1 from langchain_core.messages import HumanMessage
2 from _wikidata import build_wikidata_agent
3
4 agent = build_wikidata_agent()
5
6 question = "When was Bill Clinton president of the United States?"
7
8 print(f"USER: {question}")
9
10 result = agent.invoke(
11 {"messages": [HumanMessage(content=question)]},
12 config={"recursion_limit": 30},
13 )
14
15 print(f"AGENT: {result['messages'][-1].content.strip()}")
A representative run:
1 $ uv run 02_wikidata_agent.py
2 USER: When was Bill Clinton president of the United States?
3 AGENT: Bill Clinton was president of the United States from January 20, 1993
4 to January 20, 2001.
The agent has looked up Bill Clinton’s QID (Q1124), queried his positions held with wdt:P39, filtered to the presidency, and pulled the start and end dates via wdt:P580 and wdt:P582.
DBpedia versus Wikidata
Which one to reach for depends on the question.
- DBpedia is easier to explore because URIs are readable (
dbr:Germanyinstead ofwd:Q183) and its property names are English words (dbo:bordersinstead ofwdt:P47). Great for prototyping and for questions that mostly involve English-language Western topics. - Wikidata has broader coverage, more languages, and more reliable up-to-date data because its edits are curated. Its query patterns are more verbose but its data is generally cleaner.
In practice I use DBpedia when I am writing a query interactively (its readable URIs are nicer to reason about) and Wikidata when I need coverage or freshness. An agent can be given tools for both; if a query needs it; you would just add both tool sets to a single create_react_agent call.
Where to take this next
Everything you learned in Chapters “Building a ReAct Agent with LangGraph + Ollama” through “Multi-Agent Supervisor Pattern” composes with these KG agents:
- Add a checkpointer (Chapter “Durable, Restart-Safe Agents”) for follow-up questions across turns. “Which of those countries has the largest population?” makes sense as a second turn only if the agent remembers the first answer.
- Add an approval interrupt (Chapter “Human-in-the-Loop Patterns”) if the KG could ever return sensitive information you want a human to see before it goes to the user.
- Add these tools to a supervisor graph (Chapter “Multi-Agent Supervisor Pattern”) as a “facts specialist” alongside your other specialists. This is the pattern I use most often: the KG agent is one of several specialists a supervisor can call on for grounded factual data.
What we covered
- DBpedia and Wikidata are large public knowledge graphs with SPARQL endpoints and no API keys.
- Giving a LangGraph agent two tools (entity lookup and SPARQL execution) turns SPARQL into an accessible skill for an LLM that does not know SPARQL by heart.
- DBpedia is friendlier to prototype against; Wikidata has better coverage and cleaner data.
- The pattern is identical for both KGs and composes with checkpointers, HITL, and supervisor graphs from earlier chapters.
The next chapter “A Perplexity-Style Local Search Agent” wraps up Part I, tying together web search, RAG, and multi-step reasoning.