The repo currently contains only a README.md describing the concept of a "Graph-RAG Recommender": vector search finds a seed node, graph traversal expands multi-hop relationships from it, and an LLM turns the resulting subgraph into a natural-language recommendation with an explicit, auditable reasoning path. There is no code yet. The goal now is to build this as a real, running system (not a notebook) — a service you can send a query to and get back a recommendation plus the graph path that justified it.
Stack decisions made with the user:
- Language: Python
- Graph DB: Neo4j, using its native vector index (5.11+) so embeddings and graph structure live in one database — no separate vector store
- Dataset: MovieLens (
ml-latest-small— 9,742 movies, 610 users, 100k ratings; matches the README's Interstellar/Inception example, fast to iterate on) - LLM: OpenAI (
gpt-4o-minifor generation,text-embedding-3-smallfor embeddings) - Enrichment: MovieLens has no director/composer/cast data, so we enrich via the free TMDb API (director, composer, top-5 cast per movie), cached to disk, one-time ~30-40 min run — this is what makes the README's exact "Interstellar → directed by Nolan → Inception" example real and demoable, not just illustrative.
v1 scope is intentionally minimal: no auth, no multi-tenancy, no scaling concerns — just a correct, working end-to-end pipeline.
vecto-graph/
├── pyproject.toml
├── .env.example
├── .gitignore
├── docker-compose.yml
├── Dockerfile
├── data/ # gitignored
│ ├── raw/ml-latest-small/
│ └── cache/tmdb_credits.json # cached TMDb responses, resumable
├── src/vecto_graph/
│ ├── config.py # pydantic-settings Settings
│ ├── db/neo4j_client.py # driver singleton + session helpers
│ ├── ingestion/
│ │ ├── download.py # fetch + unzip ml-latest-small (idempotent)
│ │ ├── tmdb_enrich.py # director/composer/cast via TMDb, cached
│ │ ├── embed.py # build embedding text, call OpenAI embeddings
│ │ ├── schema.py # constraints + vector index Cypher
│ │ ├── load_neo4j.py # batched UNWIND/MERGE writes
│ │ └── run_ingestion.py # CLI entrypoint chaining all steps
│ ├── retrieval/
│ │ ├── vector_search.py # embed query, db.index.vector.queryNodes
│ │ ├── graph_traversal.py # multi-hop Cypher from seed node(s)
│ │ └── subgraph.py # serialize Path objects -> JSON + audit trail
│ ├── generation/
│ │ ├── prompt.py # prompt template
│ │ └── llm.py # OpenAI chat completion call
│ ├── pipeline.py # recommend(query) orchestrates retrieval+generation
│ └── api/
│ ├── main.py # FastAPI: POST /recommend, GET /health
│ └── schemas.py
└── scripts/
├── sanity_check.py # Cypher count checks post-ingestion
└── test_retrieval.py # manual retrieval+generation test, no HTTP
Core deps: neo4j (official driver), openai, fastapi, uvicorn[standard], pydantic-settings, httpx, pandas, tenacity, tqdm. .gitignore covers .env, data/, .venv/, __pycache__/.
Nodes: Movie {movieId, title, year, tmdbId, imdbId, genres[], embeddingText, embedding}, Genre {name}, User {userId}, Person {personId, name} (directors/composers/cast share this label).
Relationships: (User)-[:RATED {rating, timestamp}]->(Movie), (User)-[:TAGGED {tag, timestamp}]->(Movie), (Movie)-[:IN_GENRE]->(Genre), (Person)-[:DIRECTED]->(Movie), (Person)-[:COMPOSED]->(Movie), (Person)-[:ACTED_IN {character}]->(Movie) (top-5 billed only).
CREATE CONSTRAINT movie_id IF NOT EXISTS FOR (m:Movie) REQUIRE m.movieId IS UNIQUE;
CREATE CONSTRAINT user_id IF NOT EXISTS FOR (u:User) REQUIRE u.userId IS UNIQUE;
CREATE CONSTRAINT genre_name IF NOT EXISTS FOR (g:Genre) REQUIRE g.name IS UNIQUE;
CREATE CONSTRAINT person_id IF NOT EXISTS FOR (p:Person) REQUIRE p.personId IS UNIQUE;
CREATE VECTOR INDEX movieEmbeddingIndex IF NOT EXISTS
FOR (m:Movie) ON (m.embedding)
OPTIONS { indexConfig: { `vector.dimensions`: 1536, `vector.similarity_function`: 'cosine' } };embeddingText per movie = "{title} ({year}). Genres: {genres}. Tags: {top user tags from tags.csv}." — self-contained, no extra API needed beyond calling OpenAI to embed it.
download.py— fetchml-latest-small.zipfrom GroupLens, unzip todata/raw/, skip if already present.- Parse
movies.csv,ratings.csv,tags.csv,links.csvwith pandas. tmdb_enrich.py— for eachtmdbIdinlinks.csv, call TMDbGET /movie/{tmdbId}/credits. Extractjob == "Director",job == "Original Music Composer", cast withorder < 5. Cache every response todata/cache/tmdb_credits.jsonkeyed bytmdbId(resumable — reruns skip cached ids).tenacityretry on transient errors; skip gracefully on missing/null tmdbId or 404.embed.py— buildembeddingText, callopenai.embeddings.create(model="text-embedding-3-small", input=[...])in batches of ~500.schema.py— run constraints + vector index Cypher once before any writes.load_neo4j.py— batchedUNWIND/MERGEwrites viadriver.execute_query(...), batch size ~1000, idempotent (safe to rerun).run_ingestion.py—python -m vecto_graph.ingestion.run_ingestionchains all of the above withtqdmprogress and a final node/relationship count summary.
- Embed query with the same model as ingestion.
- Vector search for seed node(s):
CALL db.index.vector.queryNodes('movieEmbeddingIndex', $topK, $queryEmbedding) YIELD node, score. - Multi-hop traversal from the seed: separate, explicit Cypher queries (not one giant query) for director-overlap, composer-overlap, genre-overlap, and co-rated-highly-by-similar-users, each returning real
Pathobjects. Merge and rank candidates in Python: +2 shared director, +2 shared composer, +1 per shared genre, +1 per distinct co-rating user (capped). Take top 3-5. - Serialize into
{seed, candidates: [{movie, reasons[], path[]}]}— used both as LLM context and as the API's audit trail.
Fixed prompt template instructing the model to recommend exactly one candidate from the provided list and explain why, citing only the given relationships (no invented facts). openai.chat.completions.create(model="gpt-4o-mini", temperature=0.4). pipeline.py exposes recommend(query) -> RecommendationResult as the single entrypoint used by both the test script and the API.
FastAPI (api/main.py): POST /recommend {"query": str, "top_k": int=5} → runs pipeline.recommend, returns recommendation text + seed movie + candidates + graph path. GET /health checks driver.verify_connectivity().
docker-compose.yml: neo4j service (neo4j:5.26-community, APOC enabled, ports 7474/7687) + app service (builds from repo Dockerfile, runs uvicorn, depends on neo4j, reads .env). Ingestion runs as a one-off (docker compose run app python -m vecto_graph.ingestion.run_ingestion), not baked into the always-on container.
config.py — pydantic-settings BaseSettings reading .env: neo4j_uri, neo4j_user, neo4j_password, openai_api_key, openai_embedding_model, openai_chat_model, tmdb_api_key. .env.example committed with placeholders only; .env gitignored; never hardcoded.
- Phase 0 — Scaffold (
pyproject.toml,src/layout,.env.example,.gitignore) +docker-compose.ymlwith justneo4j. Verify:docker compose up -d neo4j, reachhttp://localhost:7474. - Phase 1 — Full
ingestion/+ run it. Verify: counts + vector index exist + Interstellar hasDIRECTED-by-Nolan andCOMPOSED-by-Zimmer edges (queries below). - Phase 2 —
retrieval/+scripts/test_retrieval.py. Verify: query "sci-fi thriller like Interstellar" returns Interstellar as seed, Inception as top candidate with Nolan/Zimmer reasons. - Phase 3 —
generation/+pipeline.py. Verify: generated text recommends Inception citing Nolan/Zimmer. - Phase 4 —
api/FastAPI service running locally against the already-running Neo4j. Verify via curl. - Phase 5 — Add
appservice +Dockerfileto compose, full stack up. Verify same curl test end-to-end containerized.
Phase 1 sanity checks (scripts/sanity_check.py):
MATCH (m:Movie) RETURN count(m);
MATCH (u:User) RETURN count(u);
MATCH ()-[r:RATED]->() RETURN count(r);
MATCH ()-[r:DIRECTED]->() RETURN count(r);
SHOW INDEXES YIELD name, type WHERE type = 'VECTOR';
MATCH (:Movie {title: 'Interstellar (2014)'})<-[:DIRECTED]-(p:Person) RETURN p.name;
MATCH (:Movie {title: 'Interstellar (2014)'})<-[:COMPOSED]-(p:Person) RETURN p.name;Phase 2/3: python scripts/test_retrieval.py "Recommend me sci-fi thrillers like Interstellar" — assert seed contains "Interstellar", Inception appears with a Nolan/Zimmer reason, generated text reproduces the README's example.
Phase 4/5 end-to-end:
curl -X POST http://localhost:8000/recommend \
-H "Content-Type: application/json" \
-d '{"query": "Recommend me sci-fi thrillers like Interstellar"}'Confirm response mentions Inception + Nolan/Zimmer and graph_path lists the explicit traversal edges — the README's "auditable logic" made concrete.
src/vecto_graph/ingestion/run_ingestion.pysrc/vecto_graph/ingestion/tmdb_enrich.pysrc/vecto_graph/ingestion/schema.pysrc/vecto_graph/retrieval/graph_traversal.pysrc/vecto_graph/pipeline.pydocker-compose.yml