A multi-stage RAG pipeline that plans its own retrieval, searches three stores at once, and grades its own answer before returning it.
adRAG β βββ π 1. Overview β βββ β¨ 2. Features β βββ π§ 3. The RAG system β βββ 3.1 The eight nodes end to end β βββ 3.2 The planner decides whether to retrieve at all β βββ 3.3 Three stores answer the same query β βββ 3.4 Merge then rerank β βββ 3.5 Compression is the exception not the rule β βββ 3.6 The cited answer β βββ 3.7 The self-reflection loop β βββ 3.8 What a retry can and cannot change β βββ ποΈ 4. The system end to end β βββ 4.1 System architecture β βββ 4.2 The backend half β βββ 4.3 The frontend half β βββ 4.4 The seam between them β βββ π οΈ 5. Tech stack β βββ π 6. Project structure β βββ π 7. Getting started β βββ 7.1 Prerequisites β βββ 7.2 Run the backend β βββ 7.3 Run the frontend β βββ 7.4 Run both halves at once β βββ 7.5 Your first query β βββ π‘ 8. Usage β βββ π 9. API β βββ βοΈ 10. Configuration β βββ β οΈ 11. Known gaps β βββ π 12. Documentation β βββ πΊοΈ 13. Roadmap
adRAG is a Retrieval-Augmented Generation system built around the idea that one similarity search is not enough. It runs an eight-node LangGraph pipeline that decides whether retrieval is needed at all, queries three different stores over the same corpus, reranks the merged evidence with a cross-encoder, and then runs a second LLM as a critic that reads the answer back against the evidence and can send the whole thing round again.
It ships as two independently runnable halves:
- Backend β a Flask REST + Server-Sent Events API wrapped around the pipeline. It ingests documents, retrieves, reranks, compresses, generates a cited answer, and verifies the answer's grounding before returning it.
- Frontend β a Vue 3 single-page app that drives the pipeline and renders its progress live from the SSE stream: chat, knowledge-base management, and provider configuration.
The problem it targets is the one every naive RAG system hits. A single vector search returns plausible-looking chunks; the model answers from them whether or not they support the claim; and nobody can tell where the answer came from. adRAG attacks that from three sides β it retrieves the same corpus three different ways so keyword-exact and entity-linked evidence survives alongside semantic matches, it reranks the merged pool with a model that reads the query and the passage together, and it re-reads its own answer against the evidence before shipping it.
Note
Everything runs locally against your own documents. The LLM is either OpenAI or a local Ollama server, chosen per request, and web search (DuckDuckGo) is optional and needs no API key. Nothing but the LLM call leaves the machine.
Caution
This is a localhost project as it stands. There is no authentication on any route,
DELETE /api/clear wipes the whole index in one unauthenticated request, the CORS allowlist ends in
a literal "*", and FLASK_DEBUG defaults to true. See Β§11 and
Backend/Documentation/security.md before exposing the port.
- π§ A Self-RAG planner β an LLM decision node runs first and decides whether the knowledge base should be searched at all, whether to reach for web search, and what kind of question this is. A greeting or a general-knowledge question never touches the corpus.
- π Hybrid retrieval over three stores β a dense vector index (ChromaDB by default, FAISS opt-in), a BM25 keyword index, and a NetworkX entity graph, all queried on every retrieval pass.
- π― Cross-encoder reranking β
cross-encoder/ms-marco-MiniLM-L-6-v2scores every(query, passage)pair and keeps the top five. It is the only place in the pipeline where a single comparable relevance number exists. - ποΈ Threshold-triggered compression β evidence passes through untouched below
MAX_CONTEXT_CHARS(4000). Only above it does an LLM compression pass run, so the common case costs nothing. - π A bounded self-reflection loop β a second LLM grades the answer for grounding, and a failed
grade can send the pipeline back to retrieval up to
MAX_REFLECTION_RETRIES(2) more times, for at most three passes total. - π Evidence-driven web escalation β when a retry follows an empty or irrelevant knowledge-base hit, reflection turns DuckDuckGo search on for the next attempt.
- π‘ Live pipeline streaming β 31 emit sites across the eight nodes produce seven event types, and the route frames four more, so the UI shows the pipeline working rather than a spinner.
- π§Ύ Cited answers β the generator emits inline
[1],[2]citations, and the source list is filtered down to the documents the model actually cited. - π Two LLM providers β OpenAI or Ollama, selected per request, with a live availability probe and
model list behind
GET /api/providers. The API key never leaves the server; availability is reported as a boolean. - π Knowledge-base management β upload any of 35 file types (up to 50 MB), see what is indexed, delete one knowledge base or clear everything. Re-uploading the same bytes replaces that document's data instead of duplicating it.
This is the part of the project everything else exists to serve. The full engineering treatment lives
in Backend/Documentation/rag-pipeline/; this section
is the working explanation.
The pipeline is a LangGraph state machine built in
Backend/src/adrag/custom_packages/rag_pipeline/workflow.py and compiled once into a module-level
rag_graph singleton. Eight nodes are registered, the entry point is planner, and two of the edges
are conditional.
Diagram source: rag-pipeline-flow.mmd β edit it, then regenerate the SVG (don't hand-edit the SVG).
| # | Node | What it contributes | LLM call? |
|---|---|---|---|
| 1 | planner |
Decides retrieve, use_external, and a query_type label |
β |
| 2 | retrieval |
Three store searches β vector, BM25, graph | β |
| 3 | external_tools |
DuckDuckGo web search; self-skips when not wanted | β |
| 4 | aggregate |
Concatenates all four result lists and deduplicates | β |
| 5 | rerank |
Cross-encoder scores every candidate; keeps the top 5 | β (a local model) |
| 6 | compress |
Shrinks the context β but only above the character threshold | conditional |
| 7 | reason |
Writes the answer with inline citations | β |
| 8 | reflect |
Grades the answer, then either retries or terminates | β |
The spine from external_tools onward is linear: aggregate β rerank β compress β reason β
reflect. There is exactly one loop edge in the whole graph β reflect back to retrieval.
Important
final_answer is the termination signal, and it is the only one. The reflection router
(_route_reflection, workflow.py:47) tests state["final_answer"] for truthiness and nothing
else β not grounded, not retry_count, not the retry budget. Any node that writes a non-empty
final_answer ends the graph; any path that reaches reflect without one loops back to retrieval.
The retry budget is enforced inside the reflection node, not by the router.
The first node is a Self-RAG decision. The LLM is asked for JSON β
{retrieve, use_external, query_type, reasoning} β and the router reads the two booleans:
# workflow.py:38
def _route_planner(state: RAGState) -> str:
if state.get("retrieve", True):
return "retrieval"
if state.get("use_external", False):
return "external_tools"
return "aggregate" # direct answer: skip all retrievalretrieve is true for questions about your uploaded documents and false for general world knowledge,
maths, greetings, and coding questions. use_external is true only for recent events and live data.
Two consequences worth knowing:
- On the
retrieve = truepath,use_externalnever reaches the router.retrievalflows intoexternal_toolson a static edge, and that node decides for itself whether to run β emitting a skip event when it does not. So web search still happens on that path if the planner asked for it. - The planner fails toward retrieval. Any exception in the node returns
retrieve=True, use_external=False, query_type="factual", so an LLM hiccup degrades to a plain RAG query rather than an unsourced direct answer.
The retrieval node runs three searches over the same corpus. They find different things on purpose.
| Store | Library | What it finds | Width | Score scale |
|---|---|---|---|---|
| Dense vector | ChromaDB PersistentClient, cosine space (FAISS IndexFlatIP opt-in) |
Semantic neighbours | RETRIEVAL_TOP_K = 10 |
1.0 - distance, roughly 0β1 |
| Sparse keyword | rank_bm25.BM25Okapi over lower-cased \b\w+\b tokens |
Exact terms and rare words | 10 | Raw BM25, unbounded, zero-scoring hits dropped |
| Entity graph | NetworkX bipartite document β entity graph | Chunks linked to entities in the question | max(10 // 2, 3) = 5 |
Traversal weight, unbounded |
Three details that shape how the system behaves:
- The three searches are sequential, not parallel. They are three ordinary function calls in one
node β no thread pool, no
asyncio. - Entity extraction is regex, not a model. The graph store recognises multi-word capitalised proper
nouns, 2β6 character acronyms, and camelCase identifiers, minus a 26-word stop list. First-hop
documents score
edge_weight Γ 2.0, second-hop documents+0.5per path. A lower-case question with no proper nouns yields nothing from the graph at all β the store returns an empty list immediately. - Web results, when enabled, carry a fixed score of
0.7and a hardcoded width of five.
Warning
The score field is not comparable across stores. A cosine similarity of 0.82, a raw BM25
score of 8.2, and a graph traversal weight of 4.5 share one float field and mean three
different things. Only rerank_score is comparable β never rank on score.
aggregate concatenates the four lists in a fixed order (vector, BM25, graph, web) and deduplicates by
the MD5 of the chunk text, keeping the highest-scoring copy. That is exact-string identity: a
near-duplicate, or the same passage chunked at a different offset, does not collapse.
rerank is where the incomparable scales are resolved. A cross-encoder reads each (query, passage)
pair together and emits one number on one scale; the node sorts on it and slices to RERANK_TOP_K
(5). That slice becomes the context every downstream node sees.
Important
rerank_score is a raw logit, not a 0β1 probability, and negative values are meaningful β the
default ms-marco cross-encoder returns negative scores for pairs it considers irrelevant. That sign
is load-bearing: the reflection node's web-escalation test is "was the best passage scored below
zero?" Swap in a reranker with a non-negative output range and escalation silently stops firing.
The reranker is the pipeline's dominant local compute β one forward pass per candidate, and at the
defaults there are up to thirty of them (ten vector, ten BM25, five graph, five web) before dedup. If the model fails to load, the node falls back to sorting by the raw score
and keeps going; the answer still arrives, just ranked by an incomparable field.
compress assembles the surviving documents into numbered blocks β [1] filename, [2] filename β
and measures the total. Below MAX_CONTEXT_CHARS (4000) it returns that text verbatim and makes no
LLM call at all. At the defaults that is the common case: five chunks of 500 characters is about
2500. Only a long context triggers the compression prompt, and even then the model sees at most the
first 10 000 characters.
Those [1], [2] labels are not decoration β they are the same 1-based ordering the answer generator
uses to build its source list, which is what makes the citations line up.
reason builds the source list first, one entry per context document, then asks the LLM for JSON:
{answer, confidence, cited_sources, key_facts, is_sufficient}. The prompt requires the answer to use
only the provided context and to carry inline [n] citations.
Then it filters:
# reasoning.py:93
cited_indices = set(result.get("cited_sources", []))
cited_sources = [s for s in sources if s["index"] in cited_indices]Only sources the model explicitly cited survive into the response. A retrieved-but-uncited document
is invisible in the UI, and an answer written from the model's own training knowledge comes back with
an empty sources array. That is normal behaviour, not a bug β and it is the mechanism behind "the
answer has no sources".
If there is no context at all β the planner's direct-answer path β the node takes a plain,
non-JSON branch and returns sources: [] explicitly.
Reflection is what separates this pipeline from a one-shot chain. A second LLM call, with the same
provider and the same temperature, receives the question, the retrieved context, and the answer, and
returns {grounded, confidence, issues, feedback, should_retry}.
A retry happens only when all three of these hold:
# reflection.py:97
will_retry = (not grounded) and raw_retry and (retry_count < MAX_RETRIES)- the critic judged the answer not grounded;
- the critic also asked for a retry β it can call an answer ungrounded and still decline to try again;
- the budget is unspent β
MAX_REFLECTION_RETRIESdefaults to2, so three passes maximum.
Escalation to the web is evidence-driven. On a retry, if the knowledge base looked insufficient β
zero context documents, or a best rerank_score below zero β and web search was not already on,
reflection flips use_external to true so the next pass adds DuckDuckGo results. It is the only place
outside the planner that writes that flag.
When the budget runs out the answer is still returned, with a caveat line appended noting that some
claims may not be fully supported. And if reflection itself raises, it fails open: it marks the
answer grounded, sets final_answer, and terminates β a broken critic ends the run rather than
looping forever.
This is the most important honest limit in the system, and it follows from three facts that are each individually reasonable:
- Every LLM call uses
temperature=0; no node overrides it. - A node's returned keys overwrite rather than accumulate, so a second retrieval pass replaces the first pass's documents instead of adding to them.
reflection_feedbackis written on every reflection pass and read by nothing. The critique reaches the browser as thereasonfield of aretryevent, but no prompt is changed by it.
Put together: a retry that does not escalate re-runs the identical query against the identical corpus with the identical prompt at temperature zero β and so produces the same answer, which the critic then judges the same way. The retry budget can only change the outcome when escalation fires and adds web documents. Feeding the critique back into the next attempt, or raising the temperature on a retry, are the two obvious ways to make the loop earn its cost; neither exists today.
The browser never talks to a store. The Vue SPA calls the Flask API, Flask runs the compiled pipeline on a background thread, and every stage the pipeline enters is pushed back to the browser over one long-lived SSE response.
Diagram source: system-architecture.mmd β edit it, then regenerate the SVG (don't hand-edit the SVG).
| Piece | Where it runs | Responsibility |
|---|---|---|
| Vue 3 SPA | localhost:8080 (dev server) |
Four lazy routes, 13 components, three Pinia stores; renders the live pipeline |
| Dev proxy | Vue CLI dev server | Forwards /api/* to http://localhost:5000, so development has no cross-origin hop |
| Flask API | 0.0.0.0:5000, threaded |
Eight routes across four blueprints; owns upload, store access, and the SSE session |
| LangGraph pipeline | A daemon thread per query | The eight nodes, compiled once into rag_graph |
| Stores | On disk under Backend/data/databases/ |
Chroma (or FAISS), BM25, and the entity graph β each a process-wide singleton |
| LLM providers | The OpenAI API or a local Ollama server | Chosen per request; instances cached by provider, temperature, JSON mode, and model |
A Flask application factory that carries zero route decorators. Every route lives in
routes/<resource>/<resource>_routes.py behind its own blueprint, and the factory registers them by
iterating one tuple:
# adrag/app.py:51
for blueprint in BLUEPRINTS:
app.register_blueprint(blueprint)Adding an endpoint is a folder plus one line in routes/__init__.py β nothing in app.py changes.
Behind the routed layer sits custom_packages/rag_pipeline/, the capability nothing routes to: it
never imports Flask, never imports upward into app.py, and reaches the browser only through the
event bus.
Important
The server runs as exactly one process with one worker, and that is a correctness constraint.
The SSE session registry is a plain module-level dict and every store is a module singleton, so
forking splits the event producer from its consumer and gives each worker a divergent BM25 corpus
and graph. That is why development uses threaded=True and production uses gunicorn's -w 1.
Concurrent queries are fine β each gets its own session, queue, and thread. Concurrent ingest is
not: the stores are unsynchronised, and only the knowledge-base registry holds a lock.
A Vue 3 SPA built with Vue CLI / webpack (not Vite). Four lazily-imported routes, 13 components, three Pinia stores in setup style, and two axios clients β each constructing its own instance.
The placement rules are deliberately different for the two kinds of thing, and both are load-bearing:
components are placed by ownership (a component used by one page lives under that page; it moves to
shared/ only when a second page imports it), while state and HTTP clients are flat by kind
(store/ragStore.js, services/kbApi.js β the domain lives in the filename, not a directory).
Nothing points upward: no store imports a component, no service imports a store, and shared/ never
imports pages/. There is exactly one accepted exception to "components call store actions, not
services" β the navigation bar imports the health check directly to drive its connection dot.
One POST opens a stream and the pipeline narrates itself down it.
- The transport is
fetch+ReadableStream, notEventSource.EventSourceis GET-only and the query has to be sent as a POST body, so the client reads the frames by hand β and therefore gets no automatic reconnection. - Each query gets a
uuid4session id mapped to an unboundedqueue.Queue. The pipeline runs on a daemon thread and pushes into that queue; the HTTP response drains it with a 180-second per-event timeout and closes on aNonesentinel, sending a literalstream_endframe last. - Errors are in-band. Only two failures produce an HTTP error status β a blank query and an unknown
provider, both
400, both before the stream opens. Once the response headers are on the wire the status is200forever, so a pipeline failure arrives as anerrorevent on a successful response. A client that reads the status code to decide whether a query succeeded will report every failed run as a success. - A disconnect frees the socket, never the compute. The daemon thread runs to completion regardless; the emit function is a deliberate no-op once its session is gone.
Eleven event types reach the browser β seven emitted by pipeline nodes and four framed by the route
(done, two shapes of error, and stream_end).
Warning
An SSE stage id is not the graph node name β five of the eight differ. The graph registers
aggregate, rerank, compress, reason, reflect, but the frames those nodes emit carry
aggregator, reranker, compressor, reasoning, reflection. Only planner, retrieval and
external_tools coincide. The frontend's stage list must equal the emitted set, and an
unrecognised stage id is dropped silently β no error, no console warning. Rename a node and nothing
breaks; change an emitted stage literal and that tracker row stops updating forever.
Backend β Python 3.10+ is a hard requirement, not a preference: PEP 604 unions (str | None) are
evaluated at runtime in module and signature scope and no file carries
from __future__ import annotations, so 3.9 fails at import. Dependencies live in
Backend/pyproject.toml; requirements.txt is a one-line -e . pointer.
| Role | Package | Minimum |
|---|---|---|
| Web API | flask Β· flask-cors |
>=3.0.0 Β· >=4.0.0 |
| Config | python-dotenv |
>=1.0.0 |
| Pipeline | langgraph |
>=0.1.0 |
| LLM abstraction | langchain Β· langchain-text-splitters Β· langchain-community |
>=0.2.0 (each) |
| Provider bindings | langchain-openai Β· langchain-ollama |
>=0.1.0 (each) |
| Dense vector store | chromadb (default) Β· faiss-cpu (faiss extra) |
>=0.5.0 Β· >=1.7.4 |
| Embeddings + reranking | sentence-transformers |
>=3.0.0 |
| Sparse retrieval | rank-bm25 |
>=0.2.2 |
| Knowledge graph | networkx |
>=3.3 |
| Document loaders | pypdf Β· docx2txt Β· unstructured Β· markdown |
>=4.0.0 Β· >=0.8 Β· >=0.14.0 Β· >=3.6 |
| Web search | ddgs |
>=7.0.0 |
| Utilities | numpy Β· requests |
>=1.26.0 Β· >=2.31.0 |
Production server (prod extra) |
gunicorn Β· gevent-websocket |
>=21.0.0 Β· >=0.10.1 |
Frontend β Vue CLI 5 / webpack, from Frontend/package.json. Node.js 18 or newer is what the
toolchain expects; nothing in the manifest pins an engine.
| Role | Package | Version |
|---|---|---|
| Framework | vue |
^3.4.0 |
| Routing | vue-router |
^4.6.4 |
| State | pinia |
^2.1.7 |
| HTTP | axios |
^1.7.0 |
| Markdown rendering | marked |
^12.0.0 |
| Build | @vue/cli-service Β· @vue/cli-plugin-babel |
^5.0.8 (each) |
| Linting | eslint Β· eslint-plugin-vue Β· @vue/cli-plugin-eslint |
^8.57.0 Β· ^9.27.0 Β· ^5.0.8 |
| Styling | tailwindcss Β· postcss Β· autoprefixer |
^3.4.0 Β· ^8.4.0 Β· ^10.4.0 |
| Doc tooling (dev only) | @mermaid-js/mermaid-cli Β· svgo |
^11.16.0 Β· ^4.0.2 |
The last row is documentation tooling, not application code β it renders the .mmd sources under
.readme-lib/ into the committed SVGs these docs embed. Nothing in Frontend/src/ imports either.
Models β every default is overridable by environment variable:
| Purpose | Default | Variable |
|---|---|---|
| OpenAI chat model | gpt-4o-mini |
LLM_MODEL |
| Ollama chat model | llama3.2 (server at http://localhost:11434) |
OLLAMA_MODEL |
| Embeddings | all-MiniLM-L6-v2 |
EMBEDDING_MODEL |
| Reranker | cross-encoder/ms-marco-MiniLM-L-6-v2 |
RERANKER_MODEL |
Advanced RAG System/
β
βββ π Backend/ Flask + LangGraph RAG API (Python)
β βββ π Documentation/ 18 pages β the backend cookbook
β βββ π data/ Runtime state β git-ignored, made on first run
β βββ π src/adrag/ The installable package; the import root
β βββ π .env.example Backend env template β copy to .env
β βββ π pyproject.toml The manifest β deps, extras, adrag-dev
β βββ π README.md Backend front door
β βββ π requirements.txt A one-line `-e .` pointer, nothing more
β
βββ π Frontend/ Vue 3 SPA (Vue CLI / webpack)
β βββ π Documentation/ 8 pages β the frontend cookbook
β βββ π design/ Design source β brand workbench + theme lab
β βββ π public/ Served verbatim β build template, brand, icons
β βββ π src/ App source β components placed by ownership
β βββ π .env.example VUE_APP_API_URL β leave it unset in dev
β βββ π package.json Three scripts: serve Β· build Β· lint
β βββ π README.md Frontend front door
β βββ π tailwind.config.js Two font families; reads nothing from design/
β βββ π vue.config.js Dev server :8080 + /api proxy β :5000
β
βββ π infra/ Repo-level tooling
β βββ π dev.py Runs both halves β python infra/dev.py
β βββ π smoke.py Drives the read-only routes in-process
β
βββ π .readme-lib/ Doc assets β diagram sources and renders
β
βββ π .gitignore Ignored paths β data, secrets, build output
βββ π README.md You are here
Runtime data lives under Backend/data/ β uploads/ for the original files, databases/ for the
three stores and the knowledge-base registry. It does not exist in a fresh checkout: the backend
creates it at import time, and it is git-ignored.
Important
DATA_ROOT is anchored to the package, not to the working directory. config.py computes the
backend root from __file__ and defaults every data path against it, so where you start the server
no longer decides where the databases land. A relative DATA_ROOT set in .env is resolved
against the working directory, which reintroduces the bug β use an absolute path if you set one at
all. This is also why .env.example ships its whole storage block commented out.
The two halves run independently. Start the backend first; the frontend dev server proxies to it.
| Requirement | Why |
|---|---|
| Python 3.10+ | PEP 604 X | Y unions are evaluated at runtime, with no __future__ import |
| Node.js 18+ | What Vue CLI 5 expects; no engine is pinned in the manifest |
| An OpenAI API key or a running Ollama server | The pipeline needs at least one working provider |
Ollama, if you use it, is expected at http://localhost:11434. Web search needs no key.
git clone git@github.com:Shohrab-Hossain/Advanced-RAG-System.git
cd Advanced-RAG-Systemcd Backend
python -m venv .venvActivate the environment β .venv\Scripts\activate on Windows, source .venv/bin/activate on macOS
and Linux β then install and configure:
pip install -e .
cp .env.example .envOpen .env and set your key:
OPENAI_API_KEY=<YOUR_API_KEY>Then start it:
adrag-devadrag-dev is the console script pip installs; python -m adrag.main does the same thing without it
on PATH. The API listens on 0.0.0.0:5000 with threading enabled. Confirm it is up:
curl http://localhost:5000/api/health{ "status": "healthy" }Note
First boot is slow β around a minute on a cold filesystem, roughly ten seconds warm. Almost all
of it is sentence-transformers pulling in torch at import. /api/health answers before the
models finish loading, deliberately: it is a liveness probe, not a readiness one.
Two optional dependency groups exist, neither installed by default:
pip install -e ".[faiss]" # faiss-cpu β only for VECTOR_BACKEND=faiss
pip install -e ".[prod]" # gunicorn + gevent-websocketFor production, one worker, always β the -w 1 and the worker class are both part of the command:
gunicorn -w 1 -k geventwebsocket.gunicorn.workers.GeventWebSocketWorker \
--bind 0.0.0.0:5000 adrag.app:appIn a second terminal:
cd Frontend
npm install
npm run serveThe dev server comes up on http://localhost:8080 and proxies /api to http://localhost:5000 with
changeOrigin set, so development needs no CORS configuration. npm run build produces a static
bundle in dist/.
Warning
Frontend/.env.example ships VUE_APP_API_URL set, and its own comment tells you to leave it
unset. Copy the file verbatim and every call bypasses the dev proxy and goes cross-origin to
:5000 directly. Leave the variable unset (or empty) for normal development β both clients then fall
back to a relative base URL, which is what the proxy is for. Set it only when the SPA is genuinely
served from a different origin than the API.
Tip
npm run lint runs Vue CLI's linter with --fix on by default, so invoking it to check the
code rewrites it. Use npm run lint -- --no-fix when you only want the report.
From the repository root:
python infra/dev.pyIt picks free ports (walking up from 5000 and 8080), injects them into the backend's environment,
points the frontend proxy at whichever port the backend actually got, waits on /api/health, and
prefixes each child's output. Four flags:
| Flag | Effect |
|---|---|
--direct |
The frontend calls the backend cross-origin instead of via the dev proxy |
--no-reload |
Disables the Flask reloader, so the models load once instead of twice |
--api-port <n> |
Pins the backend port instead of probing |
--ui-port <n> |
Pins the frontend port instead of probing |
- Open
http://localhost:8080and go to Knowledge Base. - Upload a document β PDF, DOCX, Markdown, HTML, CSV, plain text, or any of 27 code extensions, up to 50 MB. It is chunked at 500 characters with 50 of overlap and written to all three stores; the response reports the chunk, vector, entity, and edge counts.
- Go to Chat and ask a question about it.
- Watch the tracker. Each of the eight rows reports as its stage starts and finishes, the retrieval row shows per-store hit counts, and the answer arrives with numbered citations you can expand.
Uploading the same file again is safe. The knowledge-base id is the MD5 of the file's contents, and indexing deletes that hash's data from all three stores before writing, so a re-upload is an idempotent re-index rather than a duplicate β even under a different filename.
The SPA has four routes, all lazily loaded, in HTML5 history mode:
| Route | Page | What you do there |
|---|---|---|
/ |
Home | The pitch and the entry point into the app |
/chat |
Chat | Ask questions, watch the live pipeline, read cited answers |
/knowledge-base |
Knowledge Base | Upload documents, review the index, delete one KB or clear all |
/configuration |
Configuration | Pick the provider and model; see which providers are reachable |
Chat history is kept in localStorage under the key rag-chat-history. Each entry stores a deep clone
of the pipeline's stage snapshot, so selecting one replays the whole tracker, not just the answer. The
write is capped at the newest 50 entries β the in-memory list is not, so a long session can show
more than 50 until the next reload trims it.
Querying from the command line. POST /api/query answers with an SSE stream, so pass -N to stop
curl from buffering:
curl -N -X POST http://localhost:5000/api/query \
-H "Content-Type: application/json" \
-d '{"query": "What does the reflection node check?", "provider": "openai"}'Frames arrive as data: <json> blocks, each carrying a type and a data object β abridged:
data: {"type": "stage_start", "data": {"stage": "planner", "message": "..."}}
data: {"type": "retrieval_result", "data": {"stage": "retrieval", "vector_count": 10, "bm25_count": 4, "graph_count": 3, "message": "..."}}
data: {"type": "stage_complete", "data": {"stage": "reranker", "top_k": 5, "scores": [4.71, 1.02], "message": "..."}}
data: {"type": "done", "data": {"answer": "...", "sources": [], "metadata": {}}}
data: {"type": "stream_end"}
provider is optional β omit it and DEFAULT_PROVIDER applies. model is optional too, and it
overrides the chat model for either provider, not just Ollama. The full event vocabulary, with
every payload key, is in
Backend/Documentation/api/query.md.
Eight routes, four blueprints, all under /api, none of them authenticated.
| Method | Path | What it does |
|---|---|---|
POST |
/api/query |
Runs the pipeline and streams it back as text/event-stream. Body {query, provider?, model?}. 400 for a blank query or an unknown provider β the only HTTP error statuses in the flow. |
POST |
/api/upload |
Multipart file upload; chunks and indexes into all three stores. 400 for a missing file, empty name, or disallowed extension; 422 when no text could be extracted; 500 on an indexing failure. |
GET |
/api/documents |
Index counts β {vector_count, bm25_count, graph}. |
DELETE |
/api/clear |
Wipes all three stores, the registry, and the uploaded files named by it. |
GET |
/api/knowledge-bases |
Lists the indexed knowledge bases, newest first. |
DELETE |
/api/knowledge-bases/<file_hash> |
Removes one knowledge base from all three stores. Idempotent β an unknown hash still returns 200. |
GET |
/api/providers |
Provider availability and model lists. Probes Ollama over the network, so it can block for up to ~10 s. |
GET |
/api/health |
{"status": "healthy"} β one key. Liveness only; it answers while the models are still loading. |
Note
The JSON {"error": β¦} envelope only covers errors the application raises deliberately. No
error handler is registered anywhere, so 404, 405, 413 (an upload over 50 MB) and any unhandled
500 come back as Werkzeug's HTML pages. A client cannot assume response.json().error exists.
Full request and response shapes, every error, and the complete SSE catalogue live in
Backend/Documentation/api/.
The backend reads Backend/.env, found by absolute path from the package location. Copy
Backend/.env.example and change what you need β everything has a working default except the OpenAI
key. The process environment wins over .env, which is exactly how infra/dev.py injects the
ports it picked.
| Variable | Default | Purpose |
|---|---|---|
OPENAI_API_KEY |
(empty) | Your OpenAI key. Empty means the OpenAI provider reports itself unavailable. |
LLM_MODEL |
gpt-4o-mini |
OpenAI chat model |
OLLAMA_BASE_URL |
http://localhost:11434 |
Where the Ollama server lives |
OLLAMA_MODEL |
llama3.2 |
Ollama chat model |
DEFAULT_PROVIDER |
openai |
Provider used when a request does not name one |
VECTOR_BACKEND |
chroma |
chroma or faiss; faiss needs the faiss extra installed |
PORT |
5000 |
Backend listen port |
FRONTEND_URL |
http://localhost:8080 |
Added to the CORS origins list |
FLASK_DEBUG |
true |
Turns on both the auto-reloader and the interactive debugger |
Full reference β pipeline tuning, paths, and the settings you cannot set
Pipeline tuning
| Variable | Default | Effect |
|---|---|---|
RETRIEVAL_TOP_K |
10 |
Candidates requested from each store (the graph gets max(k // 2, 3)) |
RERANK_TOP_K |
5 |
Documents kept after cross-encoder reranking |
MAX_CONTEXT_CHARS |
4000 |
Compression runs only above this total |
MAX_REFLECTION_RETRIES |
2 |
Extra attempts after a failed grounding check β three passes maximum |
EMBEDDING_MODEL |
all-MiniLM-L6-v2 |
The dense embedder |
RERANKER_MODEL |
cross-encoder/ms-marco-MiniLM-L-6-v2 |
The cross-encoder |
CHUNK_SIZE |
500 |
Characters per chunk at ingestion β absent from .env.example |
CHUNK_OVERLAP |
50 |
Overlap between adjacent chunks β absent from .env.example |
Paths β all derived from DATA_ROOT, which defaults to Backend/data computed from the package
location:
| Variable | Default |
|---|---|
DATA_ROOT |
Backend/data (absolute, package-anchored) |
UPLOAD_FOLDER |
<DATA_ROOT>/uploads |
DATABASE_ROOT |
<DATA_ROOT>/databases |
CHROMA_PATH |
<DATABASE_ROOT>/vector_db/chroma_db |
FAISS_PATH |
<DATABASE_ROOT>/vector_db/faiss_db |
BM25_PATH |
<DATABASE_ROOT>/keyword_db/bm25_store/bm25_store.pkl |
GRAPH_PATH |
<DATABASE_ROOT>/graph_db/graph_store/graph_store.pkl |
KB_REGISTRY_PATH |
<DATABASE_ROOT>/kb_registry.json β read directly by the registry module, not through Config |
The intermediate roots between DATABASE_ROOT and those four leaves read no environment variable,
so you can move the whole tree or an individual store file, but not one retrieval kind's folder.
Two traps worth knowing. .env.example documents the storage block with ${DATA_ROOT}-style
references and ships it entirely commented out β uncomment a child without its parent and the
prefix expands to an empty string, so UPLOAD_FOLDER becomes the literal /uploads at the filesystem
root. And the debug flag's variable is FLASK_DEBUG, not DEBUG; setting DEBUG=false changes
nothing.
Not configurable by environment
| Setting | Value |
|---|---|
| Maximum upload size | 50 MB, enforced by Flask itself |
| Allowed extensions | 35, hardcoded in two places that must stay in step |
| Web search results per query | 5 |
| SSE per-event drain timeout | 180 seconds |
| LLM temperature | 0, on every call |
Frontend
| Variable | Default |
|---|---|
VUE_APP_API_URL |
Unset in development β both clients fall back to a relative base URL and use the dev proxy. Only VUE_APP_-prefixed variables reach the browser, and the value is baked in at build time. |
The complete reference β every attribute, where it is cast, and the four ways the "a setting is a
Config attribute and an .env.example line" convention is broken β is in
Backend/Documentation/configuration.md.
The honest state of the repository. Two structural gaps, plus a set of security defaults that are fine on localhost and nowhere else.
- There is no test framework. No runner appears in
Backend/pyproject.tomlorFrontend/package.json, there is notests/directory, and there is no CI configuration.infra/smoke.pyis a dev tool and says so itself β it builds the app throughcreate_app()and drives the four read-only routes over Flask's test client, binding no port and writing nothing. It proves the import chain resolves and every blueprint is registered after a refactor. It is not a substitute for a harness, and nothing here should be described as tested. - There is no
LICENSE. No licence file exists at the root, so the terms for use, modification, and redistribution are formally undefined.
Accepted, documented, localhost-only security risks. Each is deliberate and each is a remote compromise the moment the port is reachable off the machine:
| Risk | Where | Effect |
|---|---|---|
| No authentication on any route | every route | DELETE /api/clear wipes the whole index in one unauthenticated request |
CORS allowlist ends in a literal "*" |
the app factory | Any origin is accepted β the requesting origin is echoed back, and a cross-origin DELETE preflight succeeds |
FLASK_DEBUG defaults to true |
config.py |
An unhandled error renders Werkzeug's traceback page with the interactive console enabled |
| Prompt input is unescaped by design | all four prompts | Query text, retrieved chunks and web results interpolate straight in β including into the critic that judges grounding |
The answer renders through marked with no sanitiser |
the result view | A crafted document is a stored-XSS path to every later reader |
The full treatment β the trust-boundary model, the measured wire behaviour, and an ordered checklist of
what to close first β is in
Backend/Documentation/security.md.
This README is the front door. The engineering cookbook lives beside the code it describes, one tree per half.
| Entry point | What is behind it |
|---|---|
Backend/README.md |
Backend front door β install, run, layout |
Backend/Documentation/ |
18 pages β the pipeline, the three stores, ingestion, the SSE bus, the LLM layer, the HTTP API, configuration, architecture, storage, security |
Frontend/README.md |
Frontend front door β install, run, layout |
Frontend/Documentation/ |
8 pages β the three stores, the API clients, the chat page and its tracker, the knowledge-base page, the configuration page, the design system |
Where to go from here, by what you want to do:
| If you want to⦠| Read |
|---|---|
| Understand the pipeline properly | rag-pipeline/README.md, then nodes.md and state-model.md |
| Understand retrieval quality | hybrid-retrieval/README.md and stores.md |
| Call the API from your own client | api/README.md and api/query.md |
| Follow one request across every layer | architecture/query-lifecycle.md |
| Know what is on disk and what survives a crash | architecture/storage-model.md |
| Add a document type or change chunking | ingestion/README.md |
| Work on the live tracker | chat/pipeline-tracker.md |
| Change styling or dark mode | design-system/README.md |
| Deploy this anywhere but localhost | security.md β first |
Where this goes next, in the order it makes sense to do it.
| # | Next step | What it unlocks |
|---|---|---|
| 1 | Stand up a test harness β a runner for each half and a first suite over the pipeline's routing, retry, and fallback paths. | Those paths can be verified instead of reasoned about, and infra/smoke.py can go back to being only a smoke check. |
| 2 | Add a LICENSE. |
Makes the terms for use and contribution defined rather than absent. |
| 3 | Close the localhost-only security defaults β drop the "*" origin, default FLASK_DEBUG to false, add authentication, register JSON error handlers. |
The first four are the prerequisites for this running anywhere with a reachable port. |
| 4 | Make a retry able to change the answer β feed the reflection critique into the next attempt, or vary temperature on a retry. | Today a non-escalating retry is deterministic and re-derives the same answer (Β§3.8). |
| 5 | Give ingestion a real progress channel. | Upload is fully synchronous with no job id and no stream, so the indexing bar in the UI is an animation rather than a measurement. |