CorporateKG: Knowledge Graphs from Press Releases
Paste a corporate press release, get an interactive force-directed knowledge graph — Gemini 2.5 Flash extracts entity triplets under a strict Pydantic schema, NetworkX assembles the graph, and React draws it with hand-rolled canvas labels and curved parallel edges.
This project started as three curiosities stacked on top of each other: I wanted to learn what knowledge graphs actually are, I wanted an excuse to play with graph and network tooling, and I wanted more reps with the Gemini API. CorporateKG Extractor is where they collided. Paste a corporate press release into the left panel, hit extract, and the entities — companies, people, products, dollar amounts — appear as an interactive force-directed graph, with directed, labeled edges like ACQUIRED and APPOINTED connecting them.
Contents
- Getting Structured Triplets out of Gemini
- From Triplets to a Graph
- The Force-Directed Canvas
- Running It Locally
- Results
- Lessons Learned
Getting Structured Triplets out of Gemini
The core of a knowledge graph is the triplet: source entity, target entity, and the relationship pointing from one to the other. Instead of prompting Gemini for JSON and praying, the extraction leans on the google-genai SDK's structured output — a Pydantic schema is passed as the response_schema, and the model is constrained to return data that parses into it:
class Triplet(BaseModel):
source: str = Field(description="The primary entity.")
target: str = Field(description="The related entity.")
relationship: str = Field(description="The directional relationship in UPPER_SNAKE_CASE.")
class GraphData(BaseModel):
triplets: list[Triplet]
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=text,
config=types.GenerateContentConfig(
system_instruction=SYSTEM_INSTRUCTION,
temperature=0.1, # extraction, not creativity
response_mime_type="application/json",
response_schema=GraphData,
),
)The system instruction does the semantic heavy lifting in three lines: extract distinct entities and their directed relationships, normalize relationships to concise verbs in UPPER_SNAKE_CASE (e.g., ACQUIRED, PARTNERED_WITH, APPOINTED), and make sure relationships flow logically from source to target. Temperature sits at 0.1 because this is extraction, not generation — I want the same press release to produce the same graph every time.
Feed it "TechCorp Inc. announced the acquisition of DataFlow Analytics for $450M" and back comes TechCorp Inc. → ACQUIRED → DataFlow Analytics, plus the dollar figure and the executives as their own nodes.
From Triplets to a Graph
The backend is a small FastAPI app (main.py) with one endpoint that matters: POST /api/extract. It takes {"text": "..."}, rejects empty input with a 400, sends the text to Gemini, and hands the triplets to NetworkX:
G = nx.DiGraph()
for t in triplets:
G.add_node(t["source"])
G.add_node(t["target"])
G.add_edge(t["source"], t["target"], label=t["relationship"])
nodes = [{"id": node, "val": G.degree(node)} for node in G.nodes()]
links = [{"source": u, "target": v, "label": d["label"]} for u, v, d in G.edges(data=True)]Using a directed graph isn't cosmetic — ACQUIRED read backwards is a very different press release. And NetworkX earns its import with one method: G.degree(node) becomes each node's val, which the frontend uses to size nodes. The most-connected entity in the story is automatically the biggest circle on screen, for free.
The server runs on uvicorn at port 8000 with CORS left wide open — it's a local tool talking to a local frontend. There's also a standalone extractor.py that runs the same extraction from the command line, which is how the pipeline got built and tested before the web app existed.
The Force-Directed Canvas
The frontend (corporate-kg-ui/) is React 19 + Vite 8 with Tailwind for the dark research-console look, and react-force-graph-2d 1.29.1 doing the physics. The library handles simulation and pan/zoom; almost everything you actually see is custom canvas drawing.
Default node rendering is a circle with a floating tooltip — fine for demos, useless for reading a graph. So nodeCanvasObject draws each node as a colored circle (color hashed from the entity name, radius scaled by degree) with its name in a rounded-rect pill underneath, and linkCanvasObject draws each relationship label in a pill rotated to match its edge's angle, flipped upright when the angle would render it upside down.
The fiddliest part was parallel edges. Real press releases produce multiple relationships between the same two entities — a founder who both FOUNDED one company and JOINED another creates edge pile-ups. Straight lines would overlap into an unreadable smear, so the graph data pass groups links by node pair and assigns each a curvature: bidirectional pairs bow away from each other, and multiple same-direction edges fan out in increments. Labels follow their curve via the same offset math.
Interaction got tuned until exploring felt right. Hovering a node pins it in place (setting its fx/fy so the simulation stops dragging it out from under your cursor), highlights its neighborhood, and dims everything else; a 120 ms delay before unpinning stops flicker at node edges. Clicking pins the focus so the side panel's Focus Inspector can show the node's neighbor count and sample relations. The d3 forces themselves are hand-tuned — charge strength −420 with a capped range, link distance stretched for longer entity names — because the defaults packed everything into an overlapping clump. A stats row keeps score: node count, edge count, and relationship density.
Running It Locally
Backend first — Python 3.8+ and a Gemini API key:
pip install -r requirements.txt
echo "GEMINI_API_KEY=your_api_key_here" > .env
python main.py # API at http://localhost:8000Then the frontend, which needs Node 18+:
cd corporate-kg-ui
npm install
npm run dev # UI at http://localhost:5173The UI ships with three sample snippets — acquisitions, partnerships, executive moves — so you can test the whole pipeline without hunting down a real press release.
Results
The pipeline works end to end: paste text, get a correct, readable, explorable graph in a few seconds. Entities come back clean, relationships come back in consistent UPPER_SNAKE_CASE, and the structured-output schema means the backend has never had to rescue malformed JSON.
It's deliberately not deployed anywhere. Hosting it would mean paying to keep a server and an API key alive for a tool I built to learn from, and everything it does works identically on localhost — two commands and it's running. The learning was the deliverable, and that shipped.
Lessons Learned
- Schema-constrained output turns an LLM into a parser.
response_schemaplus a low temperature produced valid, consistent triplets from the start — no regex-rescue of malformed JSON, no retry loops. - A graph library pays for itself even on tiny graphs. NetworkX looks like overkill for twenty nodes until degree-based sizing, a proper directed-graph model, and clean node/edge iteration each cost one line.
- Labels are the hard part of graph visualization. Physics and circles come free with any force-graph library; making twenty relationship labels readable took custom canvas drawing, rotation flipping, and curvature bookkeeping for parallel edges.
- Interaction defaults are tuned for demos, not reading. Hover-pinning, neighborhood dimming, and hand-tuned forces are what turned a bouncing hairball into something you can actually study.
The whole thing is at Manixh-S/CorporateKG-Extractor — clone it, drop in a Gemini key, and feed it your favorite acquisition announcement.