RAG LangGraph MCP Server
Enables local embedding generation and retrieval using the BGE-M3 model via Ollama, supporting local model inference.
Provides the language model for generating answers, using OpenAI's GPT-5-mini to synthesize responses from retrieved context.
Allows performing semantic search over a corpus stored in PostgreSQL using pgvector, retrieving relevant document chunks for the RAG agent.
Click on "Install Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@RAG LangGraph MCP ServerWhat is the deadline for providing access to records under § 164.524?"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
English · Español
RAG in Python — the same system, rebuilt in code
Rebuilding my n8n RAG as Python: the agent in LangGraph, exposed through FastAPI and MCP, with the API-versus-local-model comparison measured.
The first job was not to make it better. It was to make it identical. Until the Python retriever scores what this corpus already scores, any later comparison between the two agents would be measuring the port instead of the agent.
Stack: PostgreSQL 17 + pgvector · Ollama with BGE-M3 · psycopg · gpt-5-mini
Week 1: the retriever, ported and verified
Same 16 questions, same corpus of 581 chunks, same embedding model, same table the live n8n chat queries. The reference numbers were measured on 10 August; the Python ones on 11 August.
Reference | Python | |
Recall@10 | 16/16 | 16/16 |
MRR@10 | 0.938 | 0.938 |
Rank 1 | 14/16 | 14/16 |
Identical, to three decimals. The port is not a rewrite that happens to work — it retrieves the same chunks in the same order.
Worth being precise about what this compares. Both numbers come from querying the same table directly; neither goes through an n8n execution, because retrieval quality is a property of the corpus and the embedding model, not of the tool that calls them. The n8n-versus-LangGraph comparison is about the agent, and it lands in week 2.
What that buys: when that comparison happens, the retriever is no longer a variable.
The whole search is one line of SQL
SELECT metadata->>'seccion' FROM documentos_v3
ORDER BY embedding <=> %s::vector LIMIT 10<=> is pgvector's cosine distance operator. There is no vector database, no framework, and no
retrieval library involved — 25 lines of psycopg and requests replace the n8n nodes.
Related MCP server: personal-resume-agent
Week 2: the agent, and a metric that was lying
The agent is two nodes and one edge: search → write. The five system-prompt rules are copied
from the n8n workflow word for word — changing them would turn a framework comparison into a
prompt comparison.
graph TD;
__start__([question]):::first
buscar(search)
redactar(write)
__end__([answer]):::last
__start__ --> buscar;
buscar --> redactar;
redactar --> __end__;
classDef default fill:#f2f0ff,line-height:1.2
classDef first fill-opacity:0
classDef last fill:#bfb6fcThat diagram is not drawn by hand: agente.get_graph().draw_mermaid() emits it from the compiled
graph. Documentation that cannot drift from the code is the only kind that survives.
One of those rules stops being a rule here. Rule 1 tells the model to always search before
answering. In n8n that is a request the model is free to ignore. In a graph, START → search → write means the writer cannot run without retrieved chunks. The same instruction moves from
plea to structure, and that difference only became visible by building both.
The scores
20 questions, hand-verified answers, scored under the strict rule: a wrong citation is a failure even when the content is right.
corpus v3 | corpus v4 | |
Cited the right section | 14/16 | 15–16/16 |
Content correct | 14/16 | 15/16 |
Controls (silence is correct) | 4/4 | 4/4 |
Total | 17/20 | 19/20 |
Cost of all 20 | $0.028–0.029 | $0.027–0.030 |
The ranges are not hedging — they are the measurement. v4 was run twice and scored 16/16 and 15/16 on citations. Same table, same model, same prompt. Reporting a single number would have implied a precision this does not have.
What the repeat run separates cleanly:
Q9 fails in every v3 run and passes in every v4 run. That is not noise — it is the chunk moving from rank 63 to rank 2, and ranks are deterministic.
Q8 flips between runs in both corpora. It is the only unstable question, and it is the only one with three expected sections; the model reaches for § 164.304 (Definitions of the Security Rule), which sits semantically next to all three.
The finding: recall@10 was reporting 100% on a system delivering 81%
Three of the four failures had the same cause, and the metric could not see any of them.
recall@10 asks did the right section come back? — and it did. What it cannot ask is whether
the chunk carrying the answer came back. A section split across 17 chunks can arrive four times
without the sentence that answers the question.
Measured, chunk by chunk:
Question | Where the answering chunk actually ranked |
Access deadline (§ 164.524) | 63 of 581 |
Penalty factors (§ 160.408) | 26 — six places outside the window |
Business associate (§ 160.103) | 151 |
The system refused to answer the access-deadline question. That was the correct behaviour: the 30-day sentence was never in front of it. The writer was not the bottleneck; the metric was pointing at the wrong half.
The rejected experiment was rejected on bad evidence
An earlier corpus version — v4, which prefixes every chunk with its section heading — had been
measured and turned down: it moved MRR from 0.938 to 0.969, "one question in sixteen", at 4%
more input tokens forever.
That decision used the section-level metric. Re-measured against the chunk that answers:
Question | v3 | v4 |
Access deadline | 63 | 2 |
Penalty factors | 26 | 2 |
Business associate | 151 | 95 — still outside |
It works for a concrete reason. The chunk holding the access deadline opens with "(2) Timely
action by the covered entity" — it never says "access" or "health information". Prefixed with
§ 164.524 Access of individuals to protected health information, the vector knows what it is
about.
Both questions that jumped to rank 2 are the two that got fixed. And the 4% token argument ran backwards: v4 cost less overall, because a precise answer is a shorter answer.
The rejection was a sound decision made on a measurement that could not see the defect. That is the more useful lesson than any score here: an experiment is only as good as the metric that judged it.
Who scored what
The controls score themselves — their correct answer is one exact sentence. The 16 content judgements were made by reading each answer against the hand-verified one; Claude did the first pass and flagged the ambiguous cases, and I decided those. Three were genuinely arguable, and one of them I overruled. That is not independent evaluation and the repo should not pretend it is — it is a faster path to the same reading, with the disagreements recorded.
Week 2b: the loop, measured and rejected
A graph of two boxes and one arrow uses nothing of LangGraph — the framework exists for cycles. So
the agent grew a third node and a path that goes back: search → judge → search.
graph TD;
__start__([question]):::first
buscar(search)
decidir(judge)
redactar(write)
__end__([answer]):::last
__start__ --> buscar;
buscar --> decidir;
decidir -.-> buscar;
decidir -.-> redactar;
redactar --> __end__;
classDef default fill:#f2f0ff,line-height:1.2
classDef first fill-opacity:0
classDef last fill:#bfb6fcjudge reads the question and the 20 chunks and returns one of two things: SUFICIENTE, or a
fresh search query written in the vocabulary it just read. Capped at 5 turns, the same
maxIterations as the n8n agent. The writer's prompt was left untouched — if the score moved,
it moved because of the loop.

LangGraph Studio attached to a local server (langgraph dev), with LangSmith tracing off. Every
node can be opened to see what went in and what came out — the 20 chunks, the query the judge
wrote, the context the writer received.
The result
linear | with loop | |
Content correct | 15/16 | 15/16 |
Controls | 4/4 | 4/4 |
Total | 19/20 | 19/20 |
Seconds per query | 16.0 | 33.9 |
Input tokens, all 20 | 77,446 | 303,099 |
Cost of the 20 | $0.030 | $0.081 |
Citations went from 15/16 to 16/16, and it does not count: the only one that changed is question 8, which this repo already documents as the unstable one across runs.
The loop never turned once on the 16 questions that have an answer. The tokens show it — each spent about 7,400 on input, exactly two calls: judge and writer, one turn. The four controls spent about 46,000 each, the full five turns. 61% of the spend went to the four questions whose correct answer was to refuse. The judge charges on all twenty and only spins the loop where there is nothing to find.
Why, and it is more useful than the score
The loop was designed around one specific question: number 9, which the n8n agent got right and the linear graph got wrong. The trace showed two searches, the first teaching the second its vocabulary. But that was measured on corpus v3, where the answering chunk sat at rank 63. Under v4 it arrives at rank 2.
Fixing the data made the architecture unnecessary. A loop and a well-prepared corpus do not add up; they substitute for each other. The loop compensates for bad preparation, and you keep paying for it after the preparation is fixed.
The threshold that did not work either
If the loop only wastes tokens on questions with no answer, the obvious move is to skip the judge when not even the closest chunk resembles the question. Postgres already computes cosine distance while ordering, so the signal is free. Measured across all 20:
distance of the closest chunk | |
16 questions with an answer | 0.287 – 0.497 |
4 controls | 0.477 – 0.548 |
They overlap. A threshold at 0.47 would cut all four controls and also question 8, which does have an answer: 19/20 would become 18/20. Distance measures how unusual your phrasing is, not whether the answer is there.
And the loop stays in the repo anyway
Not because it improves the score — it does not — but because the system this repo describes is now the system it has. The experiment with its table is worth more than the deleted code: a negative result only teaches if it is published.
Week 3a: how far this holds
This system has 581 chunks and no vector index. The question is not how fast it is — it is 5 ms — but at what size an index becomes necessary, and what having one costs.
The real corpus does not change: rewriting the golden dataset over a million documents costs weeks of expert judgement and would destroy the one thing that made week 2's findings findable. The volume is generated; the queries are still the 16 real questions.
Measured on the server, in a separate container with a Docker memory cap — the public chat lives on
that machine and a benchmark does not get to take it down.
(benchmark/escala.sql)
vectors | table | no index | with index | recall@10 | index build | index size |
581 (the real ones) | 3.3 MB | 4.8 ms | — | — | — | — |
10,000 | 54 MB | 111 ms | 1.2 ms | 1.000 | 6 s | 78 MB |
100,000 | 535 MB | 1,472 ms | 5.3 ms | 1.000 | 84 s | 781 MB |
Without an index, time tracks row count: 17× the data, 23× the time. With one, 10× the data is 4× the time. The index costs no precision on this corpus — it costs space: 781 MB of index for 535 MB of data.
At 581 vectors the index was not needed, and that is now a number instead of an excuse: it would have meant adding 78 MB to save 3 milliseconds.
What extrapolates and what does not
A sequential scan is linear by definition, so scan time and storage can be computed. This is arithmetic on the measured row, not measurement:
table | index | no index | |
1,000,000 | 5.4 GB | 7.8 GB | ~15 s |
10,000,000 | 54 GB | 78 GB | ~2.5 min |
100,000,000 | 535 GB | 781 GB | ~25 min |
Indexed search does not extrapolate, and that is the part worth knowing. HNSW is fast while the
graph fits in memory; once it does not, every hop turns from a RAM read into a random disk read,
which is not "slower" but a different regime. The same break showed up during the build: pgvector
warns — hnsw graph no longer fits into maintenance_work_mem — and takes over twice as long.
That point can be computed from what was measured. The index weighs 7.81 KB per vector. With 6.1 GB of RAM free on this server, it stops fitting at roughly 780,000 vectors. And the whole disk is 38 GB, so at ten million the table alone does not fit.
The two failures that raised no error
The data generator broke the experiment silently. The first fill used a lateral subquery that depended on nothing in the row, so Postgres evaluated it once and reused the same noise: 100,000 rows that were about 1,700 distinct vectors. No errors, no warnings, plausible size, index built, queries fast. What gave it away was a recall of 0.063 — a number absurd enough to force a second look. At 0.85 it would have been published.
An index on a bloated table performs worse than no index. Dropping from 100,000 rows to 10,000,
plain VACUUM does not return the space: the table still occupied 586 MB holding 10,000 rows, and
the same index took 125 ms per query. After VACUUM FULL: 1.2 ms. A hundredfold, without
touching the index.
What this setup cannot claim
The 100,000 vectors are noised copies of 581 originals, so they form 581 neighbourhoods; a real corpus that size would carry far more topical variety. The recall of 1.000 is optimistic because of that. Latency and size do not depend on the distribution and hold; recall at scale on real data remains unknown.
Week 3b: two façades, one engine
Until now the system only ran by executing Python by hand. It now has two doors, and neither of them touches the engine.
recuperar.py agente.py
buscar_fragmentos() agente.invoke()
↑ ↖ ↑
api.py mcp_server.py
(FastAPI) (stdio)Both import the functions directly. The MCP server does not call the API over HTTP: they are sibling consumers of the same module, not a chain. When the engine changes, both change — and there is no network hop in between that can fail.
The API
Route | Returns | Cost |
| the state of Postgres and Ollama separately | $0 |
| chunks with their section and citation | $0 |
| answer, chunks, loop turns, tokens spent | ~$0.004 |
They are split on purpose. /v1/buscar is deterministic, instant and free; /v1/preguntar is
non-deterministic, slow and costs money. Measuring the two halves separately is what found week 2's
bug, and this is that lesson turned into architecture: finding out whether the database answers
should not cost an LLM call.
/health runs a real query against Postgres and a real embedding against Ollama. Returning a bare
{"status": "ok"} is precisely the failure this project has already hit twice.
The MCP server exposes one tool, not two
buscar_hipaa(pregunta, limite=20). It does not expose preguntar, and that is a decision. On
the other side of an MCP server sits Claude, which already writes; handing it preguntar would mean
paying gpt-5-mini to produce something the model on the other end was going to produce anyway,
with a worse writer in the middle. The tool worth handing a model is the one it cannot do alone:
search a private corpus.
The tool description carries a line that looks redundant and is not — "the question may arrive in Spanish, do NOT translate it". Without it, a helpful model translates to English before calling and breaks exactly what makes this system worth showing.
The two failures that building this surfaced
psycopg had no connection timeout. With the tunnel down, /health did not return an error: it
hung for over 130 seconds. A health check that hangs reads as "slow" rather than "down", which is
the worse of the two readings. And it was not an API problem: every script in this repo hung the
same way since week 1, and it never showed because the tunnel never dropped while something was
running. With connect_timeout=5: 503 in 7 seconds, naming which of the two failed.
load_dotenv() looked for .env in the current directory. Claude launches the MCP server from
its own folder, so it would have died on first start with a KeyError. Fixed with a path relative
to the file.
Neither raised an error while being built. Both surfaced from deliberately running the system broken — the same test the server's watchdog got.
The tests
Four, using FastAPI's TestClient, in 7 seconds and without calling OpenAI: health, empty
question (422), out-of-range limit (422), and a real search asserting every chunk carries text,
section and citation.
/v1/preguntar is not tested automatically: each run would spend money. It gets tested by hand
from /docs, and the repo says so rather than pretending otherwise.
Full design, including what was deliberately left out:
docs/2026-08-12-api-y-mcp-diseno.md.
Running it
Postgres and Ollama run in Docker on the server and publish no ports, so an SSH tunnel reaches them without opening anything to the internet:
ssh -i <key> -N -L 5433:<postgres-container-ip>:5432 -L 11435:<ollama-container-ip>:11434 <user>@<host>Then, with POSTGRES_USER, POSTGRES_PASSWORD and POSTGRES_DB in a local .env:
python -m venv .venv && .venv/Scripts/activate
pip install "psycopg[binary]" requests python-dotenv
python recuperar.pyThose container IPs are assigned by Docker and change when containers restart. docker inspect
gives the current ones.
The default nobody measured
topK = 20 came from the n8n chat and rode into the port unquestioned. Under corpus v4 the
answering chunk arrives at rank 2 — so eighteen chunks get paid for on every single query, and
nobody had checked what they buy.
Same corpus, same prompt, same 20 questions. Only TOP_K changed:
topK 20 | topK 5 | |
Controls (automatic) | 4/4 | 4/4 |
Cited the expected section (automatic) | 16/16 | 16/16 |
Input tokens, all 20 | 303,099 | 88,192 |
Output tokens | 41,738 | 45,336 |
Cost of the 20 | $0.081 | $0.057 |
Seconds per query | 33.9 | 27.5 |
3.4× less input for the same automatic score — and output went up. With less context the model writes slightly longer answers. The saving is real; "everything improved" would be a lie.
Declared rather than buried: the content column was not graded on this run. Both automatic measures are substring tests — did the expected section number appear, did the refusal phrase appear. Whether the sixteen answers still say the right thing takes a human reading them, and nobody has. This table supports "the automatic score did not move". It does not support "quality held".
And it changes what week 4 has to watch. Ollama's default context is 4,096 tokens and it truncates past that without a word. The 16 questions with an answer now spend 1,731–2,429 input tokens each: all of them fit. The four controls spend 10,006–19,719 — none of them do, because those are the only questions where the loop actually turns and piles up chunks. A local model measured at the default context would look fine on the sixteen and fail the four in silence.
Adopted: TOP_K defaults to 5. The value the measurement chose is the value the system runs
on — a repo whose README publishes one number while its code runs another is worse than not
measuring. The environment variable stays only while week 4 needs to run both.
Week 4: what privacy costs, in a number
Two environment switches, and the writer is the only thing that changes: MODELO picks the model,
MODELO_URL points at Ollama — which speaks the OpenAI protocol. buscar, the corpus and the
prompt are untouched. That was the architecture's own test and it passed: if swapping the cloud
for a local model had cost more than two variables, the design was wrong.
The loop is off in both runs (MAX_VUELTAS=1). It is already measured as useless (#4), and it
is the only thing that inflates the four controls to 19,719 input tokens — which do not fit in 6 GB
of VRAM. The condition still holds: only the writer changes.
The result
Measured on a GTX 1660 Ti, 6 GB, Windows with the desktop running.
|
| |
Content correct | 13/16 | 9/16 |
Controls | 4/4 | 4/4 |
Total | 17/20 | 13/20 |
Seconds per query | 18.7 | 89.2 |
Output tokens, all 20 | 24,769 | 84,750 |
Cost of the 20 | $0.031 | $0.000 |
The local model refused all four traps. No invented GDPR fine, no HIPAA certification, no Colombian obligation, no AI clause. A 2.5 GB model on a consumer card held the line that matters most in a compliance corpus.
The finding the score cannot show
Question 14 asks for the five elements a breach notice must contain. Both models gave four.
gpt-5-minifilled the fifth with text from a different section — § 164.410, which governs what a business associate reports to a covered entity, not what an individual is told.qwen3:4bgave four and stopped.
The automatic check scores those identically. Under a compliance lens they are not remotely the same: a missing element is a gap you can find; a fabricated one is a gap that looks filled.
Where each one actually failed
Question 6 fails on both, identically — the definition of business associate. Its answering chunk sits at rank 95 even under v4. That is a retrieval failure, not a writer one, and no model swap can fix it.
gpt-5-minialso lost 15, returning six of the eight required elements of a privacy notice.qwen3:4bscrambled the civil penalty tiers in question 12 — "less than $1,000 or more than $50,000 per violation" parses to nothing. That single answer cost 177.9 seconds and 8,411 output tokens, the worst of the twenty. It also dropped qualifiers elsewhere: "more than 500 individuals" where the rule says "500 residents of a State", and the bare 30- and 60-day deadlines without the one-time extension both carry.
Two costs that are not the score
84,750 output tokens against 24,769, for answers often six words long. qwen3 reasons at length and answers briefly, and the reasoning is billed as output. It is also why the context had to be raised: one smoke-test query spent 2,266 + 2,449 = 4,715 tokens, and Ollama's 4,096 default truncates past that without a word. Measured at the default, a local model would look fine on the sixteen and fail the four in silence.
89.2 seconds against 18.7. Free is not free. It is paid in time.
What this does not claim
These two columns compare to each other, not to the 19/20 published in week 2. Between those measurements two things changed at once — the loop came off and the grading criterion was tightened — so attributing the difference to either would repeat the exact mistake this repo spent three weeks finding.
The criterion, stated so it can be argued with: strict. A missing element from a required list, or a dropped qualifier that changes the legal meaning, counts as a failure.
Who graded: the four controls grade themselves on an exact phrase. The 16 content answers were read by Claude in a first pass, with Andrés setting the criterion. That is not independent evaluation — the same system that helped build this also graded it.
One run each. Neither model is deterministic.
Windows with a desktop costs ~1.3 GB of VRAM a headless Linux server does not pay, and 89.2 seconds describes this card, not server hardware.
The VRAM ceiling, measured
| model size | where it runs |
4,096 | 3.2 GB | 100% GPU |
8,192 | 3.9 GB | 100% GPU |
16,384 | 5.4 GB | 21% CPU / 79% GPU |
24,576 | 6.7 GB | 37% CPU / 63% GPU |
8,192 is the ceiling on 6 GB. Past it the model splits and the latency stops describing a GPU. These runs used 8,192, verified at 100% GPU across all twenty queries.
What's next
A retrieval metric that measures the chunk, not the section. The current one reported 16/16 while three answers were missing from what reached the model. Everything else is downstream of fixing that.
The remaining failure, § 160.103. Its answering chunk sits at rank 95 even under v4, and it opens with "(i) On behalf of such covered entity" — the words "business associate" appear nowhere in it. Retrieval alone may not reach it; returning the whole section when a chunk from it ranks is the obvious candidate, and it costs 4.6× the tokens, so it gets measured before it gets adopted.
The loop against corpus v3. If the hypothesis is that a loop compensates for bad preparation, running it on v3 — kept on purpose — should lift the 17/20 the linear flow scored there. It is the missing cell of a four-cell table.
The evaluator in CI. Every change runs the 20 questions and reports if the score drops, instead of relying on remembering to run them by hand.
Deploy with Docker, and measure
gpt-5-miniagainst a local model. That comparison is the privacy argument with a number attached instead of a claim.
Still open, and named rather than buried: these numbers are one run each. The model is not deterministic, and two questions out of sixteen would not survive a paired test. What carries the argument is the mechanism — the ranks were measured before any question was re-run, and the two questions that improved are exactly the two the ranks predicted.
Not compared yet: n8n against LangGraph on answer quality. The published n8n score was measured on corpus v2, and putting it in the same table as a v3/v4 number would repeat the mistake this week was spent finding.
This server cannot be installed
Maintenance
Resources
Unclaimed servers have limited discoverability.
Looking for Admin?
If you are the server author, to access and configure the admin panel.
Related MCP Connectors
Agent-native MCP server over the public saagarpatel.dev corpus. Read-only, stateless.
Analytical memory for AI agents: a real Postgres queried in plain English over MCP. One command.
Agentic search over your Dewey document collections from any MCP-compatible client.
Let AI agents query data and act across all your business apps via MCP.
Related MCP Servers
- FlicenseNot gradedqualityDmaintenanceAn MCP-compatible RAG backend using LangGraph and FastAPI, enabling chaining of AI model logic with document context search.1
- AlicenseNot gradedqualityDmaintenanceExposes a personalized AI agent that reads your resume and provides intelligent responses about your professional background through a standardized MCP server interface with RAG capabilities.MIT
- AlicenseAqualityAmaintenanceA multi-agent Retrieval-Augmented Generation system exposed as an MCP server. Ask a question and a LangGraph pipeline plans the retrieval, pulls evidence from a pgvector knowledge base, optionally augments it with live web research, drafts a cited answer, and then self-critiques it for grounding — revising until the answer is supported by the sources.31MIT
- FlicenseNot gradedqualityCmaintenanceExposes an existing RAG API as MCP tools, enabling health checks and document-based question answering with source evidence.
Latest Blog Posts
- Who's Calling? MCP Hosts Are an Identity Blind Spot (And the Spec Knows It)By Om-Shree-0709 on .mcpAgent IdentityOAuth 2.1
- Your AI Chatbot Just Exposed Your CEO's Salary to an InternBy Om-Shree-0709 on .Agent IdentityMCP SecurityOAuth Delegation
- Why MCP Servers Need Execution Sandboxing (And Why Your Current Stack Isn't Enough)By Om-Shree-0709 on .Agentic AiPrompt InjectionWebAssembly
MCP directory API
We provide all the information about MCP servers via our MCP API.
curl -X GET 'https://glama.ai/api/mcp/v1/servers/andresjmnz92-jpg/rag-langgraph'
If you have feedback or need assistance with the MCP directory API, please join our Discord server