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:
- Deal A: Description mentions "healthcare consolidation"
- 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 AnalysisEach layer solves a specific problem:
- Retrieval: Cast a wide net with hybrid search
- Ranking: Learn what "relevance" means from training data
- 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_scoreBut 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 queriesThis 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 scoreThe 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 runLesson 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 eSilent 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 failureThe 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
- Relationships are features: Knowledge graphs aren't just for navigation—extract structural features and feed them to ML models.
- Hybrid retrieval beats pure embeddings: Text similarity is necessary but not sufficient. Combine it with domain-specific signals.
- Synthesize training data when labels don't exist: Reverse-query generation creates high-quality training pairs without manual annotation.
- Treat prompts as code: Version them, optimize them with metrics, and A/B test before deploying.
- 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.