Point it at a TypeScript/JavaScript repository and ask questions about it.
The repo is parsed into a knowledge graph in Neo4j — every function, method, class and type is a node, with edges for what calls what and which files import which. Answering a question searches that graph and walks its relationships, so the model sees the code you asked about plus the code around it.
Attach a PDF to a chat and it is read alongside the code, which is what you want when the answer is half in a spec and half in the implementation.
flowchart LR
url(["repo URL"]) --> api["API :8504<br/>clone + parse + embed"]
pdf(["PDF, attached to a chat"]) --> api
api --> neo4j[("Neo4j :7474<br/>code graph<br/>+ document chunks")]
q(["question"]) --> chain["GraphRAG chain"]
chain <-->|hybrid search<br/>+ graph traversal| neo4j
chain --> llm["LLM"]
llm --> ans(["answer + sources"])
Every symbol is its own node keyed by path:line:qualified_name, so a name that
repeats across the repo never collapses into one shared node.
graph LR
F1["File<br/><small>core/Ky.ts</small>"] -->|CONTAINS| C1["Code<br/><small>Ky.calculateRetryDelay</small>"]
F1 -->|CONTAINS| C2["Code<br/><small>Ky.retry</small>"]
F2["File<br/><small>utils/delay.ts</small>"] -->|CONTAINS| C3["Code<br/><small>delay</small>"]
F1 -->|IMPORTS| F2
C2 -->|CALLS| C1
C1 -->|CALLS| C3
Vector search finds an entry point. The graph supplies the context around it — that traversal is what makes this GraphRAG rather than vector search that happens to be stored in a graph.
flowchart TD
Q(["how are retries decided?"]) --> H{"hybrid search<br/>vector + full-text"}
H -->|top k = 4| HIT["matched Code node"]
HIT -->|CALLS| CALLEE["what it calls"]
HIT -->|CALLS reversed| CALLER["what calls it"]
HIT -->|CONTAINS / IMPORTS| FILE["its file + dependencies"]
CALLEE --> CTX["context block:<br/>source + // Calls / // Called by"]
CALLER --> CTX
FILE --> CTX
CTX --> LLM["LLM answers, citing file paths"]
Full-text sits alongside vectors because code questions often name a symbol exactly, which lexical matching handles better than embeddings.
When the chat has PDFs attached, their chunks are retrieved in the same pass and
share that budget rather than adding to it — k drops from 4 symbols to 2, plus
4 excerpts, which is about the same amount of context either way.
1. Copy env.example to .env. The defaults work; the one choice that
matters is the model.
2. Start it:
docker compose upAnswering questions needs an LLM. On Linux, run one in a container:
docker compose --profile linux up...with OLLAMA_BASE_URL=http://llm:11434 in .env. Add COMPOSE_PROFILES=linux
there too and a plain docker compose up starts it; leave it out and the
hostname llm does not resolve on any run where you forget the flag. Or set
LLM=gpt-3.5 plus an OPENAI_API_KEY and nothing is downloaded. Without
either, indexing still works and questions report a clear error.
3. Open http://localhost:5173, paste a repository URL on Index a repository, and press the button. Then ask questions on Ask the codebase — and use the paperclip there if a PDF belongs in the conversation too.
The importer is idempotent — re-indexing updates nodes in place.
Chats are saved in Neo4j, not in the browser, so they survive a refresh and a
docker compose down. New chat in the sidebar starts one, the list switches
between them, and each can be renamed or deleted. A chat is titled after its
first question.
(:Conversation {id, title})-[:HAS_MESSAGE]->(:Message {role, content, position})
(:Conversation)-[:USES_DOCUMENT]->(:PdfDocument {id, name, pages, chunks})
Messages carry an explicit position rather than being ordered by timestamp —
both halves of a turn are written milliseconds apart.
These labels sit outside the code graph on purpose: clearing the index from
Danger zone deletes File, Code and Repository nodes and leaves your
conversations and uploaded PDFs alone.
The paperclip in the composer uploads a PDF into the chat you are in, and from then on questions are answered from the code graph and those documents together — useful when the answer is half in a spec and half in the code. Several can be attached at once, and the retrieval budget is shared rather than added to. "LLM only" turns off the codebase, not the attachments.
Each PDF is chunked under its own document_id, and retrieval filters on the
ids attached to the conversation asking. The id is content-addressed, so the
same file uploaded into a second chat is embedded once and shared; removing it
from the last chat holding it deletes its chunks.
| URL | Service | What it is |
|---|---|---|
| http://localhost:5173 | web |
The app, in React: ask the codebase (attaching PDFs if you like), index a repository. |
| http://localhost:7474 | database |
Neo4j browser — inspect the graph directly. neo4j / password. |
| http://localhost:8504 | api |
HTTP API over the chains, conversations, PDFs and indexing. The client uses nothing else. |
One optional extra, off by default:
docker compose --profile linux up # + Ollama at :11434Dockerfile one image, shared by every Python service
web.Dockerfile node image for the React dev server
docker-compose.yml four services by default: web, api, database, pull-model
genai_stack/ the library — no UI, no app code
settings.py environment configuration, read once
extract.py tree-sitter parsing: symbols, calls, imports
code_index.py reshaping the index, resolving imports to files
schema.py Neo4j labels, constraints, indexes
ingest.py writing nodes and relationships
retrieval.py the graph traversal query + the two vector stores
llm.py loading chat and embedding models
chains.py the LLM-only and GraphRAG chains, and the retriever
that reads code and attached PDFs together
conversations.py saved chats and their messages
pdf_store.py PDFs attached to a chat, and their chunks
sources.py validating and cloning repository URLs
apps/
api.py the FastAPI service - the only backend entry point
web/ the React client (Vite, Tailwind, shadcn primitives)
src/lib/api.ts the typed API client, including the SSE streams
src/hooks/useChat.ts conversations, messages, attachments and streaming
src/views/ ChatView, IndexView
src/components/ NavRail, Sidebar, Transcript, Composer (with the
paperclip that uploads a PDF into the chat)
tests/ the index pipeline, conversations, PDF attachment
index/ generated code indexes (gitignored)
data/ Neo4j's database volume (gitignored)
Everything is optional except the model choice. Full list in env.example.
| Variable | Default | Notes |
|---|---|---|
LLM |
qwen2.5-coder:1.5b |
Any Ollama tag, or gpt-4, gpt-3.5, claude |
EMBEDDING_MODEL |
sentence_transformer |
Or openai, aws, ollama |
OLLAMA_BASE_URL |
http://host.docker.internal:11434 |
http://llm:11434 with the linux profile |
NEO4J_URI / _USERNAME / _PASSWORD |
neo4j://database:7687 / neo4j / password |
|
OPENAI_API_KEY |
Only for gpt-* or EMBEDDING_MODEL=openai |
|
AWS_*, BEDROCK_MODEL_ID |
anthropic.claude-sonnet-5 |
Only for LLM=claude or EMBEDDING_MODEL=aws |
IMPORT_BATCH_SIZE |
100 |
Symbols embedded and written per batch |
ALLOWED_GIT_HOSTS |
github.com,gitlab.com,bitbucket.org |
Hosts the API may clone from |
API_KEY |
(empty = no auth) | When set, required as X-API-Key |
ALLOWED_ORIGINS |
http://localhost:5173 |
CORS allowlist for the API |
RATE_LIMIT_REQUESTS / _WINDOW_SECONDS |
30 / 60 |
Per client IP; 0 disables |
LLM= |
Download | RAM in use | Notes |
|---|---|---|---|
qwen2.5-coder:1.5b |
~1.0 GB | ~1.5 GB | Default. Code-specific. |
qwen2.5-coder:7b |
~4.7 GB | ~6 GB | Better answers if you have the room. |
llama3.2:3b |
~2.0 GB | ~3 GB | General purpose. |
gpt-3.5 / gpt-4 |
none | none | Needs OPENAI_API_KEY. |
Disk for the whole stack: ~2 GB app image, ~0.6 GB Neo4j, plus ~4.5 GB if you run Ollama in a container.
| Endpoint | Body |
|---|---|
POST /query |
{"text": "...", "rag": true} |
POST /generate-ticket |
{"text": "..."} |
GET /query-stream?text=…&rag=true |
SSE. Stays a GET because EventSource cannot POST. |
GET/POST /conversations |
List, or create one. No body. |
GET/PATCH/DELETE /conversations/{id} |
Load with its messages and attached PDFs, rename, remove. |
GET /conversations/{id}/ask?text=…&rag=true |
SSE. Saves both turns; any PDFs attached to the chat are searched too. |
POST /conversations/{id}/documents |
Attach a PDF as multipart file, indexing it if it is new. |
DELETE /conversations/{id}/documents/{doc} |
Detach it; its chunks go with it if no other chat has it. |
GET/DELETE /graph |
Counts and indexed repositories, or clear the code graph. |
GET /graph/index?url=…&branch=… |
SSE. Clone, parse and import, reporting progress. |
curl -X POST http://localhost:8504/query -H 'Content-Type: application/json' \
-d '{"text": "how are retries decided?", "rag": true}'With API_KEY set, send -H "X-API-Key: …" (or &api_key=… on the stream).
The app and the CLI share one code path. The CLI only writes JSON — nothing reaches Neo4j until you import it:
python3 -m genai_stack.extract --github-url sindresorhus/kypython3 -m genai_stack.extract --repo ../some-local-checkoutOn the host this needs only tree-sitter, not the full stack:
pip install tree-sitter tree-sitter-typescript tree-sitter-javascriptFlags: --branch, --ignore-dir / --ignore-glob, --max-chars (where long
symbols are truncated; the 4000 default fits the retriever's k=4 inside the
model context), --min-chars. Output lands in index/.
Import the result with Import an index file on Index a repository, or:
curl -X POST http://localhost:8504/graph/import \
-F file=@index/all_code_blocks.jsonThis is the route for a local checkout — indexing from the app clones over
https:// and cannot see a directory on your disk.
Repositories are cloned and parsed, never executed — no install or build step runs. Public repos over
https://only, restricted toALLOWED_GIT_HOSTS.
pip install -r requirements-dev.txt && python3 -m pytest tests/Covers symbol extraction, call and import resolution, URL validation, index reshaping, and the Cypher behind conversations and PDF attachment — the last of those against a fake graph, so no database and no LLM are needed.
- TypeScript and JavaScript only (
.ts,.tsx,.js,.jsx,.mjs,.cjs). Other languages produce an empty index. - Call edges are resolved by name, preferring a definition in the same file and otherwise requiring the name to be unique in the repo. Ambiguous names are left unlinked rather than linked incorrectly.
- Dynamic dispatch is invisible —
handlers[key]()produces no edge. - Interfaces and enums are stored as member skeletons, without their doc comments. Full JSDoc bodies used to dominate semantic search over the code that implements the behaviour being asked about.
- Attached PDFs are not part of the code graph — their chunks live under
their own
PdfBotChunklabel and are found by plain vector search, with no traversal, so a question spanning both gets the graph on one side only.
Originally a fork of neo4j-labs/genai-stack, rebuilt around a code knowledge graph.