Semantic Web Question Answering with SPARQL and Large Language Models
The Resource Description Framework (RDF) is the data model that underpins the Semantic Web. In RDF, every fact is expressed as a triple of subject, predicate, and object, where the subject and object are nodes in a graph and the predicate is a typed, named edge. Because every entity and every relationship is identified by a globally unique URI, data published by different organizations can be merged into a single shared graph without ambiguity. This graph-based foundation is what distinguishes RDF from ordinary tables or JSON documents: relationships are first-class citizens, and any two datasets that share a URI can be joined seamlessly.
One of the great advantages of RDF is that several large knowledge bases are published as public SPARQL endpoints, meaning anyone with an HTTP client can query them free of charge and without registration. DBpedia exposes http://dbpedia.org/sparql, and Wikidata exposes https://query.wikidata.org/sparql; both return results in the same standardized JSON format, so the same client code can talk to either endpoint. This means that a developer can, in a handful of lines of Python, ask structured questions about millions of people, places, organizations, and concepts curated by the Wikipedia and Wikimedia communities with no API keys, no licensing fees, and no local data storage required.
Public SPARQL endpoints also compose naturally. Because DBpedia and Wikidata both speak SPARQL and both model their data as RDF, a program can query both endpoints for the same entities and merge the results into a single context for downstream processing. Each source contributes different strengths: DBpedia carries richer infobox-derived product lists and financial figures, while Wikidata provides more comprehensive subsidiary and leadership relationships—so combining them yields a fuller picture than either alone. The remainder of this chapter shows exactly how to build such a system, pairing these public endpoints with a large language model that turns structured triples into natural-language answers.
A Live Example to Motivate the Reader
Before we dig into the example code implementation, let us see what the finished programs actually do. We have three scripts that all answer the same question but draw on different knowledge bases.
DBpedia
1 uv run DBPedia.py "tell me about the following: IBM, Microsoft"
1 Answer:
2 Based on the provided knowledge, here is a detailed overview of IBM
3 and Microsoft.
4
5 IBM (International Business Machines) is an American multinational
6 technology corporation and a public company in the information
7 technology industry. It was founded on June 16, 1911, by Charles
8 Ranlett Flint, George Winthrop Fairchild, and Herman Hollerith. Key
9 persons include Arvind Krishna and Gary Cohn. IBM's products span
10 Artificial intelligence, Cloud computing, Computer hardware, Quantum
11 computing, Robotics, Software, Automation, and Blockchain. Its
12 services include Professional services, Outsourcing, and Managed
13 services. Financial figures: revenue of approximately $62.73 billion,
14 net income of $6.023 billion, and 270,300 employees.
15
16 Microsoft is an American multinational technology corporation and a
17 public company in the information technology industry, headquartered
18 in Redmond, Washington. It was founded on April 4, 1975, by Bill
19 Gates and Paul Allen. Microsoft's products include Cloud computing,
20 Computer hardware, Consumer electronics, and Software development.
21 Its services include GitHub, LinkedIn, Microsoft 365, Microsoft Azure,
22 Microsoft Bing, Microsoft Edge, Microsoft Outlook, OneDrive, Xbox Game
23 Pass, and Xbox network. Subsidiaries include GitHub and LinkedIn.
24 Financial figures: net income of approximately $101.8 billion, and
25 228,000 employees.
Wikidata
1 uv run Wikidata.py "tell me about the following: IBM, Microsoft"
1 Answer:
2 Based on the provided Wikidata knowledge, here is a detailed overview
3 of IBM and Microsoft.
4
5 IBM (Q37156): American multinational technology corporation.
6 - Country: United States
7 - Inception: June 16, 1911
8 - Founders: Charles Ranlett Flint and Thomas John Watson, Sr.
9 - Headquarters location: Armonk
10 - Chief Executive Officer: Arvind Krishna
11 - Chairpersons: Ginni Rometty and John Fellows Akers
12 - Net profit: 6,023,000,000 (USD)
13 - Industries: computer industry, computer hardware industry, IT
14 service management, information technology consulting, software
15 - Child organizations: Red Hat, Rational Software, IBM Canada, IBM
16 Research, IBM India, IBM Israel, The Weather Company, SoftLayer,
17 Lotus Software, and others
18
19 Microsoft (Q2283): American multinational technology corporation.
20 - Country: United States
21 - Chief Executive Officer: Satya Nadella
22 - Net profit: 88,136,000,000 and 101,832,000,000 (different years)
23 - Product or material produced: Microsoft Mobile
24 - Child organizations: LinkedIn, Nuance Communications, Activision
25 Blizzard, ZeniMax Media, Skype Technologies, Turn 10 Studios,
26 Avanade, aQuantive, and others
27
28 Both corporations are American multinationals with extensive global
29 operations, numerous subsidiaries, and significant financial
30 performance. IBM was founded earlier (1911) and has a broader range
31 of industrial classifications, while Microsoft is known for its
32 software and recent acquisitions in gaming, social media, and AI.
Combined DBpedia + Wikidata
1 uv run DBPedia_and_Wikidata.py "tell me about the following: IBM, Microsoft"
1 Answer:
2 Based on the provided knowledge from DBpedia and Wikidata, here is a
3 detailed overview of IBM and Microsoft.
4
5 IBM (International Business Machines Corporation)
6 - Type: Public company (American multinational technology corporation)
7 - Industry: Information technology, computer industry, computer
8 hardware industry, IT service management, software industry
9 - Founded: June 16, 1911
10 - Founders: Charles Ranlett Flint, George Winthrop Fairchild, Herman
11 Hollerith, Thomas John Watson Sr.
12 - Headquarters: Armonk, United States
13 - Key People: Arvind Krishna (CEO), Ginni Rometty (chairperson),
14 John Fellows Akers (former chairperson)
15 - Products: AI, Cloud computing, Computer hardware, Quantum computing,
16 Robotics, Software, Automation, Blockchain
17 - Services: Professional services, Outsourcing, Managed services
18 - Subsidiaries: Red Hat, Rational Software, IBM Canada, IBM Research,
19 IBM Israel, The Weather Company, SoftLayer, Lotus Software
20 - Revenue: $62.73 billion | Net income: $6.023 billion
21 - Employees: 270,300
22
23 Microsoft Corporation
24 - Type: Public company (American multinational technology corporation)
25 - Industry: Information technology
26 - Founded: April 4, 1975
27 - Founders: Bill Gates, Paul Allen
28 - Headquarters: Redmond, Washington, United States
29 - CEO: Satya Nadella
30 - Products: Cloud computing, Computer hardware, Consumer electronics,
31 Software development, Video game industry
32 - Services: GitHub, LinkedIn, Microsoft 365, Microsoft Azure, Microsoft
33 Bing, Microsoft Dynamics, Microsoft Edge, OneDrive, Xbox Game Pass
34 - Subsidiaries: GitHub, LinkedIn, Nuance Communications, Activision
35 Blizzard, ZeniMax Media, Skype Technologies
36 - Revenue: $281.7 billion | Net income: $101.8 billion
37 - Employees: 228,000
38
39 The DBpedia source provides richer financial figures and product
40 lists, while Wikidata adds more subsidiaries and leadership details.
41 Together they give a fuller picture than either source alone.
That last example is the key insight of this chapter: because both DBpedia and Wikidata use the same RDF data model, a client program can query both and merge the results. The LLM then synthesizes a single answer that draws on the strengths of each source.
What This Program Does
The code in this directory is split across four files:
| File | Purpose |
|---|---|
library.py |
Shared utilities: LLM client, entity extraction, answer synthesis, SPARQL query execution, CLI helper |
DBPedia.py |
DBpedia-specific templates, property mappings, enrichment, and answer pipeline |
Wikidata.py |
Wikidata-specific templates, property mappings, enrichment, and answer pipeline |
DBPedia_and_Wikidata.py |
Federated example that queries both knowledge bases and merges results |
Each of the three example scripts implements the same four-stage pipeline:
- Entity extraction - An LLM identifies named entities in the question and classifies them by type (person, organization, place, or misc).
- Relationship detection - A keyword-matching layer checks whether the question asks about a specific relationship such as “capital of” or “married to.”
- SPARQL retrieval - The program constructs and executes SPARQL queries against one or more knowledge bases, gathering descriptions and structured facts.
- Answer synthesis - An LLM synthesizes the retrieved facts into a natural-language answer.
The shared library.py handles the parts that are identical across all three scripts: the Fireworks.ai LLM client, the entity-extraction prompt, the answer-synthesis prompt, and the SPARQL HTTP transport. The KB-specific scripts handle the parts that differ: SPARQL query templates, property mappings, and enrichment logic.
The Semantic Web and RDF
The Resource Description Framework (RDF) is the data model underlying the Semantic Web. In RDF, every piece of knowledge is a triple:
1 subject predicate object
The subject and object are nodes in a graph (entities), and the predicate is a typed edge (a relationship). For example, the statement “Paris is the capital of France” becomes the triple:
1 <http://dbpedia.org/resource/Paris>
2 <http://dbpedia.org/ontology/capital>
3 <http://dbpedia.org/resource/France>
Each URI is a globally unique identifier. The predicate URI http://dbpedia.org/ontology/capital is not just a label; it is a defined property in the DBpedia ontology with a documented meaning. Because every entity and every relationship has a URI, data from different sources can be merged unambiguously into a single graph.
DBpedia and Wikidata: Two RDF Knowledge Bases
DBpedia is a community project that extracts structured data from Wikipedia infoboxes. It uses human-readable URIs like http://dbpedia.org/resource/IBM for entities and http://dbpedia.org/ontology/foundingDate for properties. The DBpedia ontology defines classes (Person, Organisation, Place) and properties (birthDate, foundedBy, capital) in a way that mirrors common-sense categories.
Wikidata is the structured-data sister project of Wikipedia, maintained by the Wikimedia Foundation. It uses opaque identifiers: entities are Q-numbers (Q2283 for Microsoft) and properties are P-numbers (P159 for headquarters location). This design allows Wikidata to be language-neutral and to support claims with qualifiers, references, and ranks, but it makes queries less readable without label resolution.
Both knowledge bases expose public SPARQL endpoints:
| Knowledge base | SPARQL endpoint |
|---|---|
| DBpedia | http://dbpedia.org/sparql |
| Wikidata | https://query.wikidata.org/sparql |
Because both speak SPARQL and return results in the same JSON format, the same client code can query either one.
Literals and Language Tags
Objects in RDF triples can be URIs (links to other entities) or literals (plain values such as strings, numbers, or dates). String literals can carry a language tag. For example, the short description of IBM in DBpedia is stored as:
1 "American multinational technology corporation"@en
The @en suffix marks the string as English. Both DBpedia and Wikidata contain descriptions in many languages, so SPARQL queries almost always include a language filter to avoid mixing languages in the results.
SPARQL Query Language
SPARQL (SPARQL Protocol and RDF Query Language) is the standard query language for RDF graphs. A SPARQL query matches graph patterns using variables prefixed with ?. Here is a minimal example that finds the English label of any entity whose capital is France:
1 SELECT ?s WHERE {
2 ?s <http://dbpedia.org/ontology/capital> <http://dbpedia.org/resource/France> .
3 ?s <http://www.w3.org/2000/01/rdf-schema#label> ?label .
4 FILTER(lang(?label) = 'en')
5 }
The SELECT clause lists which variables to return. The block inside { } is the graph pattern: one or more triple patterns that the data must match. The FILTER function constrains results, in this case requiring the label to be in English.
Key SPARQL Features Used in This Program
The queries in these scripts use several SPARQL features worth understanding:
VALUES enumerates a set of allowed values for a variable. Instead of writing three separate triple patterns for three predicates, we write one pattern and constrain the predicate variable:
1 ?s ?p ?comment .
2 VALUES ?p {
3 <http://dbpedia.org/ontology/abstract>
4 <http://www.w3.org/2000/01/rdf-schema#comment>
5 <http://dbpedia.org/ontology/description>
6 }
A powerful variant uses two-variable VALUES to pair a URI with a literal label in each row. The enrichment queries use this to carry a human-readable property name alongside each property URI:
1 VALUES (?prop ?propLabel) {
2 (<http://dbpedia.org/ontology/foundingDate> "foundingDate")
3 (<http://dbpedia.org/ontology/industry> "industry")
4 }
When the query matches a triple ?s dbo:foundingDate ?obj, the variable ?propLabel is automatically bound to the string "foundingDate".
OPTIONAL specifies a pattern that may or may not match. If the optional pattern fails, the rest of the query still returns results, with the optional variables unbound. We use this so that an rdf:type constraint does not eliminate entities whose type does not perfectly match our expectation.
FILTER applies a boolean condition to each candidate row. We use it for language filtering (lang(?comment) = 'en'), for excluding Wikipedia category pages (!CONTAINS(STR(?s), 'dbpedia.org/resource/Category:')), and for requiring URI-valued objects (isURI(?obj)).
ORDER BY sorts results. We sort by ?p so that the preferred predicate (the full abstract) appears first in the results, and the shorter description fields come later as fallback.
LIMIT caps the number of rows returned, which keeps queries fast and prevents oversized responses.
The Wikidata Label Service
Wikidata queries often use the wikibase:label service to resolve Q-numbers to human-readable labels. However, our enrichment queries use a simpler approach: an OPTIONAL block that fetches rdfs:label for each object value. This works with any SPARQL endpoint and does not require Wikidata-specific extensions.
The Shared Library: library.py
The library.py module contains all the code that is identical across the three example scripts. Let us walk through each piece.
Fireworks.ai Client Setup
The program uses the OpenAI Python SDK to talk to Fireworks.ai, which provides an OpenAI-compatible API. This means we can use the familiar client.chat.completions.create interface with a different base_url:
1 import os
2 import json
3 import re
4 import requests
5 from openai import OpenAI
6
7 client = OpenAI(
8 base_url="https://api.fireworks.ai/inference/v1",
9 api_key=os.getenv("FIREWORKS_API_KEY"),
10 )
11
12 MODEL_ID = "accounts/fireworks/models/deepseek-v4-flash"
The API key is read from the FIREWORKS_API_KEY environment variable. If it is not set, the client receives None, and the first LLM call raises an authentication error. The MODEL_ID constant identifies which model to use; we picked deepseek-v4-flash for its speed and low cost, which matters because the program makes up to three LLM calls per question.
A descriptive User-Agent string is defined so that SPARQL endpoints (especially Wikidata) do not reject our queries as unidentified bot traffic:
1 _USER_AGENT = (
2 "PythonAIBook/1.0 (educational project; "
3 "+https://github.com/markw/PythonAIBook)"
4 )
The llm_complete Helper
Every LLM call in the program follows the same pattern, so we factor it into a single helper:
1 def llm_complete(prompt: str, max_tokens: int = 3000,
2 temperature: float = 0) -> str:
3 """Send a single user message to the Fireworks.ai LLM and return the text."""
4 response = client.chat.completions.create(
5 model=MODEL_ID,
6 messages=[{"role": "user", "content": prompt}],
7 max_tokens=max_tokens,
8 temperature=temperature,
9 )
10 return response.choices[0].message.content.strip()
The temperature=0 default makes the model deterministic, which is important for entity extraction: we want the same question to produce the same entities every time. Callers that want more creative output (such as answer synthesis) can override the temperature.
Entity Extraction
Natural-language questions mention entities by name: “Bill Gates,” “Paris,” “IBM.” Before we can query any knowledge base, we need to identify these entities and classify them by type. The extract_entities function uses an LLM with a one-shot prompt to do this in a single API call.
A one-shot prompt gives the model one example before asking it to perform the task on new input. This anchors the model on the desired output format:
1 def extract_entities(text: str) -> dict:
2 one_shot_prompt = """You are an expert named-entity recognition system.
3
4 Identify the named entities in the user's text and classify each one
5 into exactly one of these types:
6 - PERSON (people: individuals, fictional characters)
7 - ORG (organizations: companies, bands, teams, government bodies)
8 - GPE (geo-political entities: countries, states, cities, regions)
9 - MISC (anything else worth looking up: scientific fields, products,
10 works of art, concepts)
11
12 Return ONLY a JSON object of the form {"TYPE": ["Name", ...]}.
13 Omit types with no entities. No prose, no markdown fences.
14
15 # Example
16 Text: "Where does Bill Gates work and what is the population of Paris?"
17 Output: {"PERSON": ["Bill Gates"], "GPE": ["Paris"]}
18
19 # Now classify this text
20 Text: "{text}"
21 Output: """
Several design choices in this prompt are worth noting:
- Role assignment (“You are an expert named-entity recognition system”) primes the model to adopt the persona of a specialist system.
- Explicit type definitions with parenthetical examples prevent the model from guessing what each type means.
- Format constraint (“Return ONLY a JSON object”) tells the model we want structured output, not prose.
- A worked example shows the exact input/output shape, which is more reliable than describing the format abstractly.
- Negative instructions (“No prose, no markdown fences”) anticipate and suppress common failure modes.
The {text} placeholder is replaced with the user’s actual question before the prompt is sent to llm_complete.
Parsing and Normalizing the LLM Output
Even with instructions to return only JSON, LLMs sometimes wrap their output in markdown code fences. The code handles this with a regex fallback:
1 try:
2 raw = llm_complete(
3 one_shot_prompt.replace("{text}", text),
4 max_tokens=3000,
5 temperature=0,
6 )
7 # Strip markdown code fences if the model wrapped its output
8 fence_match = re.search(r"```(?:json)?\s*(.*?)```", raw, re.DOTALL)
9 if fence_match:
10 raw = fence_match.group(1).strip()
11 entities = json.loads(raw)
12 # Normalize: ensure values are lists of strings
13 cleaned = {}
14 for etype, names in entities.items():
15 if isinstance(names, str):
16 names = [names]
17 cleaned[etype] = [str(n) for n in names]
18 return cleaned
19 except Exception as e:
20 print(f"[Warning] LLM entity extraction failed: {e}")
21 return {}
The regex r"```(?:json)?\s*(.*?)```" matches a fenced code block with an optional json language tag. re.DOTALL lets . match newlines so the fence can span multiple lines. If a fence is found, we extract its inner content; otherwise we parse the raw string directly.
After parsing, the normalization loop converts single strings to one-element lists (the model might return {"PERSON": "Bill Gates"} instead of {"PERSON": ["Bill Gates"]}) and coerces all names to strings. The broad except clause ensures that a malformed LLM response does not crash the program; it returns an empty dict, and the pipeline continues with no entities.
Answer Synthesis
The synthesize_answer function sends the question and the retrieved context to the LLM, asking it to produce a natural-language answer. It is shared by all three scripts:
1 def synthesize_answer(question: str, context: str,
2 source_label: str = "the knowledge base") -> str:
3 short_context = context[:3000] if len(context) > 3000 else context
4 prompt = (
5 f'Given question: "{question}"\n\n'
6 f"Knowledge from {source_label} about relevant entities:\n"
7 f"{short_context}\n\n"
8 "Please answer the question based on this knowledge. "
9 "Be specific, address every entity the user asked about, and "
10 "cite entity names where possible."
11 )
12 try:
13 return llm_complete(prompt, max_tokens=3500)
14 except Exception as e:
15 print(f"[Warning] LLM synthesis failed: {e}")
16 return ""
The prompt has three parts: the original question, the retrieved knowledge, and instructions on how to answer. The instruction to “address every entity the user asked about” is critical for multi-entity questions like “tell me about IBM, Microsoft.” Without it, the model might focus on only the first entity and ignore the rest.
The source_label parameter lets each script name its source: “DBpedia”, “Wikidata”, or “DBpedia and Wikidata”. The context is truncated to 3000 characters to stay within the model’s context window and control cost.
SPARQL Query Execution
All SPARQL queries go through a single function that uses the requests library to send the query over HTTP:
1 def query_sparql(endpoint: str, sparql_query: str) -> list[dict]:
2 print(f"SPARQL query ({endpoint}):\n{sparql_query}")
3 resp = requests.get(
4 endpoint,
5 params={"query": sparql_query, "format": "json"},
6 headers={
7 "User-Agent": _USER_AGENT,
8 "Accept": "application/sparql-results+json",
9 },
10 timeout=60,
11 )
12 resp.raise_for_status()
13 ret = resp.json()["results"]["bindings"]
14 print(f"Results: {len(ret)} bindings\n")
15 return ret
The function is endpoint-agnostic: it works with DBpedia, Wikidata, or any other SPARQL 1.1 endpoint that supports JSON results. The User-Agent header is especially important for Wikidata, which rejects requests without one. The format=json query parameter tells the endpoint to return results in the SPARQL 1.1 JSON Results format.
The JSON Results format represents each value as a dict with a type (uri, literal, typed-literal, or bnode) and a value. For literals, an optional xml:lang key carries the language tag. The rest of the code accesses values with patterns like result["comment"]["value"] and result["objLabel"]["value"].
Relationship Detection
Many questions ask about a specific relationship: “What is the capital of France?” “Where was Bill Gates born?” The detect_relationship function is generic: it takes the question and a caller-supplied mapping table, and returns the matching property URI and verb:
1 def detect_relationship(question: str,
2 relationship_table: dict) -> tuple[str, str] | None:
3 lower = question.lower()
4 for key in sorted(relationship_table, key=len, reverse=True):
5 if key in lower:
6 uri, verb = relationship_table[key]
7 return uri, verb
8 return None
The keys are sorted by descending length so that multi-word phrases are checked before their single-word sub-phrases. Without this, the question “What is the capital of France?” would match “capital” before “capital of,” and while both map to the same URI in this case, the ordering matters for phrases where a shorter key maps to a different property than the longer one.
Value Resolution Helper
Both DBpedia and Wikidata return values that can be either literals (dates, numbers) or URIs (links to other entities). The resolve_value helper converts any value to a human-readable string:
1 def resolve_value(obj: str, obj_label: str | None) -> str:
2 if obj_label:
3 return obj_label
4 if obj.startswith("http"):
5 return obj.rsplit("/", 1)[-1].replace("_", " ")
6 return obj
If the SPARQL query resolved the object to an English label (via an OPTIONAL block), we use that. Otherwise, if the object is a URI, we extract the last path segment and replace underscores with spaces (for example, Information_technology becomes Information technology). If the object is a literal, we return it directly.
The Shared CLI
All three scripts use the same command-line interface, provided by run_cli:
1 def run_cli(answer_fn, script_name: str):
2 import sys
3
4 if len(sys.argv) > 1:
5 question = " ".join(sys.argv[1:])
6 else:
7 print(f"{script_name} - QA with SPARQL + LLM")
8 print("-" * 50)
9 question = input("Enter your question: ")
10
11 print(f"\nQuestion: {question}")
12 print("-" * 50)
13
14 answer, context = answer_fn(question)
15
16 print("\nAnswer:")
17 print(answer)
The answer_fn parameter is a callable supplied by each script. This is how DBPedia.py, Wikidata.py, and DBPedia_and_Wikidata.py each plug in their own answer pipeline while sharing the same CLI logic.
Example 1: DBpedia (DBPedia.py)
The DBpedia script defines three SPARQL query templates, a relationship property mapping, an entity-type-to-ontology mapping, and an enrichment property table. Let us walk through each.
Entity Lookup Template
The first template finds an entity by its English label and retrieves descriptive text:
1 SPARQL_QUERY_TEMPLATE = """
2 SELECT DISTINCT ?s ?p ?comment WHERE {{
3 ?s <http://www.w3.org/2000/01/rdf-schema#label> '{name}'@en .
4 FILTER(!CONTAINS(STR(?s), 'dbpedia.org/resource/Category:')) .
5 ?s ?p ?comment .
6 FILTER (lang(?comment) = 'en') .
7 VALUES ?p {{
8 <http://dbpedia.org/ontology/abstract>
9 <http://www.w3.org/2000/01/rdf-schema#comment>
10 <http://dbpedia.org/ontology/description>
11 }}
12 {dbpedia_type}
13 }} ORDER BY ?p LIMIT 15
14 """
Line by line:
SELECT DISTINCT ?s ?p ?commentreturns the subject URI, the predicate used, and the descriptive text.?s rdfs:label '{name}'@enfinds entities whose English label matches the name we are looking for.FILTER(!CONTAINS(STR(?s), 'dbpedia.org/resource/Category:'))excludes Wikipedia category pages, which carry only the unhelpful label “Wikimedia category.”VALUES ?p { ... }lists three predicates to try, in priority order.{dbpedia_type}is replaced with an optionalrdf:typeconstraint.ORDER BY ?psorts results so the abstract comes before the shorter comment and description.LIMIT 15caps the result set.
The doubled braces {{ }} are Python str.format escapes; they produce literal braces in the output. The single-brace placeholders {name} and {dbpedia_type} are replaced at runtime.
The template is instantiated by build_sparql_query:
1 def build_sparql_query(name: str, dbpedia_type_uri: str) -> str:
2 if dbpedia_type_uri:
3 type_clause = (
4 f"OPTIONAL {{ ?s "
5 f"<http://www.w3.org/1999/02/22-rdf-syntax-ns#type> "
6 f"{dbpedia_type_uri} . }}"
7 )
8 else:
9 type_clause = ""
10 return SPARQL_QUERY_TEMPLATE.format(name=name, dbpedia_type=type_clause)
The type clause is wrapped in OPTIONAL deliberately: if we made it a hard requirement, entities whose LLM-assigned type does not exactly match the DBpedia ontology would be missed. For example, “Pepsi” might be tagged as an organization by the LLM but typed as dbo:Beverage in DBpedia. With OPTIONAL, the label match alone is sufficient.
Enrichment Template and Properties
The enrichment template fetches a curated set of property/value pairs in one query:
1 SPARQL_ENRICHMENT_TEMPLATE = """
2 SELECT DISTINCT ?propLabel ?obj ?objLabel WHERE {{
3 ?s <http://www.w3.org/2000/01/rdf-schema#label> '{name}'@en .
4 VALUES (?prop ?propLabel) {{
5 {values}
6 }}
7 ?s ?prop ?obj .
8 OPTIONAL {{
9 ?obj <http://www.w3.org/2000/01/rdf-schema#label> ?objLabel .
10 FILTER(lang(?objLabel) = 'en')
11 }}
12 }} LIMIT 80
13 """
This uses the two-variable VALUES clause described earlier. Each row pairs a property URI with a human-readable label string. The OPTIONAL block resolves URI-valued objects to their English label.
The ENRICHMENT_PROPERTIES dictionary defines which DBpedia properties to fetch for each entity type:
1 ENRICHMENT_PROPERTIES = {
2 "ORG": {
3 "foundingDate": "http://dbpedia.org/ontology/foundingDate",
4 "foundingYear": "http://dbpedia.org/ontology/foundingYear",
5 "foundedBy": "http://dbpedia.org/ontology/foundedBy",
6 "founder": "http://dbpedia.org/ontology/founder",
7 "industry": "http://dbpedia.org/ontology/industry",
8 "type": "http://dbpedia.org/ontology/type",
9 "service": "http://dbpedia.org/ontology/service",
10 "product": "http://dbpedia.org/ontology/product",
11 "keyPerson": "http://dbpedia.org/ontology/keyPerson",
12 "numberOfEmployees": "http://dbpedia.org/ontology/numberOfEmployees",
13 "revenue": "http://dbpedia.org/ontology/revenue",
14 "netIncome": "http://dbpedia.org/ontology/netIncome",
15 "operatingIncome": "http://dbpedia.org/ontology/operatingIncome",
16 "locationCity": "http://dbpedia.org/ontology/locationCity",
17 "locationCountry": "http://dbpedia.org/ontology/locationCountry",
18 "subsidiary": "http://dbpedia.org/ontology/subsidiary",
19 },
20 "PERSON": {
21 "birthDate": "http://dbpedia.org/ontology/birthDate",
22 "birthPlace": "http://dbpedia.org/ontology/birthPlace",
23 "deathDate": "http://dbpedia.org/ontology/deathDate",
24 "deathPlace": "http://dbpedia.org/ontology/deathPlace",
25 "occupation": "http://dbpedia.org/ontology/occupation",
26 "nationality": "http://dbpedia.org/ontology/nationality",
27 "spouse": "http://dbpedia.org/ontology/spouse",
28 "almaMater": "http://dbpedia.org/ontology/almaMater",
29 "knownFor": "http://dbpedia.org/ontology/knownFor",
30 "employer": "http://dbpedia.org/ontology/employer",
31 "award": "http://dbpedia.org/ontology/award",
32 },
33 "GPE": {
34 "populationTotal": "http://dbpedia.org/ontology/populationTotal",
35 "areaTotal": "http://dbpedia.org/ontology/areaTotal",
36 "country": "http://dbpedia.org/ontology/country",
37 "capital": "http://dbpedia.org/ontology/capital",
38 "leaderName": "http://dbpedia.org/ontology/leaderName",
39 "type": "http://dbpedia.org/ontology/type",
40 "language": "http://dbpedia.org/ontology/language",
41 "currency": "http://dbpedia.org/ontology/currency",
42 },
43 "MISC": {},
44 }
The properties are chosen to match the kinds of information a person would typically want to know about each entity type. For an organization, that means who founded it, what industry it is in, how many employees it has, and what its revenue is. For a person, that means birth and death dates, occupation, nationality, and spouse. For a place, that means population, area, capital, and leader.
The enrich_entity Function
The enrichment function builds the VALUES block from the property table, executes the SPARQL query, and converts the raw results into human-readable fact strings:
1 def enrich_entity(name: str, entity_type: str) -> list[str]:
2 props = ENRICHMENT_PROPERTIES.get(entity_type, {})
3 if not props:
4 return []
5
6 values_block = "\n ".join(
7 f'(<{uri}> "{label}")' for label, uri in props.items()
8 )
9 query = SPARQL_ENRICHMENT_TEMPLATE.format(name=name, values=values_block)
10 try:
11 results = query_dbpedia(query)
12 except Exception as e:
13 print(f"[Warning] Enrichment query failed for {name}: {e}")
14 return []
15
16 facts = []
17 seen = set()
18 for r in results:
19 label = r.get("propLabel", {}).get("value", "")
20 obj = r.get("obj", {}).get("value", "")
21 obj_label = r.get("objLabel", {}).get("value")
22 value = resolve_value(obj, obj_label)
23 if not value:
24 continue
25 key = (label, value)
26 if key in seen:
27 continue
28 seen.add(key)
29 facts.append(f"{label}: {value}")
30 return facts
The values_block string is built by joining together (<uri> "label") pairs, one per property. For an organization like IBM, this produces a block of sixteen rows. The seen set deduplicates facts, because DBpedia sometimes has multiple labels for the same URI.
The DBpedia Answer Pipeline
The answer_question function in DBPedia.py orchestrates the full pipeline:
1 def answer_question(question: str) -> tuple[str, str]:
2 entities = extract_entities(question)
3 print(f"[DEBUG] Entities found: {entities}")
4
5 # Step 1: Relationship detection -> direct SPARQL property lookup
6 relationship = detect_relationship(question, RELATIONSHIP_PROPERTIES)
7 if relationship:
8 property_uri, verb = relationship
9 all_names = [n for names in entities.values() for n in names]
10 if not all_names:
11 all_names = [w for w in question.replace("?", "").split()
12 if w[0].isupper()]
13 for name in all_names:
14 try:
15 rel_results = query_relationship(name, property_uri)
16 if rel_results:
17 parts = []
18 for r in rel_results[:5]:
19 label = r["label"]
20 comment = r.get("comment", "")
21 if comment:
22 parts.append(f"{label} ({comment})")
23 else:
24 parts.append(label)
25 answer = f"The {verb} of {name} is: {', '.join(parts)}."
26 return answer, "\n".join(parts)
27 except Exception as e:
28 print(f"[Warning] Relationship query failed for {name}: {e}")
29
30 # Step 2: General entity lookup + enrichment
31 context = get_entity_context(entities)
32 if not context.strip():
33 return "I couldn't find relevant entities in the knowledge base.", ""
34
35 # Step 3: LLM synthesis
36 answer = synthesize_answer(question, context, source_label="DBpedia")
37 if answer:
38 return answer, context
39
40 # Step 4: Fallback (raw descriptions)
41 # ... returns best comment per entity if LLM synthesis fails
If a relationship is detected, the function returns immediately with a direct answer like “The capital of France is: Paris.” This early return is intentional: for relationship questions, the direct SPARQL lookup gives a precise, fast answer. Otherwise, the program builds a context string from DBpedia, asks the LLM to synthesize an answer, and falls back to raw descriptions if the LLM call fails.
The DBpedia Relationship Property Mapping
The RELATIONSHIP_PROPERTIES dictionary maps English phrases to DBpedia ontology property URIs:
1 RELATIONSHIP_PROPERTIES = {
2 "capital": ("http://dbpedia.org/ontology/capital", "capital"),
3 "capital of": ("http://dbpedia.org/ontology/capital", "capital"),
4 "birthplace": ("http://dbpedia.org/ontology/birthPlace", "birthplace"),
5 "born": ("http://dbpedia.org/ontology/birthPlace", "birthplace"),
6 "born in": ("http://dbpedia.org/ontology/birthPlace", "birthplace"),
7 "spouse": ("http://dbpedia.org/ontology/spouse", "spouse"),
8 "married to": ("http://dbpedia.org/ontology/spouse", "spouse"),
9 "founded by": ("http://dbpedia.org/ontology/foundedBy", "founder"),
10 "founded": ("http://dbpedia.org/ontology/foundedBy", "founder"),
11 "headquarters": ("http://dbpedia.org/ontology/locationCity",
12 "headquarters location"),
13 "population": ("http://dbpedia.org/ontology/populationTotal",
14 "population"),
15 "currency": ("http://dbpedia.org/ontology/currency", "currency"),
16 "language": ("http://dbpedia.org/ontology/language", "language"),
17 # ... more mappings
18 }
Multiple English phrases can map to the same DBpedia property. Both “capital” and “capital of” point to dbo:capital. This redundancy is intentional: users phrase questions in many ways.
Entity Type Mapping for DBpedia
1 ENTITY_TYPE_TO_DBPEDIA_URI = {
2 "PERSON": "<http://dbpedia.org/ontology/Person>",
3 "ORG": "<http://dbpedia.org/ontology/Organisation>",
4 "GPE": "<http://dbpedia.org/ontology/Place>",
5 "MISC": "",
6 }
This disambiguates entities with common names. “Washington” could be a person (George Washington) or a place (Washington state). If the LLM tags it as a GPE, the type constraint steers DBpedia toward the geographic entity. For MISC entities, the empty string means the type clause is omitted entirely.
Example 2: Wikidata (Wikidata.py)
Wikidata uses a different data model from DBpedia. Entities are identified by Q-numbers and properties by P-numbers. This section explains the key differences in the SPARQL templates and query patterns.
Entity Lookup: Resolving Labels to Q-Numbers
The first step in any Wikidata query is to find the Q-number for an entity given its English label. The lookup template filters out disambiguation pages and sorts by ascending Q-number so the most prominent entity comes first:
1 SPARQL_LOOKUP_TEMPLATE = """
2 SELECT ?item ?itemLabel ?description WHERE {{
3 ?item rdfs:label '{name}'@en .
4 ?item schema:description ?description .
5 FILTER(LANG(?description) = 'en') .
6 FILTER(CONTAINS(STR(?item), '/entity/Q')) .
7 BIND(REPLACE(STR(?item), '.*Q', '') AS ?qnum) .
8 OPTIONAL {{ ?item rdfs:label ?itemLabel .
9 FILTER(LANG(?itemLabel) = 'en') . }}
10 }}
11 ORDER BY xsd:integer(?qnum)
12 LIMIT 5
13 """
The BIND clause extracts the numeric part of the Q-number, and ORDER BY xsd:integer(?qnum) sorts results so that Q2283 (Microsoft) comes before Q124998 (a Microsoft brand page). The schema:description predicate gives us the short Wikidata description, which is the equivalent of DBpedia’s dbo:description.
The lookup_entity_qid function executes this query and returns the first non-disambiguation Q-number:
1 def lookup_entity_qid(name: str) -> str | None:
2 query = SPARQL_LOOKUP_TEMPLATE.format(name=name.replace("'", "\\'"))
3 try:
4 results = _query_wikidata(query)
5 except Exception as e:
6 print(f"[Warning] Wikidata lookup failed for {name}: {e}")
7 return None
8
9 for r in results:
10 item_uri = r.get("item", {}).get("value", "")
11 desc = r.get("description", {}).get("value", "")
12 if "disambiguation" in desc.lower():
13 continue
14 qid = item_uri.rsplit("/", 1)[-1]
15 if qid.startswith("Q"):
16 return qid
17 return None
Rate-Limit Handling
Wikidata enforces stricter rate limits than DBpedia. The _query_wikidata wrapper inserts a delay between queries to avoid HTTP 429 responses:
1 SPARQL_DELAY = 1.0 # seconds between queries
2
3 def _query_wikidata(sparql_query: str) -> list[dict]:
4 results = query_sparql(WIKIDATA_ENDPOINT, sparql_query)
5 time.sleep(SPARQL_DELAY)
6 return results
This is a simple but effective strategy. In a production system you would use exponential backoff on 429 responses, but for an educational example the fixed delay keeps the code simple.
Enrichment: The Wikidata Meta-Model
Wikidata’s property system has a meta-model that differs from DBpedia’s. Each direct-claim property (wdt:P159, for “headquarters location”) has a corresponding property entity (wd:P159) whose rdfs:label gives us the human-readable property name. The enrichment template uses this to fetch both the property name and the value in one query:
1 SPARQL_ENRICHMENT_TEMPLATE = """
2 SELECT DISTINCT ?propLabel ?obj ?objLabel WHERE {{
3 VALUES (?p ?propEntity) {{
4 {values}
5 }}
6 wd:{qid} ?p ?obj .
7 ?propEntity rdfs:label ?propLabel .
8 FILTER(LANG(?propLabel) = 'en') .
9 OPTIONAL {{
10 ?obj rdfs:label ?objLabel .
11 FILTER(LANG(?objLabel) = 'en') .
12 }}
13 }}
14 LIMIT 80
15 """
The VALUES block pairs each direct-claim property with its property entity:
1 def enrich_entity(qid: str, entity_type: str) -> list[str]:
2 props = ENRICHMENT_PROPERTIES.get(entity_type, {})
3 if not props:
4 return []
5
6 values_lines = []
7 for label, pid in props.items():
8 values_lines.append(f"(wdt:{pid} wd:{pid})")
9 values_block = "\n ".join(values_lines)
10
11 query = SPARQL_ENRICHMENT_TEMPLATE.format(qid=qid, values=values_block)
12 # ... execute query, resolve labels, deduplicate, return facts
For IBM (Q37156), the VALUES block contains ten pairs like (wdt:P571 wd:P571) (inception), (wdt:P112 wd:P112) (founded by), and so on. The query matches all ten properties in a single round trip.
Wikidata Enrichment Properties
The enrichment property table for Wikidata uses P-numbers instead of full URIs:
1 ENRICHMENT_PROPERTIES = {
2 "ORG": {
3 "inception": "P571",
4 "founded by": "P112",
5 "industry": "P452",
6 "headquarters location": "P159",
7 "country": "P17",
8 "chairperson": "P488",
9 "CEO": "P169",
10 "subsidiary": "P355",
11 "product or material produced": "P1056",
12 "net profit": "P2295",
13 },
14 "PERSON": {
15 "date of birth": "P569",
16 "place of birth": "P19",
17 "date of death": "P570",
18 "place of death": "P20",
19 "occupation": "P106",
20 "country of citizenship": "P27",
21 "spouse": "P26",
22 "educated at": "P69",
23 "employer": "P108",
24 "award received": "P166",
25 "member of": "P463",
26 },
27 "GPE": {
28 "population": "P1082",
29 "area": "P2046",
30 "country": "P17",
31 "capital": "P36",
32 "head of state": "P35",
33 "head of government": "P6",
34 "official language": "P37",
35 "currency": "P38",
36 },
37 "MISC": {},
38 }
Compare this with the DBpedia enrichment table: the property names are different (DBpedia uses camelCase like foundingDate, Wikidata uses descriptive labels like inception), but the logical coverage is the same. Both tables define which properties to fetch for each entity type.
The Wikidata Relationship Property Mapping
The relationship mapping for Wikidata uses P-number property IDs instead of full URIs:
1 RELATIONSHIP_PROPERTIES = {
2 "capital": ("P36", "capital"),
3 "capital of": ("P36", "capital"),
4 "birthplace": ("P19", "birthplace"),
5 "born": ("P19", "birthplace"),
6 "born in": ("P19", "birthplace"),
7 "spouse": ("P26", "spouse"),
8 "married to": ("P26", "spouse"),
9 "founded by": ("P112", "founder"),
10 "founded": ("P112", "founder"),
11 "headquarters": ("P159", "headquarters location"),
12 "population": ("P1082", "population"),
13 "currency": ("P38", "currency"),
14 "language": ("P37", "official language"),
15 # ... more mappings
16 }
The relationship template takes a Q-number and a P-number and fetches the related entity’s label:
1 SPARQL_RELATIONSHIP_TEMPLATE = """
2 SELECT DISTINCT ?obj ?objLabel WHERE {{
3 wd:{qid} wdt:{pid} ?obj .
4 ?obj rdfs:label ?objLabel .
5 FILTER(LANG(?objLabel) = 'en') .
6 }}
7 LIMIT 10
8 """
This is simpler than the DBpedia relationship template because we already know the entity’s Q-number from the lookup step, so we can use it directly in the wd:{qid} pattern instead of matching by label.
The Wikidata Answer Pipeline
The Wikidata answer pipeline follows the same structure as the DBpedia one, but with an extra step: each entity name must be resolved to a Q-number before any enrichment queries can be issued:
1 def answer_question(question: str) -> tuple[str, str]:
2 entities = extract_entities(question)
3 print(f"[DEBUG] Entities found: {entities}")
4
5 # Relationship detection -> direct Wikidata property lookup
6 relationship = detect_relationship(question, RELATIONSHIP_PROPERTIES)
7 if relationship:
8 pid, verb = relationship
9 all_names = [n for names in entities.values() for n in names]
10 for name in all_names:
11 qid = lookup_entity_qid(name)
12 if not qid:
13 continue
14 rel_results = query_relationship(qid, pid)
15 if rel_results:
16 parts = [r["label"] for r in rel_results[:5] if r["label"]]
17 if parts:
18 answer = f"The {verb} of {name} is: {', '.join(parts)}."
19 return answer, "\n".join(parts)
20
21 # General entity lookup + enrichment
22 context = get_entity_context(entities)
23 if not context.strip():
24 return "I couldn't find relevant entities in the knowledge base.", ""
25
26 # LLM synthesis
27 answer = synthesize_answer(question, context, source_label="Wikidata")
28 if answer:
29 return answer, context
30
31 return "Unable to generate an answer.", context
The get_entity_context function for Wikidata resolves each entity name to a Q-number, fetches the short description, and runs the enrichment query:
1 def get_entity_context(entities: dict) -> str:
2 context_parts = []
3
4 for entity_type in ENTITY_TYPES:
5 if entity_type not in entities:
6 continue
7 for name in entities[entity_type]:
8 qid = lookup_entity_qid(name)
9 if not qid:
10 continue
11 print(f"[DEBUG] {name} -> Wikidata {qid}")
12
13 # Fetch description
14 desc = ""
15 # ... (lookup query to get description for this QID)
16
17 # Fetch enrichment facts
18 facts = enrich_entity(qid, entity_type)
19
20 if desc and facts:
21 context_parts.append(
22 f"{name} ({qid}): {desc}\n" + "\n".join(facts))
23 elif desc:
24 context_parts.append(f"{name} ({qid}): {desc}")
25 elif facts:
26 context_parts.append(
27 f"{name} ({qid}):\n" + "\n".join(facts))
28
29 return "\n\n".join(context_parts)
The context string includes the Q-number alongside the entity name, which helps the LLM (and the reader) understand where the data came from.
Example 3: Combined DBpedia + Wikidata (DBPedia_and_Wikidata.py)
The combined script demonstrates one of the most powerful features of the Semantic Web: because both DBpedia and Wikidata use RDF and SPARQL, a client application can query both and merge the results. This is called federation.
The script is remarkably short because it delegates all the work to the two KB-specific modules:
1 from library import (
2 extract_entities, synthesize_answer, run_cli,
3 )
4
5 import DBPedia
6 import Wikidata
7
8
9 def answer_question(question: str) -> tuple[str, str]:
10 entities = extract_entities(question)
11 print(f"[DEBUG] Entities found: {entities}")
12
13 # --- DBpedia context ---
14 print("\n--- Querying DBpedia ---")
15 dbpedia_context = DBPedia.get_entity_context(entities)
16 print(f"[DEBUG] DBpedia context: {len(dbpedia_context)} chars")
17
18 # --- Wikidata context ---
19 print("\n--- Querying Wikidata ---")
20 wikidata_context = Wikidata.get_entity_context(entities)
21 print(f"[DEBUG] Wikidata context: {len(wikidata_context)} chars")
22
23 # --- Merge contexts ---
24 parts = []
25 if dbpedia_context.strip():
26 parts.append("=== Knowledge from DBpedia ===\n" + dbpedia_context)
27 if wikidata_context.strip():
28 parts.append("=== Knowledge from Wikidata ===\n" + wikidata_context)
29
30 combined_context = "\n\n".join(parts)
31
32 if not combined_context.strip():
33 return ("I couldn't find relevant entities in either knowledge "
34 "base.", "")
35
36 # --- LLM synthesis over the merged context ---
37 answer = synthesize_answer(
38 question, combined_context,
39 source_label="DBpedia and Wikidata",
40 )
41 if answer:
42 return answer, combined_context
43
44 return combined_context, combined_context
The function calls DBPedia.get_entity_context and Wikidata.get_entity_context with the same entity dict, then concatenates the two context strings with source labels. The LLM sees both blocks of facts and synthesizes an answer that draws on both.
What Each Source Contributes
When we look at the combined output, we can see what each knowledge base contributes:
- DBpedia provides richer product and service lists (it extracts from Wikipedia infoboxes, which list products individually), financial figures formatted as typed literals, and the short
dbo:descriptiontext. - Wikidata provides more subsidiaries (its P355 property lists child organizations comprehensively), leadership details (current and former chairpersons), and structured industry classifications that are more granular than DBpedia’s.
The LLM synthesis step naturally reconciles overlapping facts. When both sources say IBM was founded on June 16, 1911, the LLM states it once. When Wikidata lists Thomas J. Watson Sr. as a founder but DBpedia does not, the LLM includes him. This is a practical demonstration of how the Semantic Web enables knowledge fusion.
Why Not SPARQL Federation?
SPARQL supports a SERVICE keyword that lets a single query federate across multiple endpoints:
1 SELECT ?s ?comment WHERE {
2 SERVICE <http://dbpedia.org/sparql> {
3 ?s rdfs:label "IBM"@en . ?s dbo:description ?comment .
4 }
5 }
We do not use SERVICE in this program for two reasons. First, the Wikidata endpoint often blocks federated queries from external endpoints due to rate limiting. Second, our approach of querying each endpoint separately and merging the results in Python gives us more control over error handling and rate limiting. The tradeoff is that we make more HTTP requests, but the SPARQL_DELAY between Wikidata queries keeps us within rate limits.
Running the Code
Installation
The project uses uv for dependency management. Install dependencies with:
1 uv sync
This reads pyproject.toml and creates a virtual environment with openai and requests.
Configuration
Set your Fireworks.ai API key as an environment variable:
1 export FIREWORKS_API_KEY="your-api-key"
Running the Three Examples
DBpedia only:
1 uv run DBPedia.py "tell me about the following: IBM, Microsoft"
Wikidata only:
1 uv run Wikidata.py "tell me about the following: IBM, Microsoft"
Combined DBpedia + Wikidata:
1 uv run DBPedia_and_Wikidata.py "tell me about the following: IBM, Microsoft"
All three scripts also accept relationship queries:
1 uv run DBPedia.py "What is the capital of France"
2 uv run Wikidata.py "Where was Bill Gates born"
And all three can run in interactive mode (no arguments):
1 uv run DBPedia.py
Multi-Turn Chat (DBpedia)
DBPedia.py also provides a chat_with_context function for a conversational interface that maintains message history:
1 from DBPedia import chat_with_context
2 chat_with_context()
Each turn, the function extracts entities, fetches DBpedia context, and appends both to the message list. The assistant’s reply is appended back, so subsequent turns have access to the full conversation history.
Example Queries
Relationship Queries
These queries match against the built-in property mapping tables and return direct answers:
| Query | DBpedia result | Wikidata result |
|---|---|---|
| “What is the capital of France” | Paris | Paris |
| “Where was Bill Gates born” | Seattle | Seattle |
| “Who is Bill Gates married to” | Melinda French Gates | Melinda French Gates |
| “Who founded IBM” | Charles Ranlett Flint, et al. | Charles Ranlett Flint, Thomas Watson Sr. |
| “What is the population of Paris” | Population figure | Population figure |
| “Where is IBM headquartered” | Armonk, New York | Armonk |
Entity Description Queries
These queries go through the full enrichment and LLM synthesis pipeline:
1 uv run DBPedia.py "tell me about the following: IBM, Microsoft"
2 uv run Wikidata.py "tell me about the following: IBM, Microsoft"
3 uv run DBPedia_and_Wikidata.py "tell me about the following: IBM, Microsoft"
The example outputs shown at the beginning of this chapter are the results of these commands.
Troubleshooting
FIREWORKS_API_KEY not set - The script passes None to the OpenAI client, which raises an authentication error on the first LLM call. Make sure you have exported the variable in the shell where you run the script.
Wikidata returns HTTP 429 (Too Many Requests) - Wikidata’s public endpoint aggressively rate-limits queries. The SPARQL_DELAY constant in Wikidata.py inserts a pause between queries, but if you still get 429 errors, increase the delay or wait a minute before retrying.
DBpedia SPARQL queries return empty results - DBpedia occasionally changes which predicates are available. The query templates try multiple alternatives (abstract, comment, description), and the enrichment queries use stable dbo: properties. If all fail, wait a few minutes and retry; the public endpoint is sometimes overloaded.
Entity not found - Try the canonical name. For example, use “United States” instead of “America,” and “Bill Gates” instead of “William Gates.”
LLM returns malformed JSON - The entity extraction function has a regex fallback to strip markdown code fences, but if the model returns something that is not JSON at all, the exception handler returns an empty dict and the pipeline continues with no entities. Retrying usually works.
Exercises
-
Add support for additional relationship keywords (e.g., “nationality”, “child”, “parent”)
Extend the
RELATIONSHIP_PROPERTIESdictionaries in bothDBPedia.pyandWikidata.py. Find the appropriate property URIs for DBpedia at https://dbpedia.org/ontology/ and the P-numbers for Wikidata at https://www.wikidata.org/wiki/Wikidata:List_of_properties. Example:1 # DBPedia.py 2 "nationality": ("http://dbpedia.org/ontology/nationality", "nationality"), 3 # Wikidata.py 4 "nationality": ("P27", "country of citizenship"),
-
Add a new entity type: “PRODUCT”
Add
PRODUCTto the entity extraction prompt inlibrary.py, add a DBpedia type URI and enrichment properties inDBPedia.py, and add Wikidata P-numbers inWikidata.py. Research DBpedia’sdbo:Productclass and Wikidata’sQ2424752(product) class. -
Create a caching layer for SPARQL query results
Implement a simple in-memory cache using Python’s
functools.lru_cacheor a dictionary keyed by query string. This is especially valuable for the Wikidata script, where rate limiting makes redundant queries expensive. Example:1 from functools import lru_cache 2 3 @lru_cache(maxsize=256) 4 def query_sparql_cached(endpoint: str, sparql_query: str): 5 return query_sparql(endpoint, sparql_query)
-
Modify the combined script to deduplicate overlapping facts
Currently,
DBPedia_and_Wikidata.pypasses both context strings to the LLM without deduplication. Implement a Python-side deduplication step that removes facts that appear in both contexts (e.g., “foundingDate: 1911-06-16”). Consider normalizing property labels across the two sources before comparing. -
Add support for follow-up questions that reference entities from previous answers
Modify the
answer_questionfunctions to track conversation history. Use entity coreference resolution (“it”, “they”) by maintaining a context dictionary of recently mentioned entities. Consider storing entity names with their types and Q-numbers for disambiguation. -
Handle multi-valued properties in the enrichment layer
Some DBpedia and Wikidata properties (like
productandsubsidiary) return many values. Currently each value becomes a separate fact line. Modifyenrich_entityin both scripts to group values by property label, producing a single line likesubsidiary: Red Hat, Lotus Software, IBM Research, ...instead of multiplesubsidiary: Red Hatlines. -
Add a fourth knowledge base: DBpedia Japanese endpoint or another language
DBpedia has language-specific endpoints (e.g.,
http://ja.dbpedia.org/sparqlfor Japanese). Create a new script that queries a non-English DBpedia endpoint, and modify the entity extraction prompt to detect the question’s language. This exercise explores how the Semantic Web enables multilingual knowledge access.
Further Reading
- DBpedia: https://wiki.dbpedia.org/
- Wikidata: https://www.wikidata.org/wiki/Wikidata:Main_Page
- Wikidata SPARQL query service: https://www.wikidata.org/wiki/Wikidata:SPARQL_query_service
- SPARQL specification: https://www.w3.org/TR/sparql11-query/
- Fireworks.ai API documentation: https://docs.fireworks.ai/
- RDF 1.1 Primer: https://www.w3.org/TR/rdf11-primer/