Study Guide · Session Notes
Why vector search and SQL both break on "who is the CEO of the parent company that acquired the startup Tony mentioned?" — and how a labeled property graph, Cypher, and a hybrid Graph RAG pipeline answer it in milliseconds. Includes a revision of the Alpha Fund hybrid tool-calling dashboard.
Telegraphic. Copy straight into your notebook. Everything here is derived from the Detailed Notes below.
| Part | Is a… | Example |
|---|---|---|
| Node | Noun / entity | Company, Executive |
| Edge | Verb / rel. (directional) | ACQUIRED, WORKS_FOR |
| Property | key:value metadata | name:"MSFT" |
(e:Executive {name:'Tony'})-[:WORKS_FOR]->(c:Company)e = alias · :Executive = label · { } =
property| CREATE | MERGE | |
|---|---|---|
| Action | always makes new node | find-or-create (upsert) |
| MSFT ×20 | 20 dupes → shattered | 1 node, all link to it |
:latest) · infra-as-code
data · logs · import · plugins7474 HTTP (human browser)
· 7687 Bolt (Python/machine driver)UNIQUE company
while True: call
tool → observe → repeat until doneBefore graphs, a recap of the Week-4/5 project so the pieces connect. The Alpha Fund Intelligence dashboard is a Streamlit app that answers questions about financial data by picking the right retrieval tool for each query.
A hybrid engine is a search system that runs two complementary retrieval methods and combines their strengths.It pairs BM25 — the "find the exact word" method (lexical search, ideal for precise things like a transaction ID) — with FAISS — the "find the thing that means the same" method (semantic/contextual search over embeddings). BM25 catches exact matches; FAISS catches meaning.
BM25 is Ctrl-F — it finds the literal string you typed. FAISS is a librarian who read every book — ask for "companies worried about margins" and she brings back the right passages even if none contain that phrase. A good assistant keeps both on staff.
LangChain's bind_tools() attaches your functions to the model. Here there are two:
query_sp500_financials (runs SQL against an S&P 500 financials table) and
search_earnings_transcript (does a similarity search over 2024 earnings-call transcripts stored
in FAISS). The LLM reads the request and routes to one, the other, or both.
The LLM decides which tool to use by reading each function's docstring written in plain English. No docstring → LangChain tool calling "will not work properly." The best practice is a clear, plain-language docstring plus schema hints (column names/types), because that plain text is the semantic signal the model routes on.
The run_agent function is a while True loop implementing agentic
reasoning: the LLM calls a tool, observes the result, appends it to the message list, then
decides whether it needs another tool. When ai_message.tool_calls comes back empty
(None), it stops and emits the final HumanMessage/answer. A single-tool question
iterates ~twice (call, then confirm done); a "boss fight" query that hits both tools iterates more.
A common misconception: that the LLM checks tools one-by-one in a for-loop. It doesn't. When you bind N tools, the framework passes the list of names + descriptions to the model, which evaluates them simultaneously and decides which are relevant. "Reading a tool and using it are different things" — it reads all descriptions at once, then executes the chosen ones.
Too many tools crowd the model's context and cause hallucination / wrong-tool picks. The fix is a filtering layer before the main LLM: either a small classification/routing agent that only holds tool names+descriptions and maps a query to a tool, or a mini vector DB of tool descriptions from which you retrieve the top ~5 relevant tools dynamically. This scales even to 1,000 tools.
Other mechanics worth remembering: temperature=0 for deterministic, context-faithful answers; a
fallback model (GPT-4o-mini) if the chosen OpenRouter model has no endpoint; the FAISS index built with
all-MiniLM-L6-v2 embeddings and a recursive splitter (chunk_size=600,
overlap=80); and executemany to bulk-insert SQL rows in one shot.
This dashboard is exactly the system that's about to fail on the multi-hop question. Hold onto two ideas: (1) hybrid = the right retriever per query, and (2) the docstring/description is how routing decisions are made. Graph RAG will reuse both — FAISS still finds the entry point, and the graph replaces the "stitch disconnected chunks" step that breaks next.
Take the motivating query again: "Who is the CEO of the parent company that recently acquired the startup Tony mentioned in his Q3 compliance report?" This is a multi-hop question — the answer isn't in one chunk or one row; you have to hop across several linked facts. Both existing tools collapse on it, for different reasons.
When FAISS embeds the query, it breaks it into semantic pieces — CEO, parent company, acquired, startup, Q3 compliance — and pulls back chunks that each resemble one of those pieces: a chunk saying "Microsoft acquired Nuance," another saying "Tony submitted the Q3 report on Friday." All true, all relevant-looking, all disconnected.
Vectors capture how alike two things are, never how they're connected. FAISS hands the LLM a pile of facts with no mathematical link saying "this Microsoft is the parent of that startup." There's no directionality — nothing encodes "A acquired B" vs "B acquired A." Pass this multi-hop query to a pure semantic router and the whole system collapses.
SQL is brilliant at aggregation (sum, min, max over rows) but a nightmare at deep
relationship mapping. To answer the question, the LLM must generate something like:
SELECT name FROM executives JOIN companies … JOIN acquisitions … JOIN companies … JOIN reports … JOIN employees …
— five hops, five JOINs.
| Problem | What goes wrong |
|---|---|
| Runtime cost | 5 JOINs computed at query time; on a 5-million-entity DB this scans and cross-references millions of rows across tables — "computationally catastrophic." |
| Schema fragility | The LLM must guess the exact schema perfectly. Miss one foreign key or table name and the query returns zero rows — a silent, confident failure. |
"When would you not use a vector DB or SQL?" is a real interview question. The crisp answer: vectors lose direction/lineage; SQL loses performance and reliability as relationship depth grows. Multi-hop relationship questions are the signature use case for a graph. This is the exact pain point Graph RAG is designed around.
You store facts as triples — subject → predicate → object (e.g. Tony — WROTE — Q3 report). The relationships are precomputed and physically stored on the hard drive, so they already exist before any question is asked. This is the opposite of SQL, where the LLM generates a query and the JOINs (the relationships) are assembled at runtime.
SQL is asking for directions and having someone calculate the route from scratch every single time. A graph is a subway map already printed on the wall — the connections are drawn once; you just trace the line with your finger. Tracing is instant; recomputing the whole city's roads is not.
When the AI queries the graph it doesn't scan millions of rows. It lands on the node (say, Tony) and walks the edges to neighboring nodes — physically hopping node → edge → node. That walk finishes in milliseconds and the output is deterministic, 100% accurate: it arrives exactly at Executive: Satya Nadella.
The whole speed advantage comes from when the relationships are built. Because edges live on disk already, the engine never pays JOIN cost at query time — it just walks pointers. The multi-hop Cypher query that would be five brutal SQL JOINs executes in about 5 ms.
Everything in Neo4j reduces to three primitives. Learn these three and the rest is syntax.
| Primitive | Grammar role | What it is | Examples |
|---|---|---|---|
| Node | Noun | A physical or conceptual entity. Every node carries a label. | :Company, :Executive, :Document |
| Edge | Verb [!] | A directional relationship connecting two nodes. | ACQUIRED, WORKS_FOR, MENTIONS |
| Property | Adjective | Key–value metadata. Can sit on a node or an edge. Schema-free. | name:"MSFT", market_cap:… |
Edges are sometimes loosely called "adjectives" and sometimes "verbs." The internally consistent, correct mapping is: nodes = nouns, edges = verbs, properties = adjectives/metadata. Edges express actions/relationships ("acquired", "works for") — those are verbs. Use this version.
Properties are schema-free: they aren't locked to a fixed column set, and different nodes of
the same label can carry different properties. A Company node might hold
{name, ticker:"MSFT", market_cap}; a Document node might hold
{title:"Q4 transcript"}. Edges can have properties too — e.g. a WORKS_FOR edge can
carry {role:"analyst"}.
As a best practice, nouns become nodes. But it can bend to the data: in a financial dataset you might
legitimately model a transaction_id as a node. Don't treat "nouns = nodes" as an absolute
law; there are slight, scenario-driven differences. [+] A useful
default: model something as a node when you'll want to traverse to and from it, not just read
it as an attribute.
Think of it as SQL for graphs. Its syntax is visual — the punctuation looks like the thing it represents.
| Symbol | Means | Why the shape |
|---|---|---|
( ) parentheses |
a node | they look like circles |
[ ] square brackets |
an edge | wraps the relationship type |
--> dash + arrow |
direction | which way the relationship points |
Inside a node you attach a label, an alias (a temporary variable, exactly like a SQL table alias), and properties in curly braces:
(e:Executive {name:'Tony'})
│ │ │
│ │ └─ property (find the node whose name = Tony)
│ └─ label :Executive
└─ alias e (reuse it later, like a SQL alias)
The same for an edge — alias, type, and optional properties:
-[w:WORKS_FOR {role:'analyst'}]->
│ │ │
│ │ └─ edge property
│ └─ relationship type
└─ alias w
MATCH is the Cypher equivalent of SQL's SELECT — it searches the database for a
specific geometric pattern. RETURN names the output columns.
Example scenario: "every company mentioned in the Q3 compliance report."
MATCH (d:Document {title:'Q3 compliance report'})-[:MENTIONS]->(c:Company)
RETURN c.company_name
Notice there are no JOINs. The query literally describes the shape of the pattern — a Document that MENTIONS a Company — and the Neo4j engine walks the precomputed edges to it. This is the entire mental shift from SQL: stop assembling relationships, start describing the picture you want.
Now the original question becomes one readable pattern. Each hop is another -[edge]->(node)
segment:
MATCH (e:Executive {name:'Tony'})-[:WROTE]->(d:Document) -[:MENTIONS]->(startup:Company) -[:ACQUIRED_BY]->(parent:Company)
MATCH (e2:Executive)-[:WORKS_FOR {role:'CEO'}]->(parent)
RETURN e2.name // parent CEO, parent.name
// → "Satya Nadella", "Microsoft" · executes in ~5 ms
Read the right-hand side of that pattern aloud: Tony wrote a document that mentions a startup acquired by a parent company whose CEO is…. The query reads like the English question. That legibility — plus zero JOINs and millisecond traversal — is the payoff of the graph model over the five-JOIN SQL nightmare.
When you build the graph, an LLM reads many documents and extracts entities. Suppose it reads 50 news articles and encounters "Microsoft" 20 times. How you write those 20 mentions decides whether your graph is usable.
CREATE always makes a brand-new node; MERGE finds an existing node or creates it only if absent (an upsert).If you CREATE (c:Company {name:'Microsoft'}) for each of the 20 mentions, Neo4j
blindly makes 20 separate Microsoft nodes. Your graph fragments into disconnected
pieces and traversal fails — there's no single Microsoft to walk to.
MERGE (c:Company {name:'Microsoft'}) first looks for a Microsoft node: if it exists, it
binds to it; if not, it creates it. The result is exactly one Microsoft node acting
like a "gravitational pull" that connects every document and executive. Rule of thumb: [+] use MERGE for entities that recur (companies,
people) and CREATE for genuinely new, unique facts.
Crucially, you don't throw away FAISS. It's a hybrid: each tool does the part it's best at.
This is the synthesis of the whole topic: FAISS solves the entry problem (which of 10k Tonys?), the graph solves the lineage problem (how are these facts connected?), and the LLM solves the language problem (turn the subgraph into a sentence). Each tool does only what it's best at — the exact hybrid philosophy from the Alpha Fund dashboard, now extended with a graph.
If the database disappears every time you restart Python, "you're building a toy, not an application." The
graph DB should run independently of your code, accept secure connections, and persist
data. You define it in a docker-compose.yml — infrastructure as code — so
every developer gets an identical environment, and you can later ship the same container to an EC2 instance
or Kubernetes without "works on my machine" problems.
:latest
Always pin the Neo4j image to a specific version. :latest drifts as
dependencies change over time, breaking reproducibility across the team. Version-pinning is what
guarantees the "identical environment" promise of infra-as-code.
Containers are ephemeral: if the container crashes, everything inside is deleted. Volume mounts map container folders to your local hard drive so the data survives. A helpful image: you "drill a hole in the Docker ship" so the Neo4j data leaks out onto your laptop and persists even if you destroy the container.
A container is a ship; whatever's on deck sinks with it. A volume is a hose
running from the deck to a tank on shore — the cargo (your graph data) is stored on land,
so scuttling the ship loses nothing. Four folders get mapped ashore: data,
logs, import, plugins.
| Port | Protocol | For | Used by |
|---|---|---|---|
7474 |
HTTP | the Neo4j browser — visual UI to draw graphs and write Cypher by hand | humans |
7687 |
Bolt | a binary driver connection, like a DB connector for Python | machines / LangChain / scripts |
Bolt is the graph analog of using psycopg/SQLite to connect a relational DB to
Python. Any LangChain agent or Python script talks to Neo4j over 7687, with auth (user
neo4j + password).
Neo4j is a pure graph database, but LLM integration needs data passed as JSON. APOC handles that string manipulation and can run complex procedures, converting nodes/edges to/from JSON when building the Graph RAG database. You enable it with a plugin setting so Docker auto-downloads and installs it on boot.
Just like SQL injection, Cypher injection is a real vulnerability. Never build queries
with Python f-strings. Pass values as isolated parameters (e.g. $author,
$startup) — a standard MLOps best practice. The query is fixed; only the parameter map
changes.
# parameterized — safe
query = "MERGE (e:Executive {name: $author})"
graph_db.execute_write(query, {"author": "Tony"})
To enforce clean structure, add uniqueness constraints — the graph equivalent of a SQL
primary key. Declaring company, executive, and document unique
guarantees Microsoft can't be created twice, locking in the MERGE behavior at the schema level. Reads use an
execute_read function (like SELECT); writes use execute_write (a
DDL-style command that creates data via CREATE/MERGE).
Graphs can handle images/diagrams too. Use a vision model (e.g. CLIP or GPT-4o) to parse a diagram into
visual components, store each as a node with metadata (filename, plus the vector embedding)
and connect them with DESCRIBES edges; keep raw images in object storage (S3/GCS/Azure
Blob). Then you can ask "which services are affected if auth fails?" and the system traces dependencies
through the graph. [+] CLIP/GPT-4o naming per the session's example.
Tap a question to reveal a 2–4 sentence answer you can read aloud.
Vectors encode similarity, so cosine similarity measures distance, not lineage. FAISS returns individually-relevant chunks but has no mathematical way to know that "this Microsoft" is the parent of "that startup." The LLM receives disconnected facts with no directional link, so it can't reliably chain them.
SQL excels at aggregation but computes JOINs at runtime. A five-hop question means five JOINs scanning and cross-referencing potentially millions of rows — computationally catastrophic on large datasets. It's also fragile: the LLM must guess the schema perfectly, and one wrong foreign key silently returns zero rows.
Nodes (nouns/entities with labels, e.g. :Company), edges (verbs — directional
relationships like ACQUIRED), and properties (schema-free key–value metadata that can
sit on either a node or an edge). Neo4j is built entirely on these three.
MATCH is read-only — the equivalent of SQL SELECT, finding an existing geometric
pattern. MERGE is an upsert: it looks for a node and binds to it if found, or creates it if not.
MATCH queries the graph; MERGE safely builds it without duplicates.
CREATE always makes a new node, so extracting "Microsoft" 20 times yields 20 disconnected duplicates and a shattered, un-traversable graph. MERGE guarantees a single canonical node that acts as a hub, letting every document and executive connect to the same Microsoft. Pair it with uniqueness constraints to enforce this at the schema level.
First, FAISS vector search finds the starting node (e.g. the right "Tony" among thousands). Second, graph traversal walks a fixed hop radius (say 2 hops) to pull every connected entity. Third, that coherent subgraph — not loose chunks — is handed to the LLM, which produces the final answer. Vectors handle entry; the graph handles relationships.
7474 is the HTTP port for the Neo4j browser — a visual UI for humans to draw graphs and
write Cypher manually. 7687 is the Bolt port, a binary protocol for machines; any
Python script or LangChain agent connects through it, analogous to a database driver like psycopg
for Postgres.
APOC (Awesome Procedures On Cypher) is a Neo4j plugin adding utilities like string manipulation and complex procedures. It's essential for LLM work because it handles JSON parsing — you can't pass graph data to an LLM directly, so APOC serializes nodes and edges to and from JSON. It's installed automatically on container boot via a plugin setting.
Building Cypher with Python f-strings opens a Cypher-injection vulnerability, the direct analog of SQL injection — a crafted input could delete or exfiltrate data. Passing values as isolated parameters keeps the query structure fixed and the data separate, which is standard MLOps security practice.
( ) node, [ ] edge,
--> direction; MATCH = SELECT, RETURN = output.Build it · Chapter 05 · Atlas
Neo4j's Cypher is just pattern matching. Here it's a match by label/property and a one-hop walk along a named edge — over the same graph from Chapter 04. Then community detection falls out as connected components (union-find).
atlas/queries.py — a query layer over the graph:
"""Chapter 05 — a mini query layer + community detection over the Graph.
Cypher is just pattern matching: find nodes by label/property, then walk a
named relationship. Communities are connected components — clusters that talk
to each other more than to the rest of the graph.
"""
from atlas.graph import Graph, seed_graph
def match(g, label=None, **props):
"""MATCH (n:Label {prop: value}) — return matching node ids."""
hits = []
for id, data in g.nodes.items():
if label and data.get("label") != label:
continue
if all(data.get(k) == v for k, v in props.items()):
hits.append(id)
return hits
def walk(g, start, rel):
"""MATCH (start)-[rel]->(x) RETURN x — one hop along a named edge."""
return [dst for r, dst in g.out[start] if r == rel]
def communities(g):
"""Connected components over the undirected graph = graph communities."""
parent = {id: id for id in g.nodes}
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
for src in g.nodes:
for _, dst in g.out[src]:
parent[find(src)] = find(dst)
groups = {}
for id in g.nodes:
groups.setdefault(find(id), []).append(id)
return sorted(groups.values(), key=len, reverse=True)
if __name__ == "__main__":
g = seed_graph()
print("Companies:", match(g, label="Company"))
print("Who does Elena work for?", walk(g, "Elena", "works_for"))
print("Communities:")
for i, c in enumerate(communities(g), 1):
print(f" {i}: {sorted(c)}")
Run it — you'll see:
Companies: ['Microsoft', 'Alpha', 'Nimbus'] Who does Elena work for? ['Nimbus'] Communities: 1: ['Alpha', 'Elena', 'Microsoft', 'Nimbus', 'Q3 Report', 'Tony']
This is one checkpoint of Atlas — a single project built across notes 04–14. Full code, setup, and the map of all checkpoints: Atlas — Build the notes ↗.