Tutorial: Building Hybrid Retrieval with Embeddings and RDFLib
- Retrieval
- Knowledge Graph
- RAG
- Python
- RDFLib
Suppose a meeting assistant receives this question:
Why did the team choose method A over method B?
The decision and its reasons may appear in different parts of the transcript. We'll use embeddings to find relevant passages, then follow their connections in a knowledge graph to collect related evidence.
This is a small version of the retrieval pattern in my meeting knowledge project: transcript hits → evidence-linked graph nodes → related context. Here, “hybrid” means vector search plus graph traversal. We won't need a vector database, an LLM framework, or the full application.
1. Set up Python and Ollama
Use Python 3.12 and install the two Python dependencies:
python -m pip install httpx rdflib
ollama pull qwen3-embedding:0.6b
Install and start Ollama first. Its local server should be available at http://localhost:11434; if it is not already running, start it with ollama serve in another terminal.
The Qwen3 embedding model turns text into vectors. RDFLib stores the graph in memory. Run the following Python blocks in order, either in a notebook or together in one script.
2. Give each passage a stable ID
We'll use four fictional transcript passages:
passages = {
"D1": "We chose method A for meeting search.",
"D2": "Method A preserves links back to the original transcript evidence.",
"D3": "We rejected method B because it loses source references when merging chunks.",
"D4": "The next team lunch is on Friday.",
}
These IDs connect the searchable text to its graph representation. In a real transcript, keep the meeting ID, speaker, timestamps, and original text as well. Scope retrieval to the meetings a user is allowed to access.
3. Search with embeddings
We'll call Ollama's /api/embed endpoint, normalize the returned vectors, and rank passages by dot product. For unit-length vectors, this is cosine similarity.
import math
import httpx
def embed(texts):
response = httpx.post(
"http://localhost:11434/api/embed",
json={
"model": "qwen3-embedding:0.6b",
"input": texts,
"truncate": False,
},
timeout=120,
)
response.raise_for_status()
vectors = response.json()["embeddings"]
if len(vectors) != len(texts):
raise ValueError("Expected one embedding per input")
normalized = []
for vector in vectors:
length = math.sqrt(sum(value * value for value in vector))
if not length:
raise ValueError("Received an empty or zero embedding")
normalized.append([value / length for value in vector])
return normalized
question = "Why did the team choose method A over method B?"
query = (
"Instruct: Retrieve meeting passages explaining a decision and its reasons.\n"
f"Query: {question}"
)
passage_ids = list(passages)
document_vectors = embed([passages[pid] for pid in passage_ids])
query_vector = embed([query])[0]
scores = {}
for pid, vector in zip(passage_ids, document_vectors, strict=True):
scores[pid] = sum(
a * b for a, b in zip(query_vector, vector, strict=True)
)
hit_ids = sorted(passage_ids, key=lambda pid: (-scores[pid], pid))[:2]
for pid in hit_ids:
print(pid, round(scores[pid], 3), passages[pid])
Qwen's retrieval examples apply an instruction to queries and embed documents without that prefix. Use the same embedding model for both.
The exact ranking is model-dependent. Inspect the output rather than assuming a particular passage must come first. A top-two search may find the decision or reasons, but it does not explicitly require the complete connected evidence set.
4. Connect the evidence in RDFLib
Now build a small graph. We manually define the relationships so this tutorial stays focused on retrieval. Automatically extracting and validating these relationships is a separate task.
from rdflib import Graph, Literal, Namespace
from rdflib.namespace import RDFS
EX = Namespace("https://example.org/meeting/")
graph = Graph()
graph.bind("ex", EX)
nodes = [
(EX.decision, "Choose method A", "D1"),
(EX.reason, "Preserve transcript evidence links", "D2"),
(EX.alternative, "Reject method B: source references are lost", "D3"),
]
for node, label, pid in nodes:
graph.add((node, RDFS.label, Literal(label)))
graph.add((node, EX.evidence, Literal(pid)))
graph.add((EX.reason, EX.motivates, EX.decision))
graph.add((EX.decision, EX.rejects, EX.alternative))
RDFLib's Graph.add() accepts a subject–predicate–object triple. The evidence property records which passage supports each node. The other predicates express connections between nodes.
The labels summarize the passages; the passages remain the source. A stored relationship still needs verification against its evidence, especially when it was extracted by a model.
5. Use vector hits as graph seeds
Find graph nodes whose evidence IDs match the vector hits:
seeds = {
node
for pid in hit_ids
for node in graph.subjects(EX.evidence, Literal(pid))
}
Then expand only the relationship types we allow. The traversal below searches both incoming and outgoing edges, while limiting depth and node count:
from collections import deque
def expand(seeds, max_depth=2, max_nodes=6):
allowed = {EX.motivates, EX.rejects}
visited = set(sorted(seeds, key=str)[:max_nodes])
queue = deque((node, 0) for node in sorted(visited, key=str))
while queue:
node, depth = queue.popleft()
if depth >= max_depth:
continue
neighbors = list(graph.predicate_objects(node))
neighbors += [(p, s) for s, p in graph.subject_predicates(node)]
for predicate, neighbor in sorted(neighbors, key=lambda pair: tuple(map(str, pair))):
if predicate not in allowed or neighbor in visited:
continue
if len(visited) >= max_nodes:
continue
visited.add(neighbor)
queue.append((neighbor, depth + 1))
return visited
related_nodes = expand(seeds)
Searching incoming edges lets us reach the reason from the decision. This is a navigation choice, not a reversal of the relationship: the reason motivates the decision, not the other way around.
If there are no matching graph nodes, seeds stays empty. We can still return the vector hits instead of inventing connections. Depth and node limits bound expansion; they do not guarantee the retrieved context is relevant.
6. Build a source-linked context packet
Collect evidence from the visited nodes, preserving the vector hits first. Also include the graph relationships in their original direction:
graph_ids = {
str(pid)
for node in related_nodes
for pid in graph.objects(node, EX.evidence)
if str(pid) in passages
}
context_ids = list(dict.fromkeys(hit_ids + sorted(graph_ids)))
context = "\n".join(f"[{pid}] {passages[pid]}" for pid in context_ids)
print(context)
for subject, predicate, obj in sorted(graph, key=lambda triple: tuple(map(str, triple))):
if predicate in {EX.motivates, EX.rejects} and subject in related_nodes and obj in related_nodes:
print(graph.value(subject, RDFS.label), predicate.split("/")[-1], graph.value(obj, RDFS.label))
If the seed set includes any of our three connected nodes, two-hop expansion can collect D1, D2, and D3. D4 has no graph connection, although it would remain in the packet if vector search selected it. This makes the difference between direct hits and graph-added evidence easy to inspect.
The packet can now be passed to an answer-generating model. An answer supported by this fictional dataset would be:
The team chose method A [D1] because it preserves transcript evidence links [D2]. Method B was rejected because it loses source references when merging chunks [D3].
That is an authored example, not a generated result. The code stops at retrieval and context assembly. Prompting for citations does not itself prove that an answer is supported; check each claim against its cited passage.
What to try next
Compare hit_ids with context_ids for several questions. Does graph expansion add missing evidence, or merely more text? Try an unrelated question too: top-k search always returns candidates, so a real assistant needs a way to detect insufficient evidence and abstain.
For a larger corpus, cache passage embeddings and move beyond an exact scan when needed. Keep source IDs throughout the pipeline, validate graph edges, and evaluate vector-only and hybrid retrieval under the same final context budget. This small example demonstrates how the pieces connect; it does not establish that graph expansion improves every query.