Phase 2 Build — Multi-Source Retrieval Oracle
A ten-step build guide for a three-engine retrieval system: SQL for the hard numbers, FAISS for what people say, Neo4j for what it is connected to — fused by a compiled DSPy module into one structured verdict, with a rule-based judge and graceful degradation.
Assumes you know
After this you should be able to
- Split an entity's attributes across structured, semantic and graph stores so each engine answers a different question
- Run three retrieval engines in parallel and degrade gracefully when one is down
- Compile a DSPy module against a rule-based judge and show the before/after difference
AI & Retrieval · Project Walkthrough
Three retrieval engines answer the same question in three different ways. Then a compiled DSPy module reasons across all three and commits to one structured verdict.
The three engines#
These three names recur on every page of this build. Each keeps its colour throughout — in the diagram, in the code labels, in the UI columns — so you can always tell at a glance which engine a piece of the system belongs to.
What are the hard numbers?
Ratings, returns, chapter counts, dates. Anything you would filter or sort on. Exact, cheap, and useless for nuance.
What do people say about it?
Reviews, commentary, synopses. Free text retrieved by meaning, not keywords — with a keyword pass layered on top for names and IDs.
What is it connected to?
Authors, managers, wallets, rivals, shared universes. Questions about relationships, which neither of the other two answers well.
The shape of one request#
Fig. 1 — Every engine is independently testable before anything connects. Mirror this diagram in your README; graders look for it.
Explicitly out of scope (that is Phase 3): LangGraph agents, tool loops, multi-agent supervisors, caching, fallback cascades. The DSPy step here is one compiled call, not an agent.
Scaffold this first#
Every step below has a home in this tree. Create it now so nothing lands in the wrong file later.
| Layer | Tool | Role |
|---|---|---|
| Structured | SQLite | numeric and categorical facts |
| Semantic | FAISS + sentence-transformers | hybrid text retrieval |
| Graph | Neo4j (Docker) | relationship traversal |
| Synthesis | DSPy | compiled ChainOfThought |
| Presentation | Streamlit | side-by-side transparency |
Pick your domain#
The build is identical across all four. Pick one, confirm it with your instructor, and read everything below with that row in mind. The worked example is the first.
| Domain | SQL | FAISS | Graph | Verdict |
|---|---|---|---|---|
| Web novel / manga (worked example) | chapters, rating, cadence, genre | reader reviews, synopses | author, translator, shared universe | BINGE / DROP |
| Mutual fund screener | returns, expense ratio, AUM | manager commentary, analyst notes | fund house, manager, holdings overlap | BUY / AVOID |
| Crypto due diligence | market cap, volume, on-chain | whitepaper, news | wallet, dev, insider | INVEST / PASS |
| Smallcase basket picker | CAGR, volatility, rebalance freq. | rationale write-ups, manager notes | manager, stock overlap, sector | SUBSCRIBE / SKIP |
The ten-step build#
Each step names its layer, gives you the code, and ends with the one check that tells you it actually works. Do not move on until that check passes.
Design your entity schema#
Data layerDecide what lives where before writing any code. Split attributes by type: numbers to SQL, free text to FAISS, relationships to Neo4j. One manifest entry feeds all three stores.
{
"id": 1, "title": "Ashfall Reign",
// → SQL: things you would filter or sort on
"rating": 4.6, "chapters": 812, "cadence_days": 2, "genre": "cultivation",
// → FAISS: prose nobody would ever write a WHERE clause against
"reviews": "Slow start, but the arc after ch.300 is peak. No filler.",
// → Neo4j: edges to other entities, not attributes of this one
"author": "Lin Wu", "translator": "Skybound TL", "shares_universe_with": [3]
}✓ Done when a one-page schema note exists covering all three stores — before any code is written.
Build the synthetic data generator#
Data layerOne reproducible script populates all three stores from a manifest of roughly 25 entities. Running it twice must be safe.
# generate_data.py
import sqlite3, json
import numpy as np, faiss
from sentence_transformers import SentenceTransformer
from neo4j import GraphDatabase
MODEL = SentenceTransformer("all-MiniLM-L6-v2")
NOVELS = [{"id": 1, "title": "Ashfall Reign", "rating": 4.6, "chapters": 812,
"cadence_days": 2, "genre": "cultivation",
"reviews": "Slow start but the arc after ch.300 is peak.",
"author": "Lin Wu", "shares_universe_with": [3]},
# ... 24 more ...
]
def build_sql():
con = sqlite3.connect("data/novels.db")
con.execute("""CREATE TABLE IF NOT EXISTS novels(
id INTEGER PRIMARY KEY, title TEXT, rating REAL,
chapters INTEGER, cadence_days INTEGER, genre TEXT)""")
for n in NOVELS:
# INSERT OR REPLACE keeps re-runs idempotent
con.execute("INSERT OR REPLACE INTO novels VALUES (?,?,?,?,?,?)",
(n["id"], n["title"], n["rating"],
n["chapters"], n["cadence_days"], n["genre"]))
con.commit(); con.close()
print(f"[SQL] inserted {len(NOVELS)} rows")
def build_faiss():
texts = [n["reviews"] for n in NOVELS]
vecs = MODEL.encode(texts, normalize_embeddings=True)
# normalized vectors + inner product == cosine similarity
index = faiss.IndexFlatIP(vecs.shape[1])
index.add(np.array(vecs, dtype="float32"))
faiss.write_index(index, "data/reviews.faiss")
# FAISS stores vectors only — keep your own id/text sidecar
json.dump([{"id": n["id"], "title": n["title"], "text": n["reviews"]}
for n in NOVELS], open("data/reviews_meta.json", "w"))
print(f"[FAISS] embedded {len(texts)} review chunks")
def build_graph():
driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "password"))
with driver.session() as s:
s.run("MATCH (n) DETACH DELETE n") # fresh graph every run
for n in NOVELS:
# MERGE, not CREATE — no duplicate authors
s.run("MERGE (nv:Novel {id:$id, title:$title}) "
"MERGE (a:Author {name:$author}) "
"MERGE (a)-[:WROTE]->(nv)",
id=n["id"], title=n["title"], author=n["author"])
for other in n["shares_universe_with"]:
s.run("MATCH (a:Novel {id:$a}),(b:Novel {id:$b}) "
"MERGE (a)-[:SHARES_UNIVERSE]->(b)", a=n["id"], b=other)
driver.close(); print("[NEO4J] wrote nodes + edges")
if __name__ == "__main__":
build_sql(); build_faiss(); build_graph()
print("All three stores populated.")✓ Done when one run populates all three stores and prints row, vector and node-edge counts. Run it twice — the counts must not double.
Query each engine independently#
Data layerThree separate functions, each returning a labeled context string — or a tagged error, never a crash. The label matters: the model needs to know which engine said what. The FAISS function is where hybrid retrieval lives.
# engines.py
import sqlite3, json
import numpy as np, faiss
from sentence_transformers import SentenceTransformer
from neo4j import GraphDatabase
MODEL = SentenceTransformer("all-MiniLM-L6-v2")
def query_sql(title: str) -> str:
try:
con = sqlite3.connect("data/novels.db")
row = con.execute("SELECT rating,chapters,cadence_days,genre "
"FROM novels WHERE title=?", (title,)).fetchone()
con.close()
if not row:
return "[SQL] no structured record found"
r, ch, cad, g = row
return f"[SQL] rating={r}/5, chapters={ch}, cadence={cad}d, genre={g}"
except Exception as e:
return f"[SQL ERROR] {e}" # tagged, never raised
def query_faiss(title: str, k: int = 3) -> str:
try:
index = faiss.read_index("data/reviews.faiss")
meta = json.load(open("data/reviews_meta.json"))
# keyword precision: exact titles a vector search can miss
exact = [m for m in meta if m["title"].lower() == title.lower()]
# dense recall: semantically close reviews sharing no words
qv = MODEL.encode([title], normalize_embeddings=True)
_, I = index.search(np.array(qv, dtype="float32"), k)
semantic = [meta[i] for i in I[0]]
hits = (exact + semantic)[:k] # hybrid fusion, exact first
return "[FAISS] " + " | ".join(h["text"] for h in hits)
except Exception as e:
return f"[FAISS ERROR] {e}"
def query_graph(title: str) -> str:
try:
driver = GraphDatabase.driver("bolt://localhost:7687", auth=("neo4j", "password"))
with driver.session() as s:
# OPTIONAL MATCH so a novel with no siblings still returns
rec = s.run("MATCH (a:Author)-[:WROTE]->(n:Novel {title:$t}) "
"OPTIONAL MATCH (n)-[:SHARES_UNIVERSE]->(o:Novel) "
"RETURN a.name AS author, collect(o.title) AS universe",
t=title).single()
driver.close()
if not rec:
return "[GRAPH] no graph record found"
return f"[GRAPH] author={rec['author']}, shared_universe={rec['universe']}"
except Exception as e:
return f"[GRAPH ERROR] {e}" # e.g. Neo4j container down✓ Done when each function runs standalone from a Python shell and returns sane output for a known entity — and a tagged error string for an unknown one.
Parallelize retrieval#
Data layerFire all three at once and tolerate any one failing. These are I/O-bound calls, so threads are the right tool — total latency becomes the slowest engine, not the sum of all three.
# retrieve.py
import time
from concurrent.futures import ThreadPoolExecutor
from engines import query_sql, query_faiss, query_graph
def parallel_retrieve(title: str) -> dict:
with ThreadPoolExecutor(max_workers=3) as ex:
futures = {"sql": ex.submit(query_sql, title),
"faiss": ex.submit(query_faiss, title),
"graph": ex.submit(query_graph, title)}
# each engine already returns a tagged string, so .result()
# never raises and one dead store cannot take down the request
return {k: f.result() for k, f in futures.items()}
if __name__ == "__main__":
t = time.time()
print(parallel_retrieve("Ashfall Reign"))
print(f"latency {time.time()-t:.2f}s (slowest engine, not the sum)")✓ Done when killing any one store still produces a usable two-engine result.
Define the DSPy signature and module#
Intelligence layerNow — and only now — the intelligence layer. A signature declares the shape of the task; DSPy writes the prompt. Note the two models: a strong teacher to generate examples, a cheap student to serve them.
# synth.py
import dspy
teacher = dspy.LM("openai/gpt-4o", temperature=0.7) # generates demos
student = dspy.LM("openai/gpt-4o-mini", temperature=0.0) # serves traffic
dspy.configure(lm=student)
class ReadVerdict(dspy.Signature):
"""Decide BINGE or DROP for a web novel, reasoning across
structured stats, reader reviews, and graph context."""
sql_context: str = dspy.InputField(desc="ratings, chapters, cadence")
faiss_context: str = dspy.InputField(desc="reader review snippets")
graph_context: str = dspy.InputField(desc="author / shared universe")
verdict: str = dspy.OutputField(desc="exactly BINGE or DROP")
score: int = dspy.OutputField(desc="confidence 0-100")
rationale: str = dspy.OutputField(desc="2-3 sentence justification")
class Oracle(dspy.Module):
def __init__(self):
super().__init__()
# ChainOfThought makes the model reason before committing
self.synth = dspy.ChainOfThought(ReadVerdict)
def forward(self, ctx: dict):
return self.synth(sql_context=ctx["sql"],
faiss_context=ctx["faiss"],
graph_context=ctx["graph"])✓ Done when an uncompiled call returns structurally valid output for one entity. It may be mediocre — that is what Step 7 fixes.
Write the judge metric#
Intelligence layerA rule-based True/False check with at least one hard content constraint. This is not decoration — it becomes the optimizer's objective in Step 7. A metric that never returns False cannot optimize anything.
# judge.py
BANNED = {"maybe", "unsure", "cannot decide"} # no fence-sitting
def judge(example, pred, trace=None) -> bool:
v = (pred.verdict or "").strip().upper()
if v not in {"BINGE", "DROP"}: # required enum
return False
if not 0 <= int(pred.score) <= 100: # valid range
return False
if any(b in pred.rationale.lower() for b in BANNED):
return False
return len(pred.rationale.split()) >= 8 # required substance✓ Done when you have hand-built one deliberately bad output and watched the judge reject it. Keep that example — you will show it to the grader.
Compile with BootstrapFewShot#
Intelligence layerThe step that proves you optimized rather than just prompted. The optimizer runs the module on your training inputs, keeps only the traces your judge accepts, and turns them into few-shot demos. The saved state file is your evidence.
# compile.py
import dspy
from dspy.teleprompt import BootstrapFewShot
from retrieve import parallel_retrieve
from synth import Oracle, teacher
from judge import judge
MANIFEST = ["Ashfall Reign", "Voidheart Saga", "Nine Sigil Gate"]
# real retrieval, so demos are grounded in real context shapes
trainset = []
for title in MANIFEST:
ctx = parallel_retrieve(title)
trainset.append(dspy.Example(ctx=ctx).with_inputs("ctx"))
optimizer = BootstrapFewShot(metric=judge,
teacher_settings=dict(lm=teacher),
max_bootstrapped_demos=4)
compiled = optimizer.compile(Oracle(), trainset=trainset)
compiled.save("data/oracle_compiled.json") # ← the evidence
print("Saved compiled state to data/oracle_compiled.json")✓ Done when the saved state file exists and you can show one post-compilation reasoning trace.
Compare compiled against uncompiled#
Intelligence layerRun both on an entity that was never in the training manifest and capture the difference. This is the single most persuasive thing in your README.
# compare.py
from synth import Oracle
from retrieve import parallel_retrieve
ctx = parallel_retrieve("Emberclad Throne") # held out on purpose
baseline = Oracle() # uncompiled
tuned = Oracle(); tuned.load("data/oracle_compiled.json")
b, t = baseline(ctx), tuned(ctx)
print("BEFORE:", b.verdict, "|", b.rationale)
print("AFTER :", t.verdict, "|", t.rationale)✓ Done when the compiled output is measurably more consistent — and that before/after pair is pasted into the README.
Build the transparency UI#
PresentationThree raw contexts side by side, then the verdict. The whole argument of this project is that retrieval should be visible, not hidden behind a chat bubble. Show the evidence before the answer.
# app.py — run with: streamlit run app.py
import streamlit as st
from retrieve import parallel_retrieve
from synth import Oracle
oracle = Oracle(); oracle.load("data/oracle_compiled.json")
st.title("Web Novel Oracle — BINGE or DROP")
title = st.text_input("Novel title", "Ashfall Reign")
if st.button("Run"):
ctx = parallel_retrieve(title)
# evidence first, in the same three columns every time
c1, c2, c3 = st.columns(3)
c1.subheader("SQL"); c1.code(ctx["sql"])
c2.subheader("FAISS"); c2.code(ctx["faiss"])
c3.subheader("Graph"); c3.code(ctx["graph"])
# then the verdict the model drew from it
pred = oracle(ctx)
st.header(f"Verdict: {pred.verdict} ({pred.score}/100)")
st.write(pred.rationale)✓ Done when a reviewer can pick any entity and see exactly what each engine contributed to the verdict.
Write the README and ship#
PresentationDocument setup for all three databases, including the Docker command. Your README is what is actually graded — a reviewer should get from clone to running UI in under fifteen minutes.
# Neo4j via Docker (README §2)
docker run -d --name neo4j \
-p 7474:7474 -p 7687:7687 \
-e NEO4J_AUTH=neo4j/password \
neo4j:5
# then, from the repo root
pip install -r requirements.txt
python generate_data.py # populate all three stores
streamlit run app.py # launch the transparency UI✓ Done when someone other than you has followed the README end to end without asking you a single question.
The concepts underneath#
Five ideas this build teaches. You can ship without articulating them; you cannot pass a viva without it.
rank_bm25) with
dense scores is the full version.
ChainOfThought adds reasoning. DSPy then compiles that into an
optimized prompt against your metric. Prompt engineering becomes programming against a compiler.
How to actually build it#
Ideate on paper for fifteen minutes first. Then prompt for one capability at a time, in four parts.
What already exists
File names and function signatures, not whole files. The assistant needs the shape of your repo, not its contents.
One capability
Exactly one step from the ten above. Never "retrieval and re-ranking and the UI".
Stack and boundaries
Which libraries, which file to touch, which conventions already hold in the repo.
The exact command
The command or test you will run to confirm it works — the same one from that step's check.
- Run each step's check yourself. Never trust "it works".
- ⚠ On failure, paste the exact error back into the same session. Starting a fresh chat mid-debug throws away everything the assistant learned.
- Commit when a check passes, naming the step:
Step 4: parallel retrieval. - Update the README section as you go, not at the end.
Submission and evaluation#
main when you send it is what gets graded — finalize first.
# Web Novel Oracle — Phase 2 (Option B, repo-only)
Domain: web novels / manga. Verdict: BINGE / DROP.
⚠ Neo4j via Docker required — see §2.
## 1. Architecture ASCII diagram (Fig. 1)
## 2. Setup docker run … ; pip install ; python generate_data.py
## 3. Tech stack table: SQLite / FAISS / Neo4j / DSPy / Streamlit
## 4. Evidence compiled-vs-uncompiled pair; judge rejection example
## 5. Run streamlit run app.py# .env.example — commit this file, never the real one
LLM_API_KEY=your-provider-key-here
NEO4J_URI=bolt://localhost:7687
NEO4J_USER=neo4j
NEO4J_PASSWORD=password| Dimension | What is actually checked |
|---|---|
| Correctness | Does it do what the deliverables require — verified live, not read off the README. |
| Architecture fidelity | Real three-engine parallel retrieval, and a genuinely compiled DSPy module. |
| Honesty | README marks what is live-wired versus stubbed. Overclaiming is penalized harder than honest scoping-down. |
| Reproducibility | A reviewer can run it from the README alone, within the stated time. |
| Evidence | Checklist items shown true — screenshots, logs, or a live walkthrough. |
Self-check before you submit#
These are the four questions a grader will ask. Answer them out loud before you send the link — if any answer is vague, that is the part to fix.
Q.Do all three engines return genuinely different information for the same entity?
parallel_retrieve and read the three strings side
by side. SQL should be numbers, FAISS should be prose, graph should be names and edges. If two of them
say roughly the same thing, your schema split in Step 1 was wrong — move attributes, do not patch the
prompt.Q.Does the system degrade gracefully when one engine is down?
[GRAPH ERROR] string, and a verdict drawn from the two surviving engines. A stack
trace means your try/except is around the executor instead of inside each engine.Q.Can you prove the DSPy module was compiled, not just prompted?
data/oracle_compiled.json and show the few-shot demos it
contains, then show one before/after pair from compare.py on a held-out entity. A
hand-written prompt with no saved state fails this criterion outright.Q.Does your judge metric actually reject bad output?
False it
returns. A metric that accepts everything gave the optimizer no signal, which means Step 7 did
nothing.