skip to content
Site header image Nayana’s Blog

Graph-Augmented RAG: Beyond Vector Similarity for Private Equity Research

How combining knowledge graphs with embeddings outperforms simple semantic search


Traditional RAG retrieves documents based on how similar they are to your query. That works fine for general Q&A, but it falls apart in specialized domains where relationships matter as much as content.


Consider private equity deal research. An investor asks: "Find precedents for a US healthcare roll-up with aggressive add-on strategy." A pure vector search might return deals with similar descriptions, but miss the platform that acquired 12 add-ons—crucial context buried in structural relationships, not text.

I built DealGraph, a graph-augmented RAG agent that fuses semantic embeddings with a knowledge graph. The result: a hybrid retrieval system where structural features—sector membership, add-on relationships, exit events—augment pure text similarity.

Here's what I learned.


The Problem: Relationships Matter

Private equity research isn't just about finding similar text. It's about finding precedents: deals that share strategic patterns, not just vocabulary.

Consider two deals:

  1. Deal A: Description mentions "healthcare consolidation"
  2. Deal B: Description is vague, but the graph shows 8 add-on acquisitions in healthcare

A cosine similarity search ranks Deal A higher. But Deal B is the better precedent—it demonstrated the actual roll-up strategy the investor is researching.

The solution: make relationships first-class features.


The Architecture: A Three-Layer Pipeline

DealGraph orchestrates retrieval through three coordinated layers:

┌─────────────────────────────────────────────────────────────────┐
│                         User Query                               │
│    "Find precedents for US healthcare roll-up with add-ons"     │
└───────────────────────────┬─────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│                   RETRIEVAL LAYER                                │
│  ┌──────────────────┐          ┌────────────────────────────┐   │
│  │  FAISS Index     │          │    NetworkX Graph          │   │
│  │  (Embeddings)    │          │    (Relationships)         │   │
│  │                  │          │                            │   │
│  │  Query → Vector  │          │  Deal ─┬─ IN_SECTOR ──→    │   │
│  │  Top-K by cosine │          │        ├─ ADDON_TO ───→    │   │
│  └────────┬─────────┘          │        └─ EXITED_VIA ─→    │   │
│           │                    └──────────────┬─────────────┘   │
│           └────────────┬──────────────────────┘                 │
│                        ▼                                         │
│              Candidate Pool + Graph Features                     │
└────────────────────────┬────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│                    RANKING LAYER                                 │
│                                                                  │
│    ┌────────────────────────────────────────────────────────┐   │
│    │  Gradient Boosting Ranker (DealRanker)                 │   │
│    │                                                         │   │
│    │  Features: text_similarity, sector_match, num_addons,  │   │
│    │           has_exit, degree, region_match, is_platform  │   │
│    │                                                         │   │
│    │  Training: Reverse-query generation (LLM synthesizes   │   │
│    │            queries for known-good deals)               │   │
│    └────────────────────────────────────────────────────────┘   │
└────────────────────────┬────────────────────────────────────────┘


┌─────────────────────────────────────────────────────────────────┐
│                   REASONING LAYER                                │
│                                                                  │
│    ┌────────────────────────────────────────────────────────┐   │
│    │  DSPy-Optimized Deal Reasoner                          │   │
│    │                                                         │   │
│    │  Outputs:                                               │   │
│    │  • Precedent selection (JSON)                          │   │
│    │  • Playbook levers (strategic patterns)                │   │
│    │  • Risk themes                                          │   │
│    │  • Executive narrative                                  │   │
│    └────────────────────────────────────────────────────────┘   │
└────────────────────────┬────────────────────────────────────────┘


                  Structured Analysis

Each layer solves a specific problem:

  1. Retrieval: Cast a wide net with hybrid search
  2. Ranking: Learn what "relevance" means from training data
  3. Reasoning: Synthesize precedents into actionable insights

Lesson 1: Model Relationships as a Graph

The core insight: deals aren't documents, they're entities with relationships.

I modeled the deal universe as a typed MultiDiGraph:

# Node types
NODE_TYPES = ["Deal", "Sector", "Region", "Event", "Snippet"]

# Edge types capture relationships
EDGE_TYPES = {
    "IN_SECTOR": ("Deal", "Sector"),    # Deal belongs to sector
    "IN_REGION": ("Deal", "Region"),    # Deal in geographic region
    "ADDON_TO": ("Deal", "Deal"),       # Add-on acquired by platform
    "EXITED_VIA": ("Deal", "Event"),    # Exit event (IPO, M&A)
    "DESCRIBED_IN": ("Deal", "Snippet") # Textual evidence
}

This structure enables queries that pure text search can't answer:

  • "Find all platforms with 5+ add-ons in healthcare"
  • "Show deals that exited via IPO in the last 3 years"
  • "Find add-ons to Platform X in adjacent sectors"

The graph becomes a source of features, not just navigation.


Lesson 2: Extract Graph Features for Ranking

The key innovation: graph topology becomes a feature vector for ML ranking.

For each candidate deal, I compute structural features:

FEATURE_NAMES = [
    "text_similarity",      # From embeddings
    "sector_match",         # Query mentions same sector?
    "region_match",         # Query mentions same region?
    "num_addons",           # How many add-ons acquired
    "has_exit",             # Successful exit?
    "degree",               # Graph connectivity
    "is_platform",          # Platform vs add-on
    "sector_degree",        # How connected is the sector
    "region_degree",        # How connected is the region
    "text_graph_alignment", # Do text and graph agree?
]

These features capture things embeddings miss. A deal might have a generic description but 10 recorded add-ons—that's crucial signal.

The heuristic ranking weights these explicitly:

def compute_relevance_score(candidate):
    features = candidate.graph_features

    # Text similarity (40%)
    text_score = features['text_similarity'] * 0.4

    # Sector match (20%)
    sector_score = features['sector_match'] * 0.2

    # Region match (15%)
    region_score = features['region_match'] * 0.15

    # Add-on activity (10%)
    addon_score = min(features['num_addons'] / 3.0, 1.0) * 0.1

    # ... other features

    return text_score + sector_score + region_score + addon_score

But hand-tuning weights is fragile. That's where ML comes in.


Lesson 3: Train a Ranker on Synthetic Query-Deal Pairs

The challenge: I don't have labeled relevance data. No one has annotated thousands of "query → relevant deals" pairs for private equity.

The solution: reverse-query generation. Instead of labeling data manually, I use an LLM to synthesize queries for known deals:

def generate_reverse_queries(deal_cluster, llm):
    """
    Given a cluster of similar deals, generate realistic
    queries that would surface these deals as relevant.
    """
    prompt = f"""
    You are a private equity analyst. Given these deals:
    {format_deals(deal_cluster)}

    Generate 3 realistic search queries an investor
    might use to find deals like these.
    """

    queries = llm.generate(prompt)
    return queries

This creates training pairs:

  • Positive: (generated query, deals in cluster)
  • Negative: (generated query, random deals from other clusters)

The gradient boosting model learns which feature combinations predict relevance:

class DealRanker:
    def __init__(self):
        self.model = GradientBoostingRegressor(random_state=42)

    def fit(self, X: np.ndarray, y: np.ndarray):
        """X = feature vectors, y = relevance scores"""
        self.model.fit(X, y)
        return self

    def rank(self, candidates: List[CandidateDeal]):
        X = build_feature_matrix(candidates)
        scores = self.model.predict(X)

        ranked = sorted(
            zip(candidates, scores),
            key=lambda pair: pair[1],
            reverse=True
        )
        return [RankedDeal(c, score, rank) for rank, (c, score) in enumerate(ranked, 1)]

The trained ranker outperforms both pure embedding search and hand-tuned heuristics.


Lesson 4: Optimize Prompts Systematically with DSPy

The reasoning layer isn't just a static prompt—it's a DSPy module that can be optimized.

DSPy treats prompts as programs with learnable components. The MIPRO optimizer generates prompt variants and evaluates them against a composite metric:

# Composite evaluation metric
def composite_metric(example, prediction):
    score = (
        0.4 * precision_at_k(prediction.precedents, example.gold_precedents, k=3) +
        0.3 * llm_judge_playbook_quality(prediction.playbook_levers) +
        0.3 * llm_judge_narrative_coherence(prediction.narrative_summary)
    )
    return score

The optimizer runs ~500 LLM calls to find better prompts. Results on my benchmark:

Metric Naive Prompt MIPRO-Optimized Improvement
Precision@3 0.42 0.68 +62%
Playbook Quality 0.55 0.72 +31%
Narrative Coherence 0.61 0.78 +28%
Composite Score 0.52 0.73 +40%

The key insight: prompts are artifacts that should be versioned, evaluated, and optimized—not artisanal text you tweak by hand.

# Runtime loads optimized version automatically
reasoner = DealReasonerModule()
reasoner.load("prompts/deal_reasoner/v2_optimized.json")

# Falls back to naive baseline if optimization hasn't run

Lesson 5: Fail Loudly, Don't Degrade Silently

A tempting pattern: wrap everything in try/catch and return empty results on failure.

Don't do this.

# BAD: Silent degradation
try:
    result = reasoner(query=query, candidate_deals=deals_json)
except Exception:
    return {"precedents": [], "narrative": "Unable to analyze."}

# GOOD: Fail loudly
try:
    result = reasoner(query=query, candidate_deals=deals_json)
except Exception as e:
    raise DealReasonerError(f"Deal reasoning failed: {e}") from e

Silent failures hide bugs. In production, I'd rather see an error than serve garbage that looks like a valid response.

This extends to model loading:

# Model loading with graceful fallback (good)
if ranker_model_exists():
    ranker = DealRanker.load("models/deal_ranker_v1.pkl")
else:
    ranker = HeuristicRanker()  # Explicit fallback
    logger.warning("Using heuristic ranker - ML model not found")

# But NOT silent fallback during inference
scores = ranker.predict_scores(X)  # This should throw on failure

The Architecture Decision: NetworkX for V1

A common question: why not Neo4j or a "real" graph database?

For V1 with <1000 nodes, NetworkX is the right choice:

  • Zero infrastructure: No database to deploy
  • Fast iteration: Graph structure can change without migrations
  • Rich algorithms: NetworkX has excellent graph algorithms out of the box
  • Good enough: In-memory traversal is plenty fast at this scale

The migration triggers are clear:

  • Graph size exceeds 10K nodes
  • Query latency becomes unacceptable (>1s)
  • Need for persistence across restarts
  • Multi-user concurrent access

Until then, keep it simple.


What I'd Do Differently

1. Use a vector database with filtering: FAISS is fast but doesn't support metadata filtering. I filter post-hoc, which is wasteful. Pinecone or Weaviate would let me push filters into the index.

2. Add temporal reasoning: Deals have dates. A 2010 precedent might be less relevant than a 2023 one. The graph should encode temporal relationships.

3. Implement online learning: As users interact with results, their clicks are implicit relevance labels. The ranker should learn from this feedback.

4. Build evaluation into the pipeline: I added benchmarks late. Starting with evaluation infrastructure would have caught issues earlier.


Key Takeaways

  1. Relationships are features: Knowledge graphs aren't just for navigation—extract structural features and feed them to ML models.
  2. Hybrid retrieval beats pure embeddings: Text similarity is necessary but not sufficient. Combine it with domain-specific signals.
  3. Synthesize training data when labels don't exist: Reverse-query generation creates high-quality training pairs without manual annotation.
  4. Treat prompts as code: Version them, optimize them with metrics, and A/B test before deploying.
  5. Keep infrastructure simple: Start with in-memory solutions. Migrate when you hit actual scale limits, not imagined ones.

DealGraph is available on GitHub. The synthetic data generator creates a realistic deal universe for testing, and the full training pipeline runs on commodity hardware.