Wicked Smart Data
LearnArticlesAbout
Sign InSign Up
LearnArticlesAboutContact
Sign InSign Up
Wicked Smart Data

The go-to platform for professionals who want to master data, automation, and AI — from Excel fundamentals to cutting-edge machine learning.

Platform

  • Learning Paths
  • Articles
  • About
  • Contact

Connect

  • Contact Us
  • RSS Feed

© 2026 Wicked Smart Data. All rights reserved.

Privacy PolicyTerms of Service
All Articles
Building a Knowledge Graph-Augmented RAG System: Combining Vector Search with Entity Relationships for Multi-Hop Reasoning

Building a Knowledge Graph-Augmented RAG System: Combining Vector Search with Entity Relationships for Multi-Hop Reasoning

AI & Machine Learning🔥 Expert26 min readAug 13, 2026Updated Aug 13, 2026
Table of Contents
  • Prerequisites
  • Why Multi-Hop Reasoning Breaks Pure RAG
  • The Architecture at a Glance
  • Setting Up Your Environment
  • The Entity and Relationship Extraction Pipeline
  • Defining Your Schema
The Extraction Prompt
  • Entity Disambiguation
  • Building the Knowledge Graph in Neo4j
  • The Hybrid Retrieval Engine
  • Context Assembly and Prompt Construction
  • The Complete Pipeline: Putting It Together
  • Hands-On Exercise
  • Common Mistakes & Troubleshooting
  • Mistake 1: Over-broad Entity Schemas
  • Mistake 2: Ignoring Relationship Direction
  • Mistake 3: Forgetting to Chunk Before Extracting
  • Mistake 4: Using `MERGE` on Non-Indexed Properties
  • Mistake 5: Not Handling LLM Extraction Failures
  • Mistake 6: Max Hops Too High
  • Troubleshooting: Empty Graph Traversal Results
  • Troubleshooting: High Hallucination in Extracted Relationships
  • Summary & Next Steps
  • Building a Knowledge Graph-Augmented RAG System: Combining Vector Search with Entity Relationships for Multi-Hop Reasoning

    Standard RAG is good at finding relevant passages. It's terrible at following a chain of reasoning across multiple documents. Ask a vector search system "Which pharmaceutical compounds developed by researchers at MIT have been tested in clinical trials funded by organizations that also sponsor climate research?" and watch it confidently return the most semantically similar chunk — which almost certainly won't actually answer your question. The retrieval was fine. The reasoning capability was never there to begin with.

    This is the fundamental limitation of pure vector search: it retrieves by similarity, not by relationship. When your question requires traversing a web of connected facts — entity A relates to entity B which connects to entity C — cosine similarity between embeddings cannot save you. You need something that understands structure.

    Knowledge Graph-Augmented RAG (KG-RAG) solves this by combining the fuzzy semantic matching power of vector search with the precise relational traversal of a graph database. By the end of this lesson, you'll have built a working system that extracts entities and relationships from documents, stores them in a queryable graph, and uses multi-hop graph traversal to augment the context passed to an LLM — enabling the kind of connected reasoning that pure RAG systems simply cannot do.

    What you'll learn:

    • Why vector search fails at multi-hop reasoning and how graph structures fill that gap
    • How to extract entities and relationships from unstructured text using an LLM pipeline
    • How to build and query a knowledge graph using Neo4j for relationship traversal
    • How to design a hybrid retrieval strategy that combines vector search with graph traversal
    • How to construct context-aware prompts from graph paths and vector chunks for superior answer quality

    Prerequisites

    You should be comfortable with:

    • Python 3.10+ and async programming patterns
    • Standard RAG pipelines (chunking, embedding, vector store retrieval)
    • Basic graph concepts (nodes, edges, directed vs. undirected relationships)
    • Working knowledge of an LLM API (we'll use OpenAI throughout)

    You'll need running instances of Neo4j (community edition works) and a vector store. We'll use Qdrant for vector storage, but the patterns apply to Pinecone, Weaviate, or pgvector equally well.


    Why Multi-Hop Reasoning Breaks Pure RAG

    Before building anything, let's be precise about the failure mode. Consider a corpus of biomedical research papers. You embed everything, chunk it, stuff it into Qdrant, and fire queries at it.

    For a question like "What are the side effects of metformin?" — great. Vector search finds chunks mentioning metformin and side effects. The LLM synthesizes a competent answer.

    Now try: "Are there any researchers who collaborated with the team that published the ACCORD trial, and did any of them later work on SGLT2 inhibitor studies?"

    The problem isn't that the answer isn't in your corpus. It might be. The problem is that answering it requires:

    1. Identifying who was on the ACCORD trial team (entity resolution)
    2. Finding what those people collaborated on afterward (relationship traversal, hop 1)
    3. Filtering those collaborations for SGLT2 inhibitor studies (relationship traversal, hop 2)
    4. Synthesizing the result

    No individual chunk contains all of this. Vector similarity will happily return chunks about the ACCORD trial and separate chunks about SGLT2 inhibitors, but it has no mechanism to follow the chain of people connecting them. You'd need to hope the LLM hallucinates a bridge — which it often will, confidently and incorrectly.

    A knowledge graph makes these connections explicit and queryable. Instead of hoping the right context lands in your retrieval window by semantic proximity, you traverse edges: Person-[:AUTHORED]->Paper-[:CITES]->Paper-[:STUDIES]->Drug.

    The Architecture at a Glance

    The system we're building has three major components:

    1. Ingestion Pipeline: Extracts entities and relationships from documents and populates both a vector store (for semantic search) and a knowledge graph (for relational traversal).

    2. Hybrid Retrieval Engine: Takes a query, uses vector search to identify seed entities, then traverses the graph outward to gather relational context unavailable to pure vector retrieval.

    3. Context Assembly & Generation: Combines vector-retrieved chunks with graph-traversed paths into a structured prompt that gives the LLM the connected context it needs.


    Setting Up Your Environment

    Install the core dependencies:

    pip install openai neo4j qdrant-client sentence-transformers spacy tiktoken pydantic tenacity
    python -m spacy download en_core_web_trf
    

    Start Neo4j and Qdrant locally with Docker:

    # docker-compose.yml
    version: '3.8'
    services:
      neo4j:
        image: neo4j:5.15-community
        ports:
          - "7474:7474"  # Browser UI
          - "7687:7687"  # Bolt protocol
        environment:
          - NEO4J_AUTH=neo4j/your_password
          - NEO4J_PLUGINS=["apoc"]
        volumes:
          - neo4j_data:/data
    
      qdrant:
        image: qdrant/qdrant:v1.7.4
        ports:
          - "6333:6333"
        volumes:
          - qdrant_data:/qdrant/storage
    
    volumes:
      neo4j_data:
      qdrant_data:
    
    docker-compose up -d
    

    Now set up your configuration:

    # config.py
    import os
    from dataclasses import dataclass
    
    @dataclass
    class Config:
        openai_api_key: str = os.getenv("OPENAI_API_KEY")
        neo4j_uri: str = "bolt://localhost:7687"
        neo4j_user: str = "neo4j"
        neo4j_password: str = "your_password"
        qdrant_host: str = "localhost"
        qdrant_port: int = 6333
        collection_name: str = "biomedical_papers"
        embedding_model: str = "text-embedding-3-small"
        llm_model: str = "gpt-4o"
        embedding_dim: int = 1536
        entity_extraction_model: str = "gpt-4o-mini"  # Cheaper for extraction
    
    config = Config()
    

    Using gpt-4o-mini for entity extraction is a deliberate cost decision. You're going to run this against every chunk in your corpus during ingestion. The extraction task is structured and well-defined — it doesn't need the most powerful model. Save your GPT-4o budget for final answer generation.


    The Entity and Relationship Extraction Pipeline

    This is where the magic starts — and where most tutorials gloss over the hard parts. Entity extraction from scientific text is not trivial. Named entity recognition (NER) with spaCy will catch person names and organizations, but it won't understand domain-specific entities like drug compounds, clinical trial identifiers, or research methodologies.

    We'll use a hybrid approach: spaCy for coarse entity detection as a pre-filter, and an LLM with a structured extraction prompt for precise entity and relationship extraction.

    Defining Your Schema

    Before writing any extraction code, define what your graph will look like. This is your most important design decision — get it wrong and you'll end up with an unmaintainable hairball.

    # schema.py
    from pydantic import BaseModel, Field
    from typing import Optional
    from enum import Enum
    
    class EntityType(str, Enum):
        PERSON = "Person"
        ORGANIZATION = "Organization"
        DRUG = "Drug"
        DISEASE = "Disease"
        CLINICAL_TRIAL = "ClinicalTrial"
        RESEARCH_PAPER = "ResearchPaper"
        INSTITUTION = "Institution"
        BIOMARKER = "Biomarker"
    
    class RelationshipType(str, Enum):
        AUTHORED = "AUTHORED"
        AFFILIATED_WITH = "AFFILIATED_WITH"
        STUDIED = "STUDIED"
        TREATS = "TREATS"
        CAUSES = "CAUSES"
        PARTICIPATED_IN = "PARTICIPATED_IN"
        FUNDED_BY = "FUNDED_BY"
        CITES = "CITES"
        COLLABORATED_WITH = "COLLABORATED_WITH"
        REGULATES = "REGULATES"
    
    class Entity(BaseModel):
        name: str
        entity_type: EntityType
        description: Optional[str] = None
        canonical_name: Optional[str] = None  # For disambiguation
    
    class Relationship(BaseModel):
        source_entity: str
        source_type: EntityType
        relationship_type: RelationshipType
        target_entity: str
        target_type: EntityType
        context: Optional[str] = Field(None, description="Brief context for this relationship")
        confidence: float = Field(default=1.0, ge=0.0, le=1.0)
    
    class ExtractionResult(BaseModel):
        entities: list[Entity]
        relationships: list[Relationship]
        chunk_id: str
    

    The canonical_name field on Entity is critical and often neglected. Your corpus will mention "Dr. Sarah Chen," "S. Chen," "Chen et al.," and "Sarah Chen, PhD" — all the same person. Without canonicalization, you build a graph with four disconnected nodes where there should be one. We'll handle this with a disambiguation pass.

    The Extraction Prompt

    # extraction.py
    import json
    from openai import AsyncOpenAI
    from tenacity import retry, stop_after_attempt, wait_exponential
    from schema import ExtractionResult, EntityType, RelationshipType
    
    client = AsyncOpenAI(api_key=config.openai_api_key)
    
    EXTRACTION_SYSTEM_PROMPT = """You are a biomedical knowledge graph extraction system. 
    Your task is to extract entities and relationships from scientific text with high precision.
    
    ENTITY TYPES you should extract:
    - Person: Researchers, authors, clinicians
    - Organization: Companies, hospitals, funding agencies
    - Drug: Pharmaceutical compounds, treatments, interventions
    - Disease: Medical conditions, syndromes, disorders
    - ClinicalTrial: Named trials (e.g., "ACCORD trial", "UKPDS")
    - ResearchPaper: Referenced papers (use title or identifier)
    - Institution: Universities, research institutes
    - Biomarker: Biological markers, lab values
    
    RELATIONSHIP TYPES:
    - AUTHORED: Person -> ResearchPaper
    - AFFILIATED_WITH: Person -> Institution/Organization
    - STUDIED: ResearchPaper/ClinicalTrial -> Drug/Disease/Biomarker
    - TREATS: Drug -> Disease
    - CAUSES: Drug/Disease -> Disease/Biomarker (side effects, complications)
    - PARTICIPATED_IN: Person/Organization -> ClinicalTrial
    - FUNDED_BY: ResearchPaper/ClinicalTrial -> Organization
    - CITES: ResearchPaper -> ResearchPaper
    - COLLABORATED_WITH: Person -> Person (inferred from co-authorship)
    - REGULATES: Biomarker/Drug -> Biomarker/Drug
    
    RULES:
    1. Only extract relationships explicitly stated or strongly implied in the text
    2. Normalize entity names to their most complete form
    3. Assign confidence scores: 1.0 for explicit statements, 0.7-0.9 for strong implications
    4. For COLLABORATED_WITH, infer from co-authorship when multiple authors are listed
    
    Return valid JSON matching the ExtractionResult schema."""
    
    EXTRACTION_USER_PROMPT = """Extract all entities and relationships from this text chunk.
    Chunk ID: {chunk_id}
    
    TEXT:
    {text}
    
    Return JSON with this structure:
    {{
      "entities": [
        {{"name": "...", "entity_type": "...", "description": "...", "canonical_name": "..."}}
      ],
      "relationships": [
        {{
          "source_entity": "...", "source_type": "...",
          "relationship_type": "...",
          "target_entity": "...", "target_type": "...",
          "context": "...", "confidence": 0.0
        }}
      ],
      "chunk_id": "{chunk_id}"
    }}"""
    
    @retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=4, max=10))
    async def extract_entities_and_relationships(
        text: str, 
        chunk_id: str
    ) -> ExtractionResult:
        response = await client.chat.completions.create(
            model=config.entity_extraction_model,
            messages=[
                {"role": "system", "content": EXTRACTION_SYSTEM_PROMPT},
                {"role": "user", "content": EXTRACTION_USER_PROMPT.format(
                    chunk_id=chunk_id, 
                    text=text
                )}
            ],
            response_format={"type": "json_object"},
            temperature=0.1,  # Low temperature for consistent extraction
            max_tokens=2000
        )
        
        raw = json.loads(response.choices[0].message.content)
        return ExtractionResult(**raw)
    

    Warning: temperature=0.1 is intentional here. Higher temperatures introduce variability in entity names — "Metformin" vs "metformin" vs "METFORMIN" — which destroys your graph structure when the same entity gets multiple nodes. You want the model to be boring and consistent.

    Entity Disambiguation

    After extraction, you need to resolve variations of the same entity before writing to the graph. Here's a practical approach using embedding similarity:

    # disambiguation.py
    import numpy as np
    from openai import AsyncOpenAI
    from schema import Entity
    
    client = AsyncOpenAI(api_key=config.openai_api_key)
    
    async def get_embedding(text: str) -> list[float]:
        response = await client.embeddings.create(
            model=config.embedding_model,
            input=text
        )
        return response.data[0].embedding
    
    class EntityDisambiguator:
        def __init__(self, similarity_threshold: float = 0.92):
            self.known_entities: dict[str, tuple[Entity, list[float]]] = {}
            self.threshold = similarity_threshold
        
        async def resolve(self, entity: Entity) -> Entity:
            """
            Find the canonical form of an entity, or register it as new.
            Returns the canonical entity.
            """
            if not self.known_entities:
                embedding = await get_embedding(
                    f"{entity.entity_type}: {entity.name}"
                )
                self.known_entities[entity.name] = (entity, embedding)
                return entity
            
            # Embed the new entity
            new_embedding = await get_embedding(
                f"{entity.entity_type}: {entity.name}"
            )
            
            best_match = None
            best_similarity = 0.0
            
            for known_name, (known_entity, known_embedding) in self.known_entities.items():
                # Only compare same entity types
                if known_entity.entity_type != entity.entity_type:
                    continue
                
                similarity = cosine_similarity(new_embedding, known_embedding)
                if similarity > best_similarity:
                    best_similarity = similarity
                    best_match = known_entity
            
            if best_match and best_similarity >= self.threshold:
                # Return the canonical entity (the first one we saw)
                return best_match
            else:
                # New entity - register it
                self.known_entities[entity.name] = (entity, new_embedding)
                return entity
    
    def cosine_similarity(a: list[float], b: list[float]) -> float:
        a_arr = np.array(a)
        b_arr = np.array(b)
        return float(np.dot(a_arr, b_arr) / (np.linalg.norm(a_arr) * np.linalg.norm(b_arr)))
    

    The 0.92 threshold is a starting point, not a rule. In a medical corpus, "ACE inhibitors" and "ACE inhibitor therapy" should resolve to the same entity (very high similarity), but "metformin" and "metformin hydrochloride" might need expert judgment. Tune this threshold empirically on a sample of your data.


    Building the Knowledge Graph in Neo4j

    Now we write to the graph. The design of your Cypher queries here has enormous performance implications at scale.

    # graph_store.py
    from neo4j import AsyncGraphDatabase
    from schema import Entity, Relationship, ExtractionResult
    
    class KnowledgeGraphStore:
        def __init__(self):
            self.driver = AsyncGraphDatabase.driver(
                config.neo4j_uri,
                auth=(config.neo4j_user, config.neo4j_password)
            )
        
        async def initialize_schema(self):
            """Create indexes for performance. Run once."""
            async with self.driver.session() as session:
                # Uniqueness constraints prevent duplicate nodes
                await session.run(
                    "CREATE CONSTRAINT entity_name_type IF NOT EXISTS "
                    "FOR (e:Entity) REQUIRE (e.name, e.entity_type) IS UNIQUE"
                )
                # Full-text index for entity name search
                await session.run(
                    "CREATE FULLTEXT INDEX entity_name_index IF NOT EXISTS "
                    "FOR (e:Entity) ON EACH [e.name, e.canonical_name]"
                )
                # Composite index for relationship traversal performance
                await session.run(
                    "CREATE INDEX chunk_entity_idx IF NOT EXISTS "
                    "FOR ()-[r:MENTIONED_IN]-() ON (r.chunk_id)"
                )
        
        async def upsert_entity(self, entity: Entity) -> None:
            """
            MERGE ensures we don't create duplicate nodes.
            ON CREATE sets initial properties; ON MATCH updates them.
            """
            async with self.driver.session() as session:
                await session.run(
                    """
                    MERGE (e:Entity {name: $name, entity_type: $entity_type})
                    ON CREATE SET 
                        e.description = $description,
                        e.canonical_name = $canonical_name,
                        e.created_at = timestamp(),
                        e.mention_count = 1
                    ON MATCH SET
                        e.mention_count = e.mention_count + 1,
                        e.description = CASE 
                            WHEN e.description IS NULL THEN $description 
                            ELSE e.description 
                        END
                    WITH e
                    CALL apoc.create.addLabels(e, [$entity_type]) YIELD node
                    RETURN node
                    """,
                    name=entity.name,
                    entity_type=entity.entity_type.value,
                    description=entity.description or "",
                    canonical_name=entity.canonical_name or entity.name
                )
        
        async def upsert_relationship(
            self, 
            relationship: Relationship,
            chunk_id: str
        ) -> None:
            """
            Create a typed relationship between two entities.
            We also store the chunk_id so we can trace back to source text.
            """
            async with self.driver.session() as session:
                # Dynamic relationship types require APOC in Neo4j
                await session.run(
                    """
                    MATCH (source:Entity {name: $source_name, entity_type: $source_type})
                    MATCH (target:Entity {name: $target_name, entity_type: $target_type})
                    CALL apoc.merge.relationship(
                        source, 
                        $rel_type,
                        {source_chunk: $chunk_id},
                        {context: $context, confidence: $confidence, created_at: timestamp()},
                        target,
                        {}
                    ) YIELD rel
                    RETURN rel
                    """,
                    source_name=relationship.source_entity,
                    source_type=relationship.source_type.value,
                    target_name=relationship.target_entity,
                    target_type=relationship.target_type.value,
                    rel_type=relationship.relationship_type.value,
                    chunk_id=chunk_id,
                    context=relationship.context or "",
                    confidence=relationship.confidence
                )
        
        async def store_extraction_result(
            self, 
            result: ExtractionResult,
            disambiguator: 'EntityDisambiguator'
        ) -> None:
            """Process a full extraction result."""
            # First pass: upsert all entities
            canonical_map = {}
            for entity in result.entities:
                canonical = await disambiguator.resolve(entity)
                canonical_map[entity.name] = canonical
                await self.upsert_entity(canonical)
            
            # Second pass: upsert relationships using canonical names
            for rel in result.relationships:
                canonical_source = canonical_map.get(rel.source_entity)
                canonical_target = canonical_map.get(rel.target_entity)
                
                if not canonical_source or not canonical_target:
                    continue  # Skip if entities weren't extracted
                
                # Remap to canonical names
                canonical_rel = rel.model_copy(update={
                    "source_entity": canonical_source.name,
                    "target_entity": canonical_target.name
                })
                
                await self.upsert_relationship(canonical_rel, result.chunk_id)
        
        async def close(self):
            await self.driver.close()
    

    Tip: The MERGE pattern in Cypher is your best friend and a common performance trap. Always MERGE on a small, indexed set of properties. If you MERGE on properties that aren't indexed, Neo4j does a full node scan. With a large graph this becomes catastrophically slow. Our schema initialization creates the necessary indexes — don't skip it.


    The Hybrid Retrieval Engine

    This is the core intellectual contribution of KG-RAG. We're building a retrieval system that uses vector search to find entry points into the graph, then traverses edges to collect context that would be invisible to pure semantic search.

    # retrieval.py
    from dataclasses import dataclass
    from qdrant_client import AsyncQdrantClient
    from qdrant_client.models import Distance, VectorParams, PointStruct
    from neo4j import AsyncGraphDatabase
    import uuid
    
    @dataclass
    class GraphPath:
        """Represents a traversed path through the knowledge graph."""
        path_elements: list[str]  # Sequence of nodes and relationships
        start_entity: str
        end_entity: str
        hop_count: int
        relevance_score: float
        source_chunks: list[str]  # Chunk IDs where relationships were found
    
    @dataclass 
    class HybridRetrievalResult:
        vector_chunks: list[dict]       # Standard vector search results
        graph_paths: list[GraphPath]    # Multi-hop graph traversal results
        seed_entities: list[str]        # Entities identified in the query
        query: str
    
    class HybridRetriever:
        def __init__(self):
            self.qdrant = AsyncQdrantClient(
                host=config.qdrant_host, 
                port=config.qdrant_port
            )
            self.neo4j = AsyncGraphDatabase.driver(
                config.neo4j_uri,
                auth=(config.neo4j_user, config.neo4j_password)
            )
            self.openai = AsyncOpenAI(api_key=config.openai_api_key)
        
        async def extract_query_entities(self, query: str) -> list[str]:
            """
            Extract entity mentions from the query for use as graph traversal seeds.
            This is a lightweight extraction — we only need names, not full schema.
            """
            response = await self.openai.chat.completions.create(
                model=config.entity_extraction_model,
                messages=[{
                    "role": "user",
                    "content": f"""Extract all named entities from this query. 
    Return only a JSON array of entity name strings.
    Be inclusive — if something might be an entity, include it.
    
    Query: {query}
    
    Return format: ["entity1", "entity2", ...]"""
                }],
                response_format={"type": "json_object"},
                temperature=0.0
            )
            import json
            result = json.loads(response.choices[0].message.content)
            # Handle both {"entities": [...]} and direct array responses
            if isinstance(result, list):
                return result
            return result.get("entities", []) or result.get("items", [])
        
        async def vector_search(
            self, 
            query: str, 
            top_k: int = 10
        ) -> list[dict]:
            """Standard vector similarity search."""
            embedding_response = await self.openai.embeddings.create(
                model=config.embedding_model,
                input=query
            )
            query_vector = embedding_response.data[0].embedding
            
            results = await self.qdrant.search(
                collection_name=config.collection_name,
                query_vector=query_vector,
                limit=top_k,
                with_payload=True
            )
            
            return [
                {
                    "chunk_id": hit.payload.get("chunk_id"),
                    "text": hit.payload.get("text"),
                    "source": hit.payload.get("source"),
                    "score": hit.score
                }
                for hit in results
            ]
        
        async def find_seed_nodes(self, entity_names: list[str]) -> list[str]:
            """
            Match query entity mentions to actual graph nodes.
            Uses full-text search for fuzzy matching.
            """
            matched_nodes = []
            
            async with self.neo4j.session() as session:
                for name in entity_names:
                    result = await session.run(
                        """
                        CALL db.index.fulltext.queryNodes(
                            'entity_name_index', $search_term
                        ) YIELD node, score
                        WHERE score > 1.0
                        RETURN node.name AS name, node.entity_type AS type, score
                        ORDER BY score DESC
                        LIMIT 3
                        """,
                        search_term=name
                    )
                    records = await result.data()
                    matched_nodes.extend([r["name"] for r in records])
            
            return list(set(matched_nodes))
        
        async def multi_hop_traversal(
            self,
            seed_entities: list[str],
            max_hops: int = 3,
            max_paths: int = 20
        ) -> list[GraphPath]:
            """
            Core multi-hop traversal. 
            Finds paths connecting seed entities through the knowledge graph.
            """
            if not seed_entities:
                return []
            
            paths = []
            
            async with self.neo4j.session() as session:
                # Single-entity neighborhood exploration
                for seed in seed_entities:
                    result = await session.run(
                        """
                        MATCH (start:Entity {name: $seed})
                        CALL apoc.path.expandConfig(start, {
                            maxLevel: $max_hops,
                            relationshipFilter: 'AUTHORED|STUDIED|TREATS|CAUSES|
                                                PARTICIPATED_IN|FUNDED_BY|CITES|
                                                COLLABORATED_WITH|AFFILIATED_WITH',
                            uniqueness: 'NODE_PATH',
                            limit: $max_paths
                        }) YIELD path
                        WHERE length(path) > 0
                        RETURN 
                            [node in nodes(path) | node.name] AS node_names,
                            [node in nodes(path) | node.entity_type] AS node_types,
                            [rel in relationships(path) | type(rel)] AS rel_types,
                            [rel in relationships(path) | rel.source_chunk] AS chunks,
                            length(path) AS hop_count
                        ORDER BY hop_count
                        LIMIT $max_paths
                        """,
                        seed=seed,
                        max_hops=max_hops,
                        max_paths=max_paths
                    )
                    
                    records = await result.data()
                    
                    for record in records:
                        # Interleave nodes and relationships for readable path
                        path_elements = []
                        nodes = record["node_names"]
                        rels = record["rel_types"]
                        types = record["node_types"]
                        
                        for i, node in enumerate(nodes):
                            path_elements.append(f"({node}:{types[i]})")
                            if i < len(rels):
                                path_elements.append(f"-[:{rels[i]}]->")
                        
                        chunk_ids = [c for c in record["chunks"] if c]
                        
                        paths.append(GraphPath(
                            path_elements=path_elements,
                            start_entity=nodes[0] if nodes else "",
                            end_entity=nodes[-1] if nodes else "",
                            hop_count=record["hop_count"],
                            relevance_score=1.0 / (record["hop_count"] + 1),  # Closer = more relevant
                            source_chunks=chunk_ids
                        ))
                
                # If multiple seed entities: find connecting paths between them
                if len(seed_entities) >= 2:
                    for i in range(len(seed_entities)):
                        for j in range(i + 1, len(seed_entities)):
                            result = await session.run(
                                """
                                MATCH (a:Entity {name: $entity_a}), 
                                      (b:Entity {name: $entity_b})
                                CALL apoc.algo.dijkstra(a, b, 
                                    'AUTHORED|STUDIED|TREATS|CAUSES|
                                     PARTICIPATED_IN|COLLABORATED_WITH|CITES',
                                    'confidence'
                                ) YIELD path, weight
                                WHERE length(path) <= $max_hops
                                RETURN 
                                    [node in nodes(path) | node.name] AS node_names,
                                    [node in nodes(path) | node.entity_type] AS node_types,
                                    [rel in relationships(path) | type(rel)] AS rel_types,
                                    [rel in relationships(path) | rel.source_chunk] AS chunks,
                                    length(path) AS hop_count,
                                    weight
                                LIMIT 5
                                """,
                                entity_a=seed_entities[i],
                                entity_b=seed_entities[j],
                                max_hops=max_hops
                            )
                            
                            connecting_records = await result.data()
                            
                            for record in connecting_records:
                                nodes = record["node_names"]
                                rels = record["rel_types"]
                                types = record["node_types"]
                                path_elements = []
                                
                                for idx, node in enumerate(nodes):
                                    path_elements.append(f"({node}:{types[idx]})")
                                    if idx < len(rels):
                                        path_elements.append(f"-[:{rels[idx]}]->")
                                
                                paths.append(GraphPath(
                                    path_elements=path_elements,
                                    start_entity=nodes[0] if nodes else "",
                                    end_entity=nodes[-1] if nodes else "",
                                    hop_count=record["hop_count"],
                                    relevance_score=2.0 / (record["hop_count"] + 1),  # Connecting paths are more valuable
                                    source_chunks=[c for c in record["chunks"] if c]
                                ))
            
            # Sort by relevance and deduplicate
            paths.sort(key=lambda p: p.relevance_score, reverse=True)
            return self._deduplicate_paths(paths)[:max_paths]
        
        def _deduplicate_paths(self, paths: list[GraphPath]) -> list[GraphPath]:
            """Remove paths that are subsets of longer paths."""
            seen = set()
            unique_paths = []
            for path in paths:
                key = "->".join(path.path_elements)
                if key not in seen:
                    seen.add(key)
                    unique_paths.append(path)
            return unique_paths
        
        async def retrieve(
            self, 
            query: str,
            vector_top_k: int = 10,
            max_hops: int = 3
        ) -> HybridRetrievalResult:
            """
            Main retrieval entry point. Runs vector search and graph traversal
            concurrently then combines results.
            """
            import asyncio
            
            # Extract entities for graph seeding
            query_entities = await self.extract_query_entities(query)
            
            # Run vector search and graph operations concurrently
            vector_task = asyncio.create_task(
                self.vector_search(query, vector_top_k)
            )
            seed_task = asyncio.create_task(
                self.find_seed_nodes(query_entities)
            )
            
            vector_results, seed_nodes = await asyncio.gather(vector_task, seed_task)
            
            # Graph traversal happens after seed resolution
            graph_paths = await self.multi_hop_traversal(seed_nodes, max_hops)
            
            return HybridRetrievalResult(
                vector_chunks=vector_results,
                graph_paths=graph_paths,
                seed_entities=seed_nodes,
                query=query
            )
    

    The concurrent execution of vector search and entity extraction is important. These are independent operations and running them sequentially adds unnecessary latency. In production systems, this latency difference becomes significant at scale.


    Context Assembly and Prompt Construction

    Raw retrieval results are useless without thoughtful assembly into a prompt. This is where many KG-RAG implementations fall apart — they dump every graph path into the context window and hope the LLM figures it out.

    # context_assembly.py
    import tiktoken
    from retrieval import HybridRetrievalResult, GraphPath
    
    class ContextAssembler:
        def __init__(self, max_context_tokens: int = 6000):
            self.max_context_tokens = max_context_tokens
            self.encoder = tiktoken.encoding_for_model("gpt-4o")
        
        def count_tokens(self, text: str) -> int:
            return len(self.encoder.encode(text))
        
        def format_graph_path(self, path: GraphPath) -> str:
            """Convert a graph path to readable natural language."""
            return " ".join(path.path_elements)
        
        def group_paths_by_relevance(
            self, 
            paths: list[GraphPath]
        ) -> dict[int, list[GraphPath]]:
            """Group paths by hop count for structured presentation."""
            groups = {}
            for path in paths:
                groups.setdefault(path.hop_count, []).append(path)
            return dict(sorted(groups.items()))
        
        def assemble_context(
            self, 
            retrieval_result: HybridRetrievalResult
        ) -> str:
            """
            Build a structured context string that clearly separates
            vector evidence from graph relationship evidence.
            """
            sections = []
            token_budget = self.max_context_tokens
            
            # Section 1: Identified query entities
            if retrieval_result.seed_entities:
                entity_section = (
                    "## Identified Entities in Query\n"
                    + ", ".join(retrieval_result.seed_entities)
                    + "\n"
                )
                sections.append(entity_section)
                token_budget -= self.count_tokens(entity_section)
            
            # Section 2: Knowledge graph relationship paths
            if retrieval_result.graph_paths:
                path_section_parts = ["## Knowledge Graph Relationships\n"]
                path_section_parts.append(
                    "The following relationship chains were found in the knowledge graph:\n"
                )
                
                grouped = self.group_paths_by_relevance(retrieval_result.graph_paths)
                
                for hop_count, paths in grouped.items():
                    hop_label = f"### {hop_count}-hop relationships:\n"
                    path_section_parts.append(hop_label)
                    
                    for path in paths[:5]:  # Max 5 paths per hop level
                        path_text = self.format_graph_path(path) + "\n"
                        path_tokens = self.count_tokens(path_text)
                        
                        if token_budget - path_tokens > 1000:  # Keep 1000 tokens for chunks
                            path_section_parts.append(f"- {path_text}")
                            token_budget -= path_tokens
                
                sections.append("".join(path_section_parts))
            
            # Section 3: Vector-retrieved text chunks (semantic evidence)
            if retrieval_result.vector_chunks:
                chunk_section_parts = ["## Retrieved Text Passages\n"]
                
                for i, chunk in enumerate(retrieval_result.vector_chunks):
                    chunk_text = (
                        f"### Passage {i+1} (relevance: {chunk['score']:.3f})\n"
                        f"Source: {chunk.get('source', 'unknown')}\n"
                        f"{chunk['text']}\n\n"
                    )
                    chunk_tokens = self.count_tokens(chunk_text)
                    
                    if token_budget - chunk_tokens > 200:
                        chunk_section_parts.append(chunk_text)
                        token_budget -= chunk_tokens
                    else:
                        break
                
                sections.append("".join(chunk_section_parts))
            
            return "\n".join(sections)
    
    async def generate_answer(
        query: str,
        context: str,
        model: str = "gpt-4o"
    ) -> str:
        client = AsyncOpenAI(api_key=config.openai_api_key)
        
        system_prompt = """You are an expert research assistant with access to both 
    structured knowledge graph relationships and retrieved text passages.
    
    When answering:
    1. PRIORITIZE multi-hop reasoning: use the Knowledge Graph Relationships to trace 
       connections that span multiple entities
    2. USE text passages to provide supporting evidence and detail
    3. EXPLICITLY cite which relationship chains support your reasoning
    4. ACKNOWLEDGE when the available evidence doesn't fully support a definitive answer
    5. DISTINGUISH between directly stated facts and inferred connections"""
    
        response = await client.chat.completions.create(
            model=model,
            messages=[
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": f"""Context:
    {context}
    
    Question: {query}
    
    Provide a thorough answer that traces the relevant relationship chains and 
    cites specific evidence from both the knowledge graph and text passages."""}
            ],
            temperature=0.2
        )
        
        return response.choices[0].message.content
    

    The Complete Pipeline: Putting It Together

    # pipeline.py
    import asyncio
    from pathlib import Path
    from extraction import extract_entities_and_relationships
    from disambiguation import EntityDisambiguator
    from graph_store import KnowledgeGraphStore
    from retrieval import HybridRetriever
    from context_assembly import ContextAssembler, generate_answer
    import hashlib
    
    class KGRAGPipeline:
        def __init__(self):
            self.graph_store = KnowledgeGraphStore()
            self.retriever = HybridRetriever()
            self.assembler = ContextAssembler()
            self.disambiguator = EntityDisambiguator()
        
        async def ingest_document(
            self, 
            text: str, 
            source_name: str,
            chunk_size: int = 800,
            chunk_overlap: int = 100
        ) -> None:
            """Full ingestion: chunk, extract, embed, and store."""
            chunks = self._chunk_text(text, chunk_size, chunk_overlap)
            
            # Process chunks with bounded concurrency (don't hammer the API)
            semaphore = asyncio.Semaphore(5)
            
            async def process_chunk(chunk_text: str, idx: int):
                async with semaphore:
                    chunk_id = hashlib.md5(
                        f"{source_name}_{idx}".encode()
                    ).hexdigest()
                    
                    # Extract entities and relationships
                    extraction_result = await extract_entities_and_relationships(
                        chunk_text, chunk_id
                    )
                    
                    # Store in knowledge graph
                    await self.graph_store.store_extraction_result(
                        extraction_result, 
                        self.disambiguator
                    )
                    
                    # Embed and store in vector store
                    await self._store_chunk_vector(chunk_text, chunk_id, source_name)
                    
                    print(f"Processed chunk {idx}: {len(extraction_result.entities)} entities, "
                          f"{len(extraction_result.relationships)} relationships")
            
            tasks = [
                process_chunk(chunk, i) 
                for i, chunk in enumerate(chunks)
            ]
            await asyncio.gather(*tasks)
        
        def _chunk_text(
            self, 
            text: str, 
            chunk_size: int, 
            overlap: int
        ) -> list[str]:
            """Simple sentence-aware chunking."""
            import re
            sentences = re.split(r'(?<=[.!?])\s+', text)
            
            chunks = []
            current_chunk = []
            current_size = 0
            
            for sentence in sentences:
                sentence_size = len(sentence.split())
                
                if current_size + sentence_size > chunk_size and current_chunk:
                    chunks.append(" ".join(current_chunk))
                    # Keep overlap sentences
                    overlap_sentences = []
                    overlap_size = 0
                    for s in reversed(current_chunk):
                        if overlap_size + len(s.split()) <= overlap:
                            overlap_sentences.insert(0, s)
                            overlap_size += len(s.split())
                        else:
                            break
                    current_chunk = overlap_sentences
                    current_size = overlap_size
                
                current_chunk.append(sentence)
                current_size += sentence_size
            
            if current_chunk:
                chunks.append(" ".join(current_chunk))
            
            return chunks
        
        async def _store_chunk_vector(
            self, 
            text: str, 
            chunk_id: str, 
            source: str
        ) -> None:
            from openai import AsyncOpenAI
            from qdrant_client.models import PointStruct
            import uuid
            
            openai_client = AsyncOpenAI(api_key=config.openai_api_key)
            
            response = await openai_client.embeddings.create(
                model=config.embedding_model,
                input=text
            )
            vector = response.data[0].embedding
            
            await self.retriever.qdrant.upsert(
                collection_name=config.collection_name,
                points=[PointStruct(
                    id=str(uuid.uuid4()),
                    vector=vector,
                    payload={
                        "chunk_id": chunk_id,
                        "text": text,
                        "source": source
                    }
                )]
            )
        
        async def query(self, question: str) -> str:
            """End-to-end query: retrieve, assemble context, generate answer."""
            retrieval_result = await self.retriever.retrieve(question)
            context = self.assembler.assemble_context(retrieval_result)
            answer = await generate_answer(question, context)
            return answer
        
        async def close(self):
            await self.graph_store.close()
            await self.retriever.neo4j.close()
            await self.retriever.qdrant.close()
    
    
    # Example usage
    async def main():
        pipeline = KGRAGPipeline()
        
        # Initialize schema
        await pipeline.graph_store.initialize_schema()
        
        # Create Qdrant collection
        from qdrant_client.models import Distance, VectorParams
        await pipeline.retriever.qdrant.create_collection(
            collection_name=config.collection_name,
            vectors_config=VectorParams(
                size=config.embedding_dim, 
                distance=Distance.COSINE
            )
        )
        
        # Ingest a document
        sample_text = Path("research_paper.txt").read_text()
        await pipeline.ingest_document(sample_text, "ACCORD_trial_2008")
        
        # Query with multi-hop reasoning
        answer = await pipeline.query(
            "Which researchers from the ACCORD trial later collaborated on "
            "SGLT2 inhibitor studies, and what outcomes did those studies examine?"
        )
        print(answer)
        
        await pipeline.close()
    
    if __name__ == "__main__":
        asyncio.run(main())
    

    Hands-On Exercise

    Build a KG-RAG system over a domain you care about. Here's a structured exercise using publicly available data:

    Dataset: Download 20-30 Wikipedia articles about interconnected topics. For example: major technology companies (Apple, Google, Microsoft, Amazon), their founders, products, and acquisitions.

    Tasks:

    1. Ingest the corpus using the pipeline above. After ingestion, connect to Neo4j Browser (http://localhost:7474) and run MATCH (n) RETURN n LIMIT 100 to visualize your graph. You should see a rich network of companies, people, and products connected by relationships.

    2. Test single-entity queries first to validate your vector search: "What products does Apple make?" This should work well with pure vector search as a baseline.

    3. Test multi-hop queries that require graph traversal:

      • "Are there any people who founded companies that later acquired each other's subsidiaries?"
      • "Which founders attended the same university and later competed in the same market?"
    4. Measure the difference: For each multi-hop query, compare the answer from your KG-RAG system against a standard RAG system (same vector store, no graph). Log which specific graph paths were traversed to answer each question.

    5. Tune your disambiguation threshold: Find two entities in your corpus that should be the same node but aren't (or two that are different but got merged). Adjust the 0.92 threshold in EntityDisambiguator and observe the effect.


    Common Mistakes & Troubleshooting

    Mistake 1: Over-broad Entity Schemas

    The temptation is to extract every possible entity type. Resist it. Every entity type you add multiplies the disambiguation problem and graph complexity. Start with 5-7 entity types that are central to your domain. You can always add more.

    If your graph looks like a hairball with 50 node types, you've over-extracted. Good graphs have a small number of high-connectivity node types.

    Mistake 2: Ignoring Relationship Direction

    In Neo4j, (A)-[:AUTHORED]->(B) and (B)-[:AUTHORED]->(A) are different. If your extraction pipeline isn't consistent about direction, you'll create traversal queries that miss relationships or double-count them. Enforce direction in your schema documentation and validate it in your extraction prompt.

    Mistake 3: Forgetting to Chunk Before Extracting

    Running entity extraction on entire documents wastes tokens and produces poor results. LLMs have limited attention — they do better extraction on 400-800 word chunks than on 10,000-word documents. Always chunk first.

    Mistake 4: Using `MERGE` on Non-Indexed Properties

    This will kill you at scale. If you run MERGE (e:Entity {name: $name, entity_type: $type}) without an index on (name, entity_type), Neo4j does a full node scan on every merge operation. With 100,000 entities, ingestion slows from seconds to hours.

    -- Check if your indexes exist
    SHOW INDEXES
    

    Mistake 5: Not Handling LLM Extraction Failures

    The LLM will occasionally return malformed JSON, hallucinate relationship types not in your schema, or return an empty extraction for valid content. The tenacity retry decorator helps with transient failures, but you also need schema validation:

    try:
        result = ExtractionResult(**raw)
    except ValidationError as e:
        # Log the failure, continue without this chunk's graph data
        logger.warning(f"Extraction validation failed for chunk {chunk_id}: {e}")
        return ExtractionResult(entities=[], relationships=[], chunk_id=chunk_id)
    

    Mistake 6: Max Hops Too High

    Setting max_hops=5 or higher on a large graph will produce astronomically long traversals and likely timeout. In practice, 2-3 hops is sufficient for most multi-hop reasoning. 4+ hops typically produce noise — relationships that are technically connected but semantically irrelevant to the query.

    Troubleshooting: Empty Graph Traversal Results

    If multi_hop_traversal returns empty paths even though you've ingested documents:

    1. Verify entities exist: MATCH (n:Entity) RETURN count(n)
    2. Verify relationships exist: MATCH ()-[r]->() RETURN type(r), count(r) ORDER BY count(r) DESC
    3. Check that the full-text index is populated: CALL db.index.fulltext.queryNodes('entity_name_index', 'YourEntityName') YIELD node RETURN node
    4. Verify APOC is installed: RETURN apoc.version()

    Troubleshooting: High Hallucination in Extracted Relationships

    If you're seeing many spurious relationships in your graph, the most likely culprits are:

    • Chunk boundaries cutting mid-relationship: A sentence starts in one chunk and ends in another, making the LLM guess at the relationship. Increase chunk overlap.
    • Confidence threshold not being enforced: Add a filter to only store relationships with confidence > 0.6 during ingestion.
    • Temperature too high in extraction: Confirm you're using temperature=0.1 or lower.

    Summary & Next Steps

    You've built a system that genuinely solves a problem that pure vector search cannot. The architecture — entity extraction, disambiguation, graph storage, hybrid retrieval, and context-aware generation — is production-ready and extensible.

    Here's what you've actually built and why each part matters:

    The extraction pipeline transforms unstructured text into structured knowledge, with LLM-powered extraction for domain-specific entities and embedding-based disambiguation to prevent graph fragmentation.

    The knowledge graph in Neo4j stores not just entities but the relationships between them — enabling Cypher queries that traverse paths no embedding space can represent.

    The hybrid retriever combines the best of both worlds: vector search for semantic relevance as an entry point, graph traversal for relational completeness. Neither alone is sufficient.

    The context assembler ensures the LLM receives structured, well-organized evidence that distinguishes graph-derived relational facts from chunk-retrieved semantic evidence.

    Where to go from here:

    • Temporal graphs: Add timestamps to relationships to support queries like "What was the state of this research network in 2019?" Neo4j's temporal indexing makes this tractable.
    • Confidence-weighted traversal: Implement Dijkstra-weighted path finding where edge weights are your extraction confidence scores. This surfaces high-confidence paths over tenuous chains.
    • Dynamic graph updates: Build an incremental ingestion pipeline that adds new documents without re-processing the entire corpus. The MERGE pattern already supports this architecturally.
    • Graph-native reranking: After hybrid retrieval, use a graph centrality metric (PageRank on entities, betweenness centrality on relationships) to rerank results before context assembly.
    • Query planning: For very complex queries, add a planning step that decomposes the question into sub-queries, executes each against the hybrid retriever, and synthesizes results. This is where the real frontier of multi-hop reasoning lies.

    The knowledge graph approach scales well because relationships are explicit — you don't need to pray that the right chunks land in your context window. When a new paper citing the ACCORD trial gets ingested, those edges are immediately traversable. That's a fundamentally different capability than anything pure vector search can offer.

    Learning Path: Building with LLMs

    Previous

    Orchestrating Parallel LLM Calls: Batching, Concurrency, and Async Patterns for High-Throughput Production Pipelines

    Related Articles

    AI & Machine Learning🔥 Expert

    LLM Memory Architecture for Enterprise Applications: Conversation History, Summarization Buffers, and Stateful Context Management

    27 min
    AI & Machine Learning⚡ Practitioner

    Tool Memory vs. Retrieval Memory in AI Agents: Implementing Short-Term, Long-Term, and Episodic Memory Architectures

    24 min
    AI & Machine Learning⚡ Practitioner

    Orchestrating Parallel LLM Calls: Batching, Concurrency, and Async Patterns for High-Throughput Production Pipelines

    23 min

    On this page

    • Prerequisites
    • Why Multi-Hop Reasoning Breaks Pure RAG
    • The Architecture at a Glance
    • Setting Up Your Environment
    • The Entity and Relationship Extraction Pipeline
    • Defining Your Schema
    • The Extraction Prompt
    • Entity Disambiguation
    • Building the Knowledge Graph in Neo4j
    • The Hybrid Retrieval Engine
    • Context Assembly and Prompt Construction
    • The Complete Pipeline: Putting It Together
    • Hands-On Exercise
    • Common Mistakes & Troubleshooting
    • Mistake 1: Over-broad Entity Schemas
    • Mistake 2: Ignoring Relationship Direction
    • Mistake 3: Forgetting to Chunk Before Extracting
    • Mistake 4: Using `MERGE` on Non-Indexed Properties
    • Mistake 5: Not Handling LLM Extraction Failures
    • Mistake 6: Max Hops Too High
    • Troubleshooting: Empty Graph Traversal Results
    • Troubleshooting: High Hallucination in Extracted Relationships
    • Summary & Next Steps