{"slug": "build-a-local-rag-app-with-citations-in-one-database-28-lines-of-python", "title": "Build a Local RAG App With Citations in One Database (28 Lines of Python)", "summary": "A developer built the same retrieval-augmented generation (RAG) support bot twice—once on a conventional stack of Postgres with pgvector, Ollama, an ingest job and a query API, and once on the single-database SynapCores v2.4.0 Community Edition—and measured both on the same Mac. The SynapCores version required 28 lines of code and config versus 149 for the usual stack, ran as one native binary with no containers, and answered all five test questions correctly with the right source cited in both cases, while picking up new documents in about three seconds without a re-indexing step. Both versions used qwen2.5:7b for chat and nomic-embed-text for embeddings at temperature 0, seed 7 and top 3 chunks.", "body_md": "We built the same RAG app twice, with the same documents, the same models and the same five questions, and we counted everything. The first version is the stack most tutorials use: Postgres with pgvector, Ollama for embeddings and chat, an ingest job and a query API, all in Docker Compose. The second version is one database, SynapCores, and one Python file.\n\nThis post walks you through the second version from a fresh Mac to cited answers. Every command and every answer below comes from a real run on SynapCores v2.4.0 Community Edition, which is free.\n\nYou'll build a support bot for a made-up router company, Orbit Networks. It answers questions from four short policy documents, cites the file each answer came from, and says it doesn't know when the documents don't cover something. When you drop a new document into the folder, the bot can answer questions about it a few seconds later, with no re-indexing step.\n\nHere is how the two versions compared on the same Mac, on the same day:\n\n|  | The usual stack | SynapCores | \n|---|---|---|\n| Services to run | 4 (Postgres + pgvector, Ollama, ingest job, query API) | 1 database process | \n| Container images | 3 pulled + 1 built | 0 (one native binary) | \n| Files you write | 8 | 1 | \n| Lines of glue code (Python + SQL) | 92 | 28 | \n| Lines of config | 57 | 0 (you change two model names in the installed config) | \n| **Code + config total** | **149** | **28** | \n| Cold setup to first answer, including model downloads | 5:34 | 3:35 | \n| Correct answers | 5/5 | 5/5 | \n| Right source cited | 5/5 | 5/5 | \n| New file in the docs folder | re-run the ingest job | picked up automatically in about 3 seconds | \n\nBoth stacks used `qwen2.5:7b` for chat and `nomic-embed-text` for embeddings, with temperature 0, seed 7 and the top 3 chunks. Lines are counted without blank lines, comments or docstrings.\n\n`python3 --version`).\nOpen Terminal and run the official installer. `SYNAPCORES_USER_INSTALL=1` installs everything for your user only, so you don't need `sudo`:\n\n```\ncurl -fsSL https://get.synapcores.com | SYNAPCORES_USER_INSTALL=1 sh\n```\n\nThe installer asks three questions:\n\n`Y`.` n`. You'll pick the models for this tutorial in the next step, and SynapCores downloads them on first start.\nWhen it finishes you have:\n\n```\n~/.local/bin/synapcores          the database (one binary)\n~/.local/bin/synapcores-start    a small start helper\n~/.synapcores/gateway.toml       the config file\n~/.synapcores/synapcores.env     private secrets the installer generated for you\n```\n\nOpen `~/.synapcores/gateway.toml` in any text editor, find the `[query.ai_service]` section and set the two model lines:\n\n```\n[query.ai_service]\nprovider        = \"local\"\nmodel           = \"qwen2.5:7b\"\nembedding_model = \"nomic-embed-text\"\nauto_pull       = true\n```\n\nOr make the same change from the terminal:\n\n```\nsed -i '' \\\n  -e 's|^# model omitted.*|model           = \"qwen2.5:7b\"|' \\\n  -e 's|^embedding_model = .*|embedding_model = \"nomic-embed-text\"|' \\\n  ~/.synapcores/gateway.toml\n```\n\nThese are the same two models the usual stack runs in Ollama, so the comparison is like for like. SynapCores runs them in-process, on the Mac's GPU, and pulls them from the same Ollama registry. You don't install Ollama.\n\nStart SynapCores in the foreground. Leave this terminal window open while you work through the tutorial:\n\n```\n~/.local/bin/synapcores-start \\\n  --env-file ~/.synapcores/synapcores.env \\\n  --binary ~/.local/bin/synapcores \\\n  --config ~/.synapcores/gateway.toml\n```\n\nOn the first start it prints a **FIRST-BOOT CREDENTIALS** box. Copy the admin password from it, because you'll need it in a moment:\n\n```\n===============================================================\n  FIRST-BOOT CREDENTIALS\n===============================================================\n  ADMIN LOGIN\n  username: admin\n  password: ••••••••••••••••\n```\n\nThen it downloads both models:\n\n```\n  ┃ downloading model 'nomic-embed-text' …\n  ┃ downloading model 'qwen2.5:7b' …\n\n    SynapCores HTTP server is ready.\n    AI model: qwen2.5:7b — downloading\n```\n\nOpen a **second terminal window** and check whether the models are ready. While they download you'll see `\"state\":\"downloading\"`. Run it again until it says `\"ready\":true`. That takes a few minutes, depending on your connection:\n\n```\ncurl -s localhost:8080/health\n{\"status\":\"ok\", ... \"ai\":{\"model\":null,\"state\":\"ready\",\"ready\":true, ...}}\n```\n\nEvery request to SynapCores carries an API key. In the second terminal, paste your admin password between the quotes, then run the block. It logs in as admin and asks for a key that can read and write, valid for 30 days:\n\n```\nADMIN_PW='paste-the-admin-password-here'\n\nTOKEN=$(curl -s -X POST localhost:8080/v1/auth/login \\\n  -H 'content-type: application/json' \\\n  -d \"{\\\"username\\\":\\\"admin\\\",\\\"password\\\":\\\"$ADMIN_PW\\\"}\" \\\n  | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"access_token\"])')\n\ncurl -s -X POST localhost:8080/v1/api-keys \\\n  -H \"Authorization: Bearer $TOKEN\" \\\n  -H 'content-type: application/json' \\\n  -d '{\"name\":\"rag-demo\",\"permission\":\"FullAccess\",\"expires_in_days\":30}' \\\n  | python3 -c 'import sys,json; print(json.load(sys.stdin)[\"raw_key\"])'\n```\n\nIt prints your new key, which starts with `aidb_`. Save it somewhere safe, because it's shown only once. Then put it in an environment variable for this terminal:\n\n```\nexport SC_KEY='aidb_...'\n```\n\nCreate a project folder, a Python virtual environment, and the one library the app uses ([httpx](https://www.python-httpx.org/)):\n\n```\nmkdir -p ~/rag-demo/docs && cd ~/rag-demo\npython3 -m venv .venv\nsource .venv/bin/activate\npip install httpx\n```\n\nDownload the four sample documents:\n\n```\nB=https://synapcores.com/demos/two-stacks\nfor f in returns-and-refunds shipping troubleshooting-error-codes warranty; do\n  curl -fsSL -o docs/$f.md $B/docs/$f.md\ndone\n```\n\nThey're short. Here they are in full, so you can see what the bot knows.\n\n**docs/returns-and-refunds.md**\n\n```\n# Orbit Networks: Returns and Refunds Policy (rev. 2026-09)\n\nHardware purchased directly from Orbit Networks can be returned within **45 days of delivery** for a full refund.\nOpened software licenses (Orbit Shield, Orbit Mesh Manager) are not refundable.\nRefurbished units are returnable within 14 days only.\nRestocking fee: none for unopened items; 10% for opened items without the original box.\nRefunds are issued to the original payment method within 5 business days of the item arriving at our Reno warehouse.\n```\n\n**docs/shipping.md**\n\n```\n# Orbit Networks: Shipping\n\nOrders over $120 ship free within the contiguous US (3 to 5 business days).\nAlaska and Hawaii: 6 to 9 business days, flat $15.\nExpress shipping (2 business days): $24.\nWe do not ship to PO boxes for routers due to carrier signature requirements.\nInternational shipping is not available as of September 2026.\n```\n\n**docs/troubleshooting-error-codes.md**\n\n```\n# Orbit X2 / X2 Pro: Status light and error codes\n\n- **E-417**: WAN negotiation failed. Unplug the modem for 60 seconds, then power on the modem first and the router second.\n- **E-418**: DHCP lease not received. Set WAN mode to \"Dynamic IP\" in the app under Internet > Connection Type.\n- **E-512**: Firmware signature mismatch. Do NOT power off. Wait 10 minutes; the router rolls back automatically. If the light stays red, hold the reset button for 30 seconds.\n- **E-700**: Mesh backhaul too weak. Move the M1 node within 10 meters of the router or use a wired backhaul.\n- Blinking amber light: firmware update in progress (normal, up to 8 minutes).\n- Solid red light: hardware fault. File a warranty claim.\n```\n\n**docs/warranty.md**\n\n```\n# Orbit Networks: Limited Warranty\n\n| Product | Warranty |\n|---|---|\n| Orbit X2 router | 3 years |\n| Orbit X2 Pro router | 5 years |\n| Orbit Mesh node M1 | 2 years |\n| Orbit Power Adapter PA-30 | 1 year |\n\nNot covered: water damage, lightning/power surges without an Orbit surge protector, and units with a removed serial sticker.\nTo file a claim, open a ticket with your serial number (format ORB-XXXX-XXXX) at support.orbit.example.\n```\n\nDownload the app:\n\n```\ncurl -fsSLO https://synapcores.com/demos/two-stacks/rag_app.py\n```\n\nThis is the whole thing:\n\n```\n\"\"\"The same rag app on SynapCores: point it at the folder once, then ask with `rag`.\"\"\"\nimport json\nimport os\nimport sys\n\nimport httpx\n\ndb = httpx.Client(base_url=os.environ.get(\"SC_URL\", \"http://127.0.0.1:8080\"),\n                  headers={\"Authorization\": \"Bearer \" + os.environ[\"SC_KEY\"]}, timeout=600)\n\ndef index(folder):\n    r = db.post(\"/v1/filesystem-collections\", json={\n        \"name\": \"orbit_support\", \"filesystem_path\": folder, \"file_types\": [\"md\"], \"watch\": True,\n    })\n    r.raise_for_status()\n    return r.json()[\"data\"]\n\ndef ask(question):\n    r = db.post(\"/api/chat\", json={\n        \"model\": \"qwen2.5:7b\", \"stream\": False,\n        \"messages\": [{\"role\": \"user\", \"content\": question}],\n        \"options\": {\"temperature\": 0, \"seed\": 7},\n        \"rag\": {\"source\": \"filesystem:orbit_support\", \"top_k\": 3, \"strict\": True},\n    })\n    r.raise_for_status()\n    return r.json()[\"message\"][\"content\"], json.loads(r.headers.get(\"x-synapcores-citations\", \"[]\"))\n\nif __name__ == \"__main__\":\n    if sys.argv[1] == \"index\":\n        c = index(os.path.abspath(sys.argv[2]))\n        print(\"watching\", c[\"filesystem_path\"], \"| embeddings:\", c[\"embedding_model\"])\n    else:\n        answer, sources = ask(sys.argv[2])\n        print(answer, \"\\nsources:\", sources)\n```\n\nHere's what each block does.\n\n**The client.** `db` is a plain HTTP client pointed at SynapCores on port 8080. Every request sends your API key as a bearer token. The timeout is generous because the first answer after a start loads the model.\n\n**`index(folder)`.** This registers the folder as a *filesystem collection*, named `orbit_support`, for Markdown files. SynapCores reads each file, splits it into chunks, embeds the chunks with `nomic-embed-text` and keeps the vectors right next to the text. `\"watch\": True` tells it to keep watching the folder, so a new file gets indexed without you doing anything. In the usual stack, this one call is `ingest.py`, `schema.sql` and a Postgres container.\n\n**`ask(question)`.** This is SynapCores' Ollama-compatible `/api/chat` endpoint, with the same body you'd send to Ollama plus one extra field, `rag`. That field tells the database to retrieve the top 3 chunks from `filesystem:orbit_support`, put them in the prompt, and (because of `strict`) answer only from them. The answer comes back in `message.content`, the same as with Ollama. The sources come back in the `x-synapcores-citations` response header. In the usual stack, this one call is `api.py`, `rag.py`, a FastAPI container and an Ollama container.\n\n**The command line.** `python rag_app.py index <folder>` indexes a folder, and `python rag_app.py ask \"<question>\"` asks a question.\n\nIndex the folder:\n\n```\npython rag_app.py index docs\nwatching /Users/you/rag-demo/docs | embeddings: nomic-embed-text\n```\n\nSynapCores indexes the four files in the background, which takes about a second. Then ask the five test questions. Four of them are answered by the docs. The fifth is about price matching, which the docs don't cover, so the right answer is \"I don't know\".\n\n```\npython rag_app.py ask \"My Orbit X2 router shows error E-512. What should I do? Should I unplug it?\"\nFor error code **E-512** on your Orbit X2 router, which indicates a firmware signature mismatch, you should not power off the device immediately. Instead, follow these steps:\n\n1. Wait for 10 minutes to see if the issue resolves itself as the router attempts an automatic rollback.\n2. If the light remains red after 10 minutes, hold down the reset button on the router for 30 seconds. This can help trigger a firmware reset.\n\nIf the problem persists after these steps, it might be advisable to contact Orbit Networks support with your serial number and describe the issue in detail. They may provide further guidance or request that you file a warranty claim if necessary.\nsources: ['filesystem:orbit_support/troubleshooting-error-codes.md#0', 'filesystem:orbit_support/warranty.md#0', 'filesystem:orbit_support/returns-and-refunds.md#0']\npython rag_app.py ask \"How many years of warranty does the Orbit X2 Pro router have?\"\nThe Orbit X2 Pro router comes with a 5-year warranty.\nsources: ['filesystem:orbit_support/warranty.md#0', 'filesystem:orbit_support/troubleshooting-error-codes.md#0', 'filesystem:orbit_support/returns-and-refunds.md#0']\npython rag_app.py ask \"How long do I have to return a refurbished unit?\"\nYou have 14 days from the delivery date to return a refurbished unit if you wish to do so according to Orbit Networks' returns and refunds policy.\nsources: ['filesystem:orbit_support/returns-and-refunds.md#0', 'filesystem:orbit_support/warranty.md#0', 'filesystem:orbit_support/troubleshooting-error-codes.md#0']\npython rag_app.py ask \"How much does shipping to Hawaii cost, and how long does it take?\"\nShipping to Hawaii from Orbit Networks costs $15 with a delivery time of 6 to 9 business days.\nsources: ['filesystem:orbit_support/shipping.md#0', 'filesystem:orbit_support/returns-and-refunds.md#0', 'filesystem:orbit_support/warranty.md#0']\npython rag_app.py ask \"Do you offer price matching?\"\nThe provided context does not contain any information about price matching policies. Therefore, based on the given information alone, I cannot confirm whether Orbit Networks offers a price matching policy or not.\nsources: ['filesystem:orbit_support/shipping.md#0', 'filesystem:orbit_support/returns-and-refunds.md#0', 'filesystem:orbit_support/warranty.md#0']\n```\n\nThat's 5 out of 5. Each answer is correct, and the first source listed is the file that holds the answer: error codes, warranty, returns, shipping. For price matching, the bot says the docs don't cover it instead of inventing a policy. With temperature 0 and a fixed seed you should see the same answers. We got them word for word on three separate runs.\n\nThe list of sources is the top 3 chunks that were retrieved, ranked best first. Each document here is short enough to be a single chunk, which is why every source ends in `#0`.\n\nThe folder is watched, so you can add a document and ask about it straight away. Create a fifth doc:\n\n```\ncat > docs/orbit-care-plus.md <<'EOF'\n# Orbit Care+\n\nOrbit Care+ extended protection costs $49 per year and adds accidental damage cover.\nEOF\n```\n\nWait about three seconds, then ask:\n\n```\npython rag_app.py ask \"How much does Orbit Care+ cost?\"\nOrbit Care+ costs $49 per year and adds accidental damage coverage.\nsources: ['filesystem:orbit_support/orbit-care-plus.md#0', 'filesystem:orbit_support/shipping.md#0', 'filesystem:orbit_support/warranty.md#0']\n```\n\nYou didn't re-run anything. SynapCores noticed the new file, chunked and embedded it, and the very next question cited it. In our run the collection went from 4 files to 5 in 2.7 seconds. With the usual stack, this is the point where you run the ingest job again.\n\nThe other half of the video is the same app built the way most RAG tutorials build it. You can download it, along with everything from this post, in one zip: **[two-stacks-demo.zip](https://synapcores.com/demos/two-stacks/two-stacks-demo.zip)**.\n\nIt's a clean, typical setup that runs well:\n\n`db`` pgvector/pgvector:pg17`, with a `chunks` table and an HNSW cosine index (`schema.sql`).` ollama``ollama/ollama`, serving both the embedding model and the chat model.` ingest``ingest.py`) that chunks each file with overlap, embeds the chunks with nomic's document prefix, and replaces that file's rows in a transaction.`api`` api.py`) that embeds the question, runs the vector search, builds a \"use only this context\" prompt, calls the chat model and returns the sources.\nShared code lives in `rag.py`, and the container is described by `Dockerfile` and `requirements.txt`. With `docker-compose.yml` and `.env`, that's 8 files and 149 lines. It also answered 5 out of 5 with the right sources. The difference is everything you build and run to get there: start the services, pull the two models into Ollama, run the ingest job, then ask. The zip's `README.md` has the exact commands.\n\n`docs` for any folder of Markdown files, or change `file_types` to match yours.\nSynapCores Community Edition is free and self-hosted, with no signup. **[Download SynapCores](https://synapcores.com/download?utm_source=devto&utm_medium=crosspost&utm_campaign=two-stacks)** and build the app above in a few minutes. Then point it at your own docs.\n\n*Originally published at [synapcores.com](https://synapcores.com/blog/build-a-rag-app-in-one-database?utm_source=devto&utm_medium=crosspost&utm_campaign=rag-one-db).*", "url": "https://wpnews.pro/news/build-a-local-rag-app-with-citations-in-one-database-28-lines-of-python", "canonical_source": "https://dev.to/synapcores/build-a-local-rag-app-with-citations-in-one-database-28-lines-of-python-i1f", "published_at": "2026-11-14 16:16:31+00:00", "updated_at": "2026-11-14 16:21:32.895736+00:00", "lang": "en", "topics": ["ai-tools", "ai-infrastructure", "large-language-models", "developer-tools"], "entities": ["SynapCores", "Ollama", "Postgres", "pgvector", "qwen2.5:7b", "nomic-embed-text", "Orbit Networks"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/build-a-local-rag-app-with-citations-in-one-database-28-lines-of-python", "markdown": "https://wpnews.pro/news/build-a-local-rag-app-with-citations-in-one-database-28-lines-of-python.md", "text": "https://wpnews.pro/news/build-a-local-rag-app-with-citations-in-one-database-28-lines-of-python.txt", "jsonld": "https://wpnews.pro/news/build-a-local-rag-app-with-citations-in-one-database-28-lines-of-python.jsonld"}}