Study Notes
Project Walkthroughapplied

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.

45 min read·verified DSPy 2.5·reviewed 2026-09-06

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
DSPyBootstrapFewShotMulti-Source RetrievalProject Build

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.

Layers data → intelligence → presentation Steps 10 Submission repo-only
SQLite FAISS Neo4j DSPy Streamlit
One entity in, three independent contexts out, one fused verdict. The value is not the answer — it is that a reviewer can see all three raw contexts side by side and check the reasoning against them. Build the plumbing first and the intelligence second; the two must never be debugged at once.
0

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.

SQL
What are the hard numbers?

Ratings, returns, chapter counts, dates. Anything you would filter or sort on. Exact, cheap, and useless for nuance.

FAISS
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.

Neo4j
What is it connected to?

Authors, managers, wallets, rivals, shared universes. Questions about relationships, which neither of the other two answers well.

Why three, not one If all three engines return near-identical text for the same entity, you have built one engine three times. The grader's first check is whether the three contexts genuinely differ — so design the schema split (Step 1) with that test in mind.
1

The shape of one request#

┌───────────────────────────┐ entity name ────► │ parallel retrieval │ │ (ThreadPoolExecutor) │ └──┬──────────┬─────────┬───┘ │ │ │ query_sql query_faiss query_graph │ │ │ ┌───▼───┐ ┌───▼───┐ ┌──▼────┐ │SQLite │ │ FAISS │ │ Neo4j │ │ facts │ │ +BM25 │ │ graph │ └───┬───┘ └───┬───┘ └──┬────┘ └─────┬────┴────┬────┘ 3 labeled context strings │ ┌───────▼─────────┐ │ DSPy module │ ◄── compiled with │ ChainOfThought │ BootstrapFewShot └───────┬─────────┘ │ ┌───────▼─────────┐ ┌──────────────┐ │ structured │───►│ judge metric │ │ VERDICT │ │ (rule-based) │ └─────────────────┘ └──────────────┘

Fig. 1 — Every engine is independently testable before anything connects. Mirror this diagram in your README; graders look for it.

The golden rule of sequencing Get all three stores working and independently queryable before you touch DSPy. Steps 1–4 are pure plumbing; steps 5–8 add intelligence. Start synthesis early and you will be debugging three problems at once, with no way to tell which layer is lying to you.

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.

2

Scaffold this first#

Every step below has a home in this tree. Create it now so nothing lands in the wrong file later.

novel-oracle/ ├── data/ # generated stores (git-ignored) │ ├── novels.db # SQLite │ ├── reviews.faiss # FAISS index │ ├── reviews_meta.json # FAISS metadata sidecar │ └── oracle_compiled.json # saved DSPy optimizer state ├── generate_data.py # Step 2 — populate all three stores ├── engines.py # Step 3 — query_sql / query_faiss / query_graph ├── retrieve.py # Step 4 — parallel retrieval ├── synth.py # Step 5 — DSPy signature + module ├── judge.py # Step 6 — rule-based judge metric ├── compile.py # Step 7 — BootstrapFewShot compile ├── compare.py # Step 8 — uncompiled vs compiled ├── app.py # Step 9 — Streamlit transparency UI ├── .env.example # provider keys (never real secrets) ├── requirements.txt └── README.md # Step 10
LayerToolRole
StructuredSQLitenumeric and categorical facts
SemanticFAISS + sentence-transformershybrid text retrieval
GraphNeo4j (Docker)relationship traversal
SynthesisDSPycompiled ChainOfThought
PresentationStreamlitside-by-side transparency
3

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.

DomainSQLFAISSGraphVerdict
Web novel / manga (worked example)chapters, rating, cadence, genrereader reviews, synopsesauthor, translator, shared universeBINGE / DROP
Mutual fund screenerreturns, expense ratio, AUMmanager commentary, analyst notesfund house, manager, holdings overlapBUY / AVOID
Crypto due diligencemarket cap, volume, on-chainwhitepaper, newswallet, dev, insiderINVEST / PASS
Smallcase basket pickerCAGR, volatility, rebalance freq.rationale write-ups, manager notesmanager, stock overlap, sectorSUBSCRIBE / SKIP
Academic-integrity bar Same architecture as the reference, a genuinely different domain, your own decisions. A cosmetic rename that leaves logic, data and prompts unchanged is not acceptable. Study the reference; do not clone it.
4

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.

1

Design your entity schema#

Data layer

Decide 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.

2

Build the synthetic data generator#

Data layer

One 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.

3

Query each engine independently#

Data layer

Three 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.

4

Parallelize retrieval#

Data layer

Fire 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)")
The test graders actually run Stop your Neo4j container, then run this file. It must still return SQL and FAISS with a tagged graph error. If you get a stack trace, your error handling is in the wrong place — it belongs inside each engine, not around the executor.

✓ Done when killing any one store still produces a usable two-engine result.

5

Define the DSPy signature and module#

Intelligence layer

Now — 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.

6

Write the judge metric#

Intelligence layer

A 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.

7

Compile with BootstrapFewShot#

Intelligence layer

The 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.

8

Compare compiled against uncompiled#

Intelligence layer

Run 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.

9

Build the transparency UI#

Presentation

Three 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.

10

Write the README and ship#

Presentation

Document 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.

5

The concepts underneath#

Five ideas this build teaches. You can ship without articulating them; you cannot pass a viva without it.

Hybrid search — BM25 + dense + RRF Keyword search and meaning search fail in opposite ways. BM25 ranks by exact terms, which is exactly what you want for rare names and IDs. Dense vectors find semantic matches that share no words at all. Reciprocal Rank Fusion merges the two ranked lists by rank position rather than score, so documents both methods like float to the top without needing comparable scales. Step 3's "exact first, then semantic" is this idea in miniature; fusing real BM25 (rank_bm25) with dense scores is the full version.
GraphRAG — retrieval over relationships Some questions are about connections, not documents. "Which novels share this author's translation team?" is a traversal, not a text lookup. Neo4j stores entities as nodes and relationships as typed edges, queried with Cypher. Feeding traversal results to the model captures a dimension neither rows nor vectors represent well.
DSPy — declarative prompting You declare the task's shape; the framework writes the prompt. A signature types inputs to outputs; a module like ChainOfThought adds reasoning. DSPy then compiles that into an optimized prompt against your metric. Prompt engineering becomes programming against a compiler.
BootstrapFewShot — teacher and student A strong teacher generates good examples; a cheap student learns to imitate them. The optimizer runs the module on training inputs, keeps the traces that pass your judge, and installs them as few-shot demos. The cheaper model then performs closer to the expensive one — and the saved state file is what proves optimization actually happened.
Judges and graceful degradation Two habits that separate a system from a demo. A rule-based judge gives an objective, repeatable signal to optimize against — more reliable here than asking another model. Graceful degradation means a dead engine returns a tagged error, not a stack trace, and the system still answers with what it has. Both are explicitly graded.
6

How to actually build it#

Ideate on paper for fifteen minutes first. Then prompt for one capability at a time, in four parts.

Context
What already exists

File names and function signatures, not whole files. The assistant needs the shape of your repo, not its contents.

Goal
One capability

Exactly one step from the ten above. Never "retrieval and re-ranking and the UI".

Constraints
Stack and boundaries

Which libraries, which file to touch, which conventions already hold in the repo.

Definition of done
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.
7

Submission and evaluation#

Your path — repo only Neo4j plus Docker rarely deploys cleanly to a free host, so submit a GitHub repo whose README lets a reviewer run it locally in under fifteen minutes. Submission is just the repo link. Whatever is on 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
DimensionWhat is actually checked
CorrectnessDoes it do what the deliverables require — verified live, not read off the README.
Architecture fidelityReal three-engine parallel retrieval, and a genuinely compiled DSPy module.
HonestyREADME marks what is live-wired versus stubbed. Overclaiming is penalized harder than honest scoping-down.
ReproducibilityA reviewer can run it from the README alone, within the stated time.
EvidenceChecklist items shown true — screenshots, logs, or a live walkthrough.
8

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?
Run one entity through 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?
Stop the Neo4j container and run the same query. You should get SQL and FAISS results plus a [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?
Point at 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?
Show the deliberately bad prediction you built in Step 6 and the False it returns. A metric that accepts everything gave the optimizer no signal, which means Step 7 did nothing.
9

The traps that cost the most marks#

Three engines, one answer If SQL, FAISS and Neo4j return near-identical text, you built one engine three times. This is the single most common failure, and it is decided in Step 1, not Step 3.
A hand-written prompt dressed up as DSPy No saved compiled state means no evidence, which means no marks for the optimization criterion — regardless of how good the output looks.
A judge that never says no If it cannot reject anything, it is not a metric, and the optimizer had nothing to select on.
Crashing on a dead engine Test it by actually stopping Neo4j. Reading the code and concluding "it should be fine" is not the test.
A shallow domain swap Same logic, same data shapes, same prompts as the reference with names changed is an integrity violation, not a submission.
Silent lateness Blocked by a broken dependency or a host outage? Message the instructor before the deadline. A flagged problem is a scheduling issue; an unflagged one is a missed submission.
10

One-line recall#

plumbing before intelligence tagged errors, never crashes three engines, three answers exact + semantic = hybrid judge must be able to say no saved state = evidence evidence before verdict README is the deliverable