<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: www.aekanun.com</title>
    <description>The latest articles on DEV Community by www.aekanun.com (@aekanun).</description>
    <link>https://dev.to/aekanun</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3873215%2F36ab0b56-5c02-4aa9-8c1a-e44f441d2bd3.png</url>
      <title>DEV Community: www.aekanun.com</title>
      <link>https://dev.to/aekanun</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/aekanun"/>
    <language>en</language>
    <item>
      <title>Why Vector RAG Answers Only Half of a Legal Question: Building a GraphRAG Agent That Follows the Law's Cross-References, in 5 Steps</title>
      <dc:creator>www.aekanun.com</dc:creator>
      <pubDate>Fri, 02 Oct 2026 07:14:56 +0000</pubDate>
      <link>https://dev.to/aekanun/why-vector-rag-answers-only-half-of-a-legal-question-building-a-graphrag-agent-that-follows-the-1opk</link>
      <guid>https://dev.to/aekanun/why-vector-rag-answers-only-half-of-a-legal-question-building-a-graphrag-agent-that-follows-the-1opk</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; Statutes don't repeat themselves, they point: the penalty for violating Section 26 sits sixty sections away and shares no vocabulary with it, so vector search finds half the answer. This post builds the other half in five steps: section-level chunks, a 190-node knowledge graph of the law, dense + sparse embeddings in Qdrant, a 2-hop graph walk, and an MCP server a plain-Python agent can call. Code: &lt;a href="https://github.com/aekanun2020/pdpa-graphrag-mcp" rel="noopener noreferrer"&gt;github.com/aekanun2020/pdpa-graphrag-mcp&lt;/a&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;From the 96 sections of Thailand’s PDPA to a 190-node knowledge graph, hybrid search, and an MCP server any agent can call — all running locally in Docker&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;This article walks through building a question-answering system for Thailand’s Personal Data Protection Act (PDPA, B.E. 2562 / 2019), from the 96 sections of statute text to an agent that finds its own way through a knowledge graph. I built the system for a developer course I teach, and rewrote it here so that you can follow it from scratch without having been in the room. The example is Thai law, but if you have ever pointed RAG at GDPR, HIPAA, the CCPA, or Japan’s APPI, you have met the same problem: statutes do not repeat themselves, they point. A prohibition lives in one section, its exceptions in sub-clauses, and the penalty sits chapters later in a sentence that says only “whoever violates Section 26”. Plain vector RAG finds the first half and misses the second.&lt;/p&gt;

&lt;p&gt;To keep the article readable I have split the material into three parts. This part builds the system in five steps. Part two is about a bug that made the agent cite the wrong penalty section while every log looked fine. Part three is about designing tools so that an agent uses the graph the right way. Everything runs on one machine with Docker and a short list of parts: NetworkX for an in-memory knowledge graph, Qdrant for vectors, BGE-M3 for dense and sparse embeddings, FastMCP to expose it all as an MCP server, and an agent loop written in plain Python. Every number in this article (190 nodes, 380 edges, 570 profiles, 96 chunks) comes from actually running the code. One note on notation: Thai statutes use Thai numerals, so “มาตรา ๒๖” in the data and code is “Section 26” in the prose.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why vector search alone is not enough for law
&lt;/h2&gt;

&lt;p&gt;Before building anything, a quick reminder of how the RAG we all know works. We cut documents into chunks, turn each chunk into a vector, turn the question into a vector, and hand the model the chunks that sit closest to the question. This works beautifully when the answer lives in a chunk that talks about the same thing as the question: ask about the leave policy, and the answer is on the leave-policy page.&lt;/p&gt;

&lt;p&gt;Law is not written that way. Here is the question I use to test the system: &lt;em&gt;“Can we collect our employees’ health data, and what is the penalty if we get it wrong?”&lt;/em&gt; The first half is easy, because Section 26 literally contains the words “health data” (it is the PDPA’s counterpart to GDPR’s Article 9 on special categories of data). The second half lives in Sections 79 and 84, which read “A data controller who violates Section 26 paragraph one or paragraph three … shall be liable to an administrative fine of up to five million baht” (roughly US$150,000). Neither section mentions health. Neither mentions employees. They contain only a section number pointing back to Section 26. The vector of the question and the vector of the penalty section are far apart, even though legally they are one story. GDPR readers will recognise the pattern: Article 83(5) defines the higher fine tier purely by listing the articles whose violation triggers it [12].&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This is what the field calls context fragmentation: the answer is spread across several places, and what ties those places together is not similarity of wording but citation.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I like to explain it with a library. Vector RAG is an excellent librarian who fetches the books whose titles resemble your question. A lawyer reading Section 26 does something the librarian does not: she reads the footnotes, walks over to the sections they cite and the sections that cite them, and keeps going until she holds the prohibition, the exceptions, and the penalties together. GraphRAG records those footnotes as edges of a graph (sec_84 → REFERENCES_SECTION → sec_26) and lets the system do the walking. The ideas were popularised by Microsoft’s GraphRAG [2] and by LightRAG [3]; the system in this article borrows its dual-level keyword scheme from the latter.&lt;/p&gt;

&lt;p&gt;So plain RAG needs four additions: (1) a graph whose edges reflect real legal relationships; (2) a way to find the right starting point in that graph, which is where vectors come back in; (3) a traversal that knows when to stop before it sweeps unrelated sections into the context; and (4) the original statute text, which still has to reach the model, because a graph can tell you what connects to what but not what the law actually says. Those four additions are the skeleton of the five steps that follow.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture at a glance
&lt;/h2&gt;

&lt;p&gt;Before splitting the work into steps, here is the whole system (Figure 1), which comes up with a single &lt;code&gt;docker compose up&lt;/code&gt;. Read it top to bottom: the agent talks to the MCP server through five tools, the MCP server hands off to an engine with two legs, a graph and a vector index, and both legs are built from just two data files.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9815c57fst56v7bbjtsr.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9815c57fst56v7bbjtsr.png" alt="Figure 1 · PDPA GraphRAG architecture: Agent → MCP Server → Engine → Data" width="800" height="610"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Figure 1 · PDPA GraphRAG architecture: Agent → MCP Server → Engine → Data&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The graph and the vectors are not competing. Vectors find the starting point, the graph walks onward, and MCP is the door that lets any agent use both.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Why these parts? &lt;strong&gt;NetworkX&lt;/strong&gt; instead of a graph database, because the graph of one statute has 190 nodes: it loads in a fraction of a second, breadth-first search is a dozen lines you write yourself, and you can watch the traversal hop by hop instead of hiding it behind a query language (the build script also exports a .cypher file in case you want Neo4j later). &lt;strong&gt;Qdrant&lt;/strong&gt;, because it stores dense and sparse named vectors in one collection and does RRF fusion natively. &lt;strong&gt;BGE-M3&lt;/strong&gt;, because it is multilingual (Thai, Japanese, German and English all work with the same model) and produces both vector types from a single encode call. &lt;strong&gt;FastMCP&lt;/strong&gt; over streamable-http, because any client can connect: Claude Desktop, LangFlow, or the agent loop we write ourselves.&lt;/p&gt;

&lt;p&gt;The most deliberate design decision is that the server-side LLM (via OpenRouter) does exactly one job, breaking the question into keywords. Answering is entirely the job of the agent on the client side. The short version: &lt;em&gt;the server retrieves, the agent reasons.&lt;/em&gt; Docker runs two containers, the MCP server (engine included) and Qdrant, and the Dockerfile bakes BGE-M3 (about 2.3 GB) into the image, trading a bigger image for a container that starts without downloading a model.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 1 — Give the text structure: one section = one chunk
&lt;/h2&gt;

&lt;p&gt;The first step involves no AI at all, yet it decides the quality of everything after it. We convert the PDPA from PDF to Markdown with four heading levels that mirror the statute’s real structure: H1 is the Act (1), H2 the chapters plus transitional provisions (8), H3 the parts (5), and H4 the sections (96). A parser then walks the file line by line, remembers the heading at each level as the current context, and closes the previous section into a chunk every time it meets a new &lt;code&gt;####&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lightrag/knowledge_graph.py — trimmed to the essentials
# Walk line by line, remember each heading level, flush on every new section
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#### &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;          &lt;span class="c1"&gt;# new section -&amp;gt; close the previous one as a chunk
&lt;/span&gt;        &lt;span class="nf"&gt;flush_section&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;current_h4&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;:].&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;      &lt;span class="c1"&gt;# "มาตรา ๒๖" = Section 26
&lt;/span&gt;        &lt;span class="n"&gt;current_body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;### &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;         &lt;span class="c1"&gt;# part
&lt;/span&gt;        &lt;span class="nf"&gt;flush_section&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;current_h3&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;:].&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;current_h4&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;## &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;          &lt;span class="c1"&gt;# chapter
&lt;/span&gt;        &lt;span class="nf"&gt;flush_section&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;current_h2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;:].&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt; &lt;span class="n"&gt;current_h3&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;current_h4&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;current_h4&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;current_body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="c1"&gt;# body of the current section
&lt;/span&gt;
&lt;span class="c1"&gt;# In flush_section(): the text that gets embedded always carries its chapter/part
&lt;/span&gt;&lt;span class="n"&gt;chunk_store&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;safe_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;current_h2&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;] [&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;current_h3&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;] &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;section_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;body_text&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;section_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;section_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;header_2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;current_h2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;header_3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;current_h3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the last few lines. Each chunk’s text is not just the statute; it is prefixed with the chapter and part it belongs to, e.g. &lt;code&gt;[Chapter 2 Personal Data Protection] [Part 2 Collection of Personal Data] Section 26&lt;/code&gt;, so the section’s vector knows where it lives. These are two of the three chunking techniques I wrote about earlier [9]: split along the document’s own structure, and put back the context that splitting removed.&lt;/p&gt;

&lt;p&gt;Why not the usual 800-character sliding window? Because the unit lawyers cite is the section. Cut mid-section and the second paragraph of Section 26 can land in the same chunk as the head of Section 27, after which the model will cite the wrong section number with complete confidence. The downside is just as plain: a long section like 26, with its many exception sub-clauses, becomes one big chunk, which we compensate for in the next step by pulling the exceptions out as graph nodes. When the parser runs you should see &lt;code&gt;Parsed 96 sections (มาตรา), 96 chunks&lt;/code&gt;, matching the PDPA exactly. If you see fewer, check that every section heading really starts with &lt;code&gt;####&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — Design the schema and build the knowledge graph: 190 nodes / 380 edges
&lt;/h2&gt;

&lt;p&gt;The graph is the heart of the system, and the first design question is not “which tool” but “what kinds of things exist in this law, and how do they relate”. For the PDPA that gives 11 node types: Law (1), Chapter (9), Part (5), Section (96), Definition (11), LawfulBasis (7), Right (8), Obligation (7), Penalty (6), Exemption (33) and Principle (7). The edges come in 26 types, with two forming the backbone: REFERENCES_SECTION (122 edges, section cites section) and CONTAINS_SECTION (119 edges, chapter contains section), followed by GRANTS_EXEMPTION (33), EMBODIES_PRINCIPLE (14), DEFINES (11), IMPOSES_OBLIGATION (7) and PRESCRIBES_PENALTY (6). The counts say something: this law references itself more than it does anything else, which is exactly why vector-only RAG falls short.&lt;/p&gt;

&lt;p&gt;A real example from &lt;code&gt;pdpa_knowledge_graph.json&lt;/code&gt;: Section 37 (the controller’s duties, roughly the PDPA counterpart of GDPR Articles 32–33 on security and breach notification) is one node with edges out to duties, principles, exceptions and the sections it cites, and edges in from the penalty sections that cite it (Sections 83 and 86). All of this is what the BFS in Step 4 will walk.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sec_37"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"label"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Section"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"number"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;37&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"มาตรา ๓๗ ผู้ควบคุมข้อมูลส่วนบุคคลมีหน้าที่ ดังต่อไปนี้ ... (full Thai text of the section)"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sec_37"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"target"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"obl_breach_notify"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"relationship"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"IMPOSES_OBLIGATION"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sec_37"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"target"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sec37_ex_medical"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"relationship"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"GRANTS_EXEMPTION"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sec_83"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"target"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sec_37"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;            &lt;/span&gt;&lt;span class="nl"&gt;"relationship"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"REFERENCES_SECTION"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"source"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sec_79"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"target"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pen_criminal_disclosure"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"relationship"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PRESCRIBES_PENALTY"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The graph is built by a rule-based parser (&lt;code&gt;scripts/build_graph.py&lt;/code&gt;) that does three things. First, the regex &lt;code&gt;^#{1,6}\s*มาตรา\s+([\d๐-๙]+)&lt;/code&gt; finds section headings in the Markdown and creates the 96 Section nodes; it accepts Thai and Arabic digits and ignores how many &lt;code&gt;#&lt;/code&gt; or spaces precede the number, which matters because PDF-derived text has irregular spacing and a stricter pattern silently drops sections without raising any error. For another jurisdiction you would swap the pattern, for example &lt;code&gt;Article\s+(\d+)&lt;/code&gt; for GDPR or &lt;code&gt;第([一二三四五六七八九十百]+)条&lt;/code&gt; for Japan’s APPI, whose article numbers are written in kanji numerals. Second, the pattern &lt;code&gt;มาตรา\s+([\d๐-๙]+)&lt;/code&gt; scans each section’s body for references to other sections and creates a REFERENCES_SECTION edge from the citing section to the cited one, 122 of them in the shipped graph, with no LLM involved. Third, the semantic nodes (definitions, rights, obligations, penalties, exemptions, principles, lawful bases) are hand-curated lists in the script, each entry tagged with the section it comes from, so the script emits DEFINES or GRANTS_EXEMPTION edges at the same time. This approach is fast, free of API cost, and deterministic: run it ten times, get the same graph. If you want an LLM to extract more entities, there is a switch, &lt;code&gt;USE_LLM_EXTRACT=true&lt;/code&gt;, that merges the extracted graph into the main one with &lt;code&gt;nx.compose&lt;/code&gt;, at the price of time, money and review work.&lt;/p&gt;

&lt;p&gt;Before moving on, validate the graph from the data itself, not from the metadata the script wrote (the sample file in this article still carries stale metadata saying 163 / 343, while the real counts are 190 / 380). Three checks: there must be exactly 96 Section nodes, the graph must be a single weakly connected component (no island of sections the BFS cannot reach), and any section you know carries a penalty must reach a Penalty node within two or three hops.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;networkx&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;nx&lt;/span&gt;
&lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data/pdpa_knowledge_graph.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;G&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;nx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;DiGraph&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nodes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_node&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;edges&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt; &lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_edge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;target&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;relationship&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;relationship&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number_of_nodes&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number_of_edges&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;                          &lt;span class="c1"&gt;# 190 380
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;label&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Section&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;  &lt;span class="c1"&gt;# 96
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_weakly_connected&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;                                          &lt;span class="c1"&gt;# True
&lt;/span&gt;&lt;span class="n"&gt;hubs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(((&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;degree&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;label&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Section&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;x&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hubs&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;                                                           &lt;span class="c1"&gt;# sec_26 (24), sec_24 (23), sec_37 (21)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two numbers from this graph will shape Step 4. The average degree is 2 × 380 / 190 = 4, so two hops from a node sweep in about 16 nodes, which is about right for one question’s context. And the most connected hubs are Sections 26 (degree 24), 24 (23) and 37 (21), which happen to be the sections people ask about most.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7rsnq6ymgspg43f8jmlp.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7rsnq6ymgspg43f8jmlp.png" alt="Full PDPA knowledge graph, 190 nodes and 380 edges" width="800" height="639"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Figure 2 · The full graph, 190 nodes / 380 edges (red = Law, blue = Chapter, green = Section)&lt;/em&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 3 — Dense + sparse embeddings with BGE-M3, stored in Qdrant
&lt;/h2&gt;

&lt;p&gt;This step produces two kinds of vectors, and the second kind is what separates GraphRAG from plain RAG. The first kind is the 96 section chunks from Step 1, stored in the &lt;code&gt;pdpa_chunks&lt;/code&gt; collection. The second is a “profile” of the graph: every node (190) and every edge (380) rendered as a short text, e.g. &lt;code&gt;sec_26 (Section): ... relationships: DEFINES: def_sensitive_data; GRANTS_EXEMPTION: sec26_ex1; ...&lt;/code&gt;, embedded and stored in &lt;code&gt;pdpa_profiles&lt;/code&gt;, 570 entries in all. The point is to let vector search land on graph nodes rather than only on text, because the node it lands on becomes the seed from which the graph walk in the next step departs.&lt;/p&gt;

&lt;p&gt;The model is BGE-M3 [4], which returns two vectors from a single encode call: a 1024-dimensional dense vector carrying meaning, and a sparse vector of lexical weights that says which tokens matter, much like BM25. Why both? Think about legal vocabulary. Terms like “Section 26”, “sensitive data” or “data controller” have to match exactly. Dense vectors are good at nearby meaning, but may consider “data controller” and “data processor” close neighbours, even though the law gives them different duties and different penalties. The sparse vector anchors the terms that must match literally. The two result lists are merged with Reciprocal Rank Fusion [5], which Qdrant does natively [7]: a document ranked well in both lists rises to the top, with no weight to tune between dense and sparse.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lightrag/vector_index.py — hybrid search on Qdrant
&lt;/span&gt;&lt;span class="n"&gt;emb&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;return_dense&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;return_sparse&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;dense_vec&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;emb&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dense_vecs&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;                        &lt;span class="c1"&gt;# 1024-d
&lt;/span&gt;&lt;span class="n"&gt;sparse_vec&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;_to_sparse_vector&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;emb&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lexical_weights&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query_points&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;collection_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pdpa_profiles&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;prefetch&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="nc"&gt;Prefetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;dense_vec&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tolist&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;using&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dense&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;top_k&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nc"&gt;Prefetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;sparse_vec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;         &lt;span class="n"&gt;using&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sparse&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;top_k&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;FusionQuery&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fusion&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Fusion&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RRF&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;   &lt;span class="c1"&gt;# merge the two rankings with RRF
&lt;/span&gt;    &lt;span class="n"&gt;limit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;top_k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One operational note before you build: both collections are dropped and recreated every time the server starts, so after editing the graph or the text you restart the container; there is no hot reload. A successful start logs &lt;code&gt;Upserted 570 profile vectors&lt;/code&gt; and &lt;code&gt;Upserted 96 chunk vectors&lt;/code&gt;, and those numbers must match Steps 1 and 2 exactly. If they do not, something was lost upstream.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — Hybrid retrieval: keywords → vectors → BFS → context
&lt;/h2&gt;

&lt;p&gt;This is where graph and vectors work together. When the agent calls &lt;code&gt;search_pdpa(query)&lt;/code&gt;, a five-stage pipeline runs (Figure 3), all of it in &lt;code&gt;lightrag/retrieval.py&lt;/code&gt;, under 200 lines.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyd8g7di64z52x02d0apg.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyd8g7di64z52x02d0apg.png" alt="Figure 3 · The search_pdpa pipeline: keywords → vectors → BFS → context" width="800" height="699"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Figure 3 · The search_pdpa pipeline: keywords → vectors → BFS → context&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Stage one asks an LLM to break the question into keywords at two levels: low-level (specific: section numbers, named rights, penalties) and high-level (broad: principles, categories, concepts). The dual-level idea comes from LightRAG [3], and it solves a problem you hit constantly: people ask in everyday language (“collect employee health data”) while the statute speaks statute (“personal data concerning … health … without explicit consent”). The keywords act as the interpreter between the two. Stages two and three run the hybrid search from Step 3 twice: once over profiles, to find the nodes that will serve as seeds, and once over chunks, to fetch the full text of the most relevant sections, at most five.&lt;/p&gt;

&lt;p&gt;Stage four is the heart of GraphRAG. Take up to three seeds and run a breadth-first search over NetworkX for at most two hops, following edges in both directions, because “a penalty section cites Section 26” is an arrow pointing &lt;em&gt;into&lt;/em&gt; Section 26, and following arrows forward only would never reach a penalty. Every node reached is written into the context together with the relationship that led there, e.g. &lt;code&gt;[hop1][GRANTS_EXEMPTION] sec26_ex5c: sensitive data may be collected for labour-protection purposes...&lt;/code&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;That tag matters more than it looks: the model learns in what capacity a node appears, as an exception, a definition or a penalty, rather than receiving a pile of loosely related text.&lt;br&gt;
&lt;/p&gt;


&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# lightrag/retrieval.py — _expand_from_graph (trimmed to the essentials)
&lt;/span&gt;&lt;span class="n"&gt;queue&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;deque&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;sid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;sid&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;seed_ids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;      &lt;span class="c1"&gt;# seeds from profile search, ≤ 3
&lt;/span&gt;&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hop&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;popleft&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;hop&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;max_hops&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                            &lt;span class="c1"&gt;# max_hops = 2
&lt;/span&gt;        &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="n"&gt;neighbors&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;successors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;predecessors&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;nb&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;neighbors&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;nb&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;visited&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="n"&gt;visited&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;nb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;edge&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nb&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;edges&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;nb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;current&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;rel&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;edge&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;relationship&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;related&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;desc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;G&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;nodes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;nb&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;expanded&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;[hop&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;hop&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;][&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;rel&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;] &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;nb&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;desc&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# the edge name travels along
&lt;/span&gt;        &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;nb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;hop&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why two hops and three seeds? Recall the numbers from Step 2. An average degree of 4 means one seed reaches about 4 nodes on the first hop and 16 on the second. But if the seed is a hub like Section 26, two hops sweep in 69 nodes (24 on hop one, 45 on hop two), and three hops would overflow any sensible context window. That is also why stage five has to cut: at most 8 profiles, 15 expanded nodes and 5 full sections. For the employee-health-data question, the seed lands on Section 26, and the first hop already yields the definition of sensitive data, all 9 exceptions and the 11 sections that cite Section 26, Sections 79 and 84 among them. The half of the question that vector RAG could not find is in the context after one hop.&lt;/p&gt;

&lt;p&gt;A last point, and an architectural decision: &lt;code&gt;search_pdpa&lt;/code&gt; does not write an answer. It returns the context as JSON and leaves the reading and answering to the agent’s LLM. The same server therefore works with any model; I have run it with Qwen and with Claude through OpenRouter without touching the server.&lt;/p&gt;

&lt;p&gt;Two objections I expect from readers who build RAG for a living. &lt;em&gt;Why not just use bigger chunks, or parent-document retrieval?&lt;/em&gt; Larger chunks do pull Section 26 and its neighbours into one window, but the penalty for Section 26 sits in Chapter 7, almost sixty sections away, and no chunk size short of “the whole Act” puts them together. What connects them is a citation, and the graph stores exactly that. &lt;em&gt;Does this scale?&lt;/em&gt; Not as built, and I would rather say so than imply otherwise. One statute is 190 nodes in memory, rebuilt in seconds at startup; a corpus of laws with amendments and regulations needs a graph database (the build script already emits Cypher for Neo4j), incremental indexing, and an evaluation set with expected citations, which this project does not have yet. The design decisions here are meant to be visible, not final.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5 — Expose it as an MCP server and let the agent walk the graph
&lt;/h2&gt;

&lt;p&gt;Everything built so far is exposed with FastMCP, an implementation of the Model Context Protocol [6], by putting the &lt;code&gt;@mcp.tool()&lt;/code&gt; decorator on ordinary Python functions. FastMCP generates &lt;code&gt;tools/list&lt;/code&gt; and &lt;code&gt;tools/call&lt;/code&gt;, turning the function signature into the inputSchema and the docstring into the description. This is the point I most want to stress: the docstring is not a comment for humans, it is the tool’s user manual, which the agent reads before every decision. So the workflow we want goes straight into it. (The real docstrings are in Thai; they are translated here.)&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# mcp_server_rag.py — trimmed to the essentials (docstrings translated from Thai)
&lt;/span&gt;&lt;span class="n"&gt;mcp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastMCP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PDPA-RAG&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.0.0.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;8100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
              &lt;span class="n"&gt;stateless_http&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json_response&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@mcp.tool&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;search_pdpa&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;top_k&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Search PDPA knowledge; returns context from the knowledge graph + original section text.

    Always call this first for any PDPA question.
    Then look at the sections found and call get_related_sections
    to find linked sections, especially exemptions, penalties, rights, duties.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rag&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;top_k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;top_k&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;keywords&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;keywords&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                       &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;context&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;context&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]},&lt;/span&gt; &lt;span class="n"&gt;ensure_ascii&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@mcp.tool&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_penalty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;section_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Find the penalties linked to a section (BFS, at most 3 hops).

    section_id is the section that was violated, not the penalty section, e.g.
    &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;penalty for violating Section 26&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt; -&amp;gt; get_penalty(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sec_26&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;)  (not &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sec_84&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;)
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The five tools have clearly separated jobs: &lt;code&gt;search_pdpa&lt;/code&gt; does the hybrid search of Step 4, &lt;code&gt;get_related_sections&lt;/code&gt; walks the in- and out-edges of one section, &lt;code&gt;get_penalty&lt;/code&gt; runs a BFS to Penalty nodes, &lt;code&gt;get_section_text&lt;/code&gt; returns the full text of a section by id, deterministically, and &lt;code&gt;get_pdpa_summary&lt;/code&gt; describes the graph’s structure. After &lt;code&gt;docker compose up --build&lt;/code&gt;, one thing trips people up: a bare curl to &lt;code&gt;http://localhost:8100/mcp&lt;/code&gt; returns the error &lt;code&gt;-32600 Client must accept text/event-stream&lt;/code&gt;. That means the server is alive; the streamable-http transport simply requires the header &lt;code&gt;Accept: application/json, text/event-stream&lt;/code&gt;. Add it and you can call tools immediately.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:8100/mcp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Accept: application/json, text/event-stream"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"jsonrpc":"2.0","id":5,"method":"tools/call",
       "params":{"name":"get_penalty","arguments":{"section_id":"sec_26"}}}'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="s1"&gt;'.result.content[0].text | fromjson'&lt;/span&gt;
&lt;span class="c"&gt;# the tool result is a JSON string nested inside JSON-RPC, hence the double parse&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the agent side, this article uses plain Python and no framework, because a minimal agent is nothing more than a while loop, a model and some tools, and seeing that loop in the open makes it clear what the frameworks do for you. We write a small MCP client with httpx that performs the lifecycle &lt;code&gt;initialize → notifications/initialized → tools/list → tools/call&lt;/code&gt;, then pass the inputSchema from &lt;code&gt;tools/list&lt;/code&gt; straight to the LLM as a function schema, because both sides speak JSON Schema. The agent loop reduces to THINK (the model asks for a tool) → TOOL_USE (we send tools/call) → OBSERVE (the result goes back into messages), repeated until the model stops asking for tools: END_TURN.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# labs/lab8/agent_pdpa_mcp.py — the core of the agent loop (trimmed)
&lt;/span&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_steps&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;llm&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;openai_tools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;temperature&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tool_calls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                                   &lt;span class="c1"&gt;# THINK
&lt;/span&gt;        &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;assistant&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                         &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool_calls&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;tc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_dump&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;tc&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tool_calls&lt;/span&gt;&lt;span class="p"&gt;]})&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tool_calls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;                      &lt;span class="c1"&gt;# TOOL_USE
&lt;/span&gt;            &lt;span class="n"&gt;args&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;function&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;arguments&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;registry&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dispatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;function&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# -&amp;gt; MCP tools/call
&lt;/span&gt;            &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool_call_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                             &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;         &lt;span class="c1"&gt;# OBSERVE
&lt;/span&gt;        &lt;span class="k"&gt;continue&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;                                   &lt;span class="c1"&gt;# END_TURN
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is the log of a real run on the question from the beginning (the question and answer were in Thai; translated). The agent loops four times, makes seven tool calls, and concludes on the fifth round. Notice the order it chose on its own from the docstrings: search first, then walk the graph and look up penalties, then read full sections before citing them.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[user] Can we collect our employees' health data, and what is the penalty if we get it wrong?
[step 1] THINK -&amp;gt; requests 1 tool
           TOOL_USE search_pdpa({"query": "collect employee health data, sensitive data"})
[step 2] THINK -&amp;gt; requests 2 tools
           TOOL_USE get_related_sections({"section_id": "sec_26"})
           TOOL_USE get_penalty({"section_id": "sec_26"})      # penalties_found: 2 (via Section 79)
[step 3] THINK -&amp;gt; requests 2 tools
           TOOL_USE get_section_text({"section_id": "sec_79"})
           TOOL_USE get_section_text({"section_id": "sec_84"})
[step 4] THINK -&amp;gt; requests 2 tools
           TOOL_USE get_section_text({"section_id": "sec_27"})
           TOOL_USE get_section_text({"section_id": "sec_28"})
[step 5] END_TURN
[answer] ... cites Sections 26 / 79 / 84, separating [statutory provisions] from [advice] ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That order is not luck. The agent reads the full text with &lt;code&gt;get_section_text&lt;/code&gt; before every citation because the docstring tells it to, and that tool has a history of its own: it was added later, after we found the agent “could see the edge but could not follow it to the text”. There is also the case where the graph leads to the right section and the agent misreads that section’s scope. Both stories are for part three of this series.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy15aoqnjinxgnjcku84c.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fy15aoqnjinxgnjcku84c.png" alt="Terminal screenshot of the agent loop answering the employee health data question" width="799" height="672"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Figure 4 · The agent loop on the employee-health-data question&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What I hope you take away
&lt;/h2&gt;

&lt;p&gt;If you remember three things from this article, let them be these. First, GraphRAG is not good because it has a graph; it is exactly as good as its edges reflect real relationships. A graph missing a single edge makes the agent confidently wrong while every log looks healthy, which is the subject of part two. Second, graph and vectors do not compete: vectors find the starting point, the graph walks onward, and the full statute text is what the model must read before it cites anything. Third, what makes this system work is not a smarter model but the developer acting as architect: designing the graph schema, choosing the hop count, writing the tool docstrings, and imposing the discipline of reading before answering. That is the same point I made in my article on Harness [10]: the model is only the reader; the structure that makes it read the right thing is ours.&lt;/p&gt;

&lt;p&gt;Thanks for reading this far. The code, data and figures are in the companion repository at &lt;a href="https://github.com/aekanun2020/pdpa-graphrag-mcp" rel="noopener noreferrer"&gt;https://github.com/aekanun2020/pdpa-graphrag-mcp&lt;/a&gt;, including the validation script and a curl smoke test. Part two covers the bug where one missing edge sent the agent to the wrong penalty section. I teach all of this as a three-day hands-on course at IMC Institute in Bangkok. If you have questions, or have built GraphRAG for a different kind of cross-referencing document, I would love to compare notes in the comments.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;p&gt;[1] Companion repository (MCP server, retrieval engine, PDPA graph, agent loop, validation script): &lt;a href="https://github.com/aekanun2020/pdpa-graphrag-mcp" rel="noopener noreferrer"&gt;https://github.com/aekanun2020/pdpa-graphrag-mcp&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;[2] D. Edge et al., “From Local to Global: A Graph RAG Approach to Query-Focused Summarization,” arXiv:2404.16130, 2024. &lt;a href="https://arxiv.org/abs/2404.16130" rel="noopener noreferrer"&gt;https://arxiv.org/abs/2404.16130&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;[3] Z. Guo et al., “LightRAG: Simple and Fast Retrieval-Augmented Generation,” arXiv:2410.05779, 2024. &lt;a href="https://arxiv.org/abs/2410.05779" rel="noopener noreferrer"&gt;https://arxiv.org/abs/2410.05779&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;[4] J. Chen et al., “BGE M3-Embedding: Multi-Lingual, Multi-Functionality, Multi-Granularity Text Embeddings Through Self-Knowledge Distillation,” arXiv:2402.03216, 2024. &lt;a href="https://arxiv.org/abs/2402.03216" rel="noopener noreferrer"&gt;https://arxiv.org/abs/2402.03216&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;[5] G. V. Cormack, C. L. A. Clarke, and S. Büttcher, “Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods,” SIGIR 2009.&lt;/p&gt;

&lt;p&gt;[6] Model Context Protocol — Specification. &lt;a href="https://modelcontextprotocol.io" rel="noopener noreferrer"&gt;https://modelcontextprotocol.io&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;[7] Qdrant Documentation — Hybrid Queries (Prefetch, RRF fusion). &lt;a href="https://qdrant.tech/documentation/concepts/hybrid-queries/" rel="noopener noreferrer"&gt;https://qdrant.tech/documentation/concepts/hybrid-queries/&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;[8] Personal Data Protection Act B.E. 2562 (2019), Thailand. Royal Gazette Vol. 136, Part 69 Kor, 27 May 2019.&lt;/p&gt;

&lt;p&gt;[9] Aekanun Thongtae, “3 Chunking Techniques That Make RAG Better” (in Thai), Medium, 2025. &lt;a href="https://aekanunbigdata.medium.com/b28a03eee598" rel="noopener noreferrer"&gt;https://aekanunbigdata.medium.com/b28a03eee598&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;[10] Aekanun Thongtae, “Harness: The Architecture That Makes LLMs Actually Do the Work” (in Thai), Medium, 2026. &lt;a href="https://aekanunbigdata.medium.com/b287f896b6db" rel="noopener noreferrer"&gt;https://aekanunbigdata.medium.com/b287f896b6db&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;[11] Aekanun Thongtae, “Why Model Context Protocol (MCP)” (in Thai), Medium, 2025. &lt;a href="https://aekanunbigdata.medium.com/0b0ec9cb64da" rel="noopener noreferrer"&gt;https://aekanunbigdata.medium.com/0b0ec9cb64da&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;[12] Regulation (EU) 2016/679 (General Data Protection Regulation), Article 83(5). &lt;a href="https://eur-lex.europa.eu/eli/reg/2016/679/oj" rel="noopener noreferrer"&gt;https://eur-lex.europa.eu/eli/reg/2016/679/oj&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;First published on &lt;a href="https://aekanunbigdata.medium.com/why-vector-rag-answers-only-half-of-a-legal-question-graphrag-mcp-in-5-steps-eff01664cb2a" rel="noopener noreferrer"&gt;Medium&lt;/a&gt;. Part 1 of 3.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>python</category>
      <category>llm</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>I Used the OpenAI SDK—and Claude Answered. Here’s Why.</title>
      <dc:creator>www.aekanun.com</dc:creator>
      <pubDate>Sat, 25 Jul 2026 15:14:22 +0000</pubDate>
      <link>https://dev.to/aekanun/i-used-the-openai-sdk-and-claude-answered-heres-why-1l8o</link>
      <guid>https://dev.to/aekanun/i-used-the-openai-sdk-and-claude-answered-heres-why-1l8o</guid>
      <description>&lt;p&gt;You install the OpenAI Python package.&lt;/p&gt;

&lt;p&gt;You import &lt;code&gt;OpenAI&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;You call &lt;code&gt;client.chat.completions.create()&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And &lt;strong&gt;Claude&lt;/strong&gt; answers.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;openai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OpenAI&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.anthropic.com/v1/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude-sonnet-4-6&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Explain this in one sentence.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That looks wrong the first time you see it.&lt;/p&gt;

&lt;p&gt;If the SDK says &lt;code&gt;openai&lt;/code&gt;, shouldn't an OpenAI model answer?&lt;/p&gt;

&lt;p&gt;No—and the reason matters far beyond this one code sample.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 10-second answer
&lt;/h2&gt;

&lt;p&gt;Three independent choices are hiding in that snippet:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The SDK&lt;/strong&gt; decides how your application constructs requests and reads responses.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The endpoint and API contract&lt;/strong&gt; decide where the request goes and what shape crosses the network.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The model identifier and routing rules&lt;/strong&gt; decide what ultimately runs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The package name is not the model name.&lt;/p&gt;

&lt;p&gt;In the example above:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OpenAI Python SDK
        ↓ speaks OpenAI-style HTTP
Anthropic compatibility endpoint
        ↓ maps the request
Claude Sonnet
        ↓ result is mapped back
OpenAI-shaped response
        ↓ parsed by the SDK
response.choices[0].message.content
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Anthropic officially provides this compatibility layer for testing and comparing Claude with existing OpenAI integrations. It is not the recommended production path for most Claude-first applications, for reasons we will get to shortly.&lt;/p&gt;

&lt;p&gt;But first, let us separate the layers developers accidentally compress into the word “AI.”&lt;/p&gt;

&lt;h2&gt;
  
  
  The four layers hiding behind one API call
&lt;/h2&gt;

&lt;p&gt;The useful mental model is not “SDK → model.” It is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;┌───────────────────────────────────────────────┐
│ 1. Application + SDK                         │
│    Builds a request and parses a response    │
├───────────────────────────────────────────────┤
│ 2. API endpoint / gateway                    │
│    Auth, routing, policy, protocol mapping   │
├───────────────────────────────────────────────┤
│ 3. Serving layer                             │
│    Scheduling, batching, streaming, metrics  │
├───────────────────────────────────────────────┤
│ 4. Inference runtime + model                  │
│    Prompt → tokens → generation → tokens     │
└───────────────────────────────────────────────┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These layers may live in one program, several containers, or multiple companies' infrastructure. The boundaries are conceptual, but the responsibilities are different.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. The SDK is a client, not a model
&lt;/h3&gt;

&lt;p&gt;An SDK usually gives you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;authentication and default headers;&lt;/li&gt;
&lt;li&gt;request and response types;&lt;/li&gt;
&lt;li&gt;serialization and validation;&lt;/li&gt;
&lt;li&gt;retries, timeouts, and error classes;&lt;/li&gt;
&lt;li&gt;streaming helpers;&lt;/li&gt;
&lt;li&gt;a nicer interface than raw HTTP.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without an SDK, the same job can be done with an HTTP client:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;

&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.anthropic.com/v1/messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x-api-key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;anthropic-version&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2023-06-01&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content-type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;claude-sonnet-4-6&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;max_tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hello&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
    &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The native Anthropic response uses &lt;code&gt;content[]&lt;/code&gt;. The OpenAI-style response uses &lt;code&gt;choices[]&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Those shapes are API contracts. Neither is the natural output format of a neural network.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The endpoint is more than a URL
&lt;/h3&gt;

&lt;p&gt;Consider these clients:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# OpenAI
&lt;/span&gt;&lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.openai.com/v1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Anthropic's OpenAI compatibility layer
&lt;/span&gt;&lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ANTHROPIC_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://api.anthropic.com/v1/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# A local Ollama server
&lt;/span&gt;&lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ollama&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c1"&gt;# required by the client; ignored locally
&lt;/span&gt;    &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:11434/v1/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The calling style barely changes, but the request crosses three completely different trust, billing, latency, and data boundaries.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;base_url&lt;/code&gt; can point to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the model provider itself;&lt;/li&gt;
&lt;li&gt;a multi-provider gateway such as OpenRouter or LiteLLM;&lt;/li&gt;
&lt;li&gt;a cloud proxy;&lt;/li&gt;
&lt;li&gt;an OpenAI-compatible server such as vLLM;&lt;/li&gt;
&lt;li&gt;a local runtime such as Ollama;&lt;/li&gt;
&lt;li&gt;your own internal policy and routing service.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is why “we use the OpenAI SDK” tells an architect almost nothing about where prompts go.&lt;/p&gt;

&lt;p&gt;The next questions should be:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;What is the &lt;code&gt;base_url&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;Who controls that endpoint?&lt;/li&gt;
&lt;li&gt;Which model identifier is sent?&lt;/li&gt;
&lt;li&gt;Can the gateway rewrite or reroute it?&lt;/li&gt;
&lt;li&gt;Which protocol features survive the translation?&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  3. The serving layer creates the API-shaped response
&lt;/h3&gt;

&lt;p&gt;At the lowest useful level, a language model works with numbers.&lt;/p&gt;

&lt;p&gt;The prompt is formatted and tokenized. The model produces logits. A generation strategy selects new token IDs. Those IDs are decoded into text.&lt;/p&gt;

&lt;p&gt;For example, Hugging Face Transformers documents that &lt;code&gt;generate()&lt;/code&gt; returns token sequences—or a richer internal &lt;code&gt;ModelOutput&lt;/code&gt; when requested:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;generated_ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;model_inputs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_new_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tokenizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;batch_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;generated_ids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;skip_special_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is no universal neural-network law requiring the result to contain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"choices"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"finish_reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"stop"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"usage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"prompt_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"completion_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That public JSON shape is assembled by software around the generation runtime.&lt;/p&gt;

&lt;p&gt;A serving system may also produce internal text, token IDs, finish metadata, timing information, cache statistics, and scheduler state. The important distinction is not “the engine only returns text.” The important distinction is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Provider JSON is a network contract created by the serving/API layer—not an intrinsic property of the model weights.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Projects such as vLLM make this visible: the inference machinery returns internal request outputs, while the OpenAI-compatible server exposes endpoints with OpenAI-style schemas.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. The model is selected downstream
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;model&lt;/code&gt; field is a request to the service, not a Python import.&lt;/p&gt;

&lt;p&gt;The endpoint decides how to interpret it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;anthropic/claude-sonnet-4.6&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Hello&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A gateway might:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;map that identifier to Anthropic;&lt;/li&gt;
&lt;li&gt;choose one of several upstream regions;&lt;/li&gt;
&lt;li&gt;fail over to another provider hosting the same open model;&lt;/li&gt;
&lt;li&gt;reject the identifier;&lt;/li&gt;
&lt;li&gt;apply an alias configured by your organization.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So even the model string is not always the complete deployment identity. In production, log the resolved provider, model version, request ID, and routing decision whenever the platform exposes them.&lt;/p&gt;

&lt;h2&gt;
  
  
  “OpenAI-compatible” does not mean “identical”
&lt;/h2&gt;

&lt;p&gt;This is where a convenient prototype becomes a quiet production bug.&lt;/p&gt;

&lt;p&gt;Two services can support &lt;code&gt;/v1/chat/completions&lt;/code&gt; and still disagree on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;accepted parameters;&lt;/li&gt;
&lt;li&gt;tool-call guarantees;&lt;/li&gt;
&lt;li&gt;multimodal content;&lt;/li&gt;
&lt;li&gt;structured output enforcement;&lt;/li&gt;
&lt;li&gt;streaming event details;&lt;/li&gt;
&lt;li&gt;token accounting;&lt;/li&gt;
&lt;li&gt;error payloads;&lt;/li&gt;
&lt;li&gt;provider-specific capabilities.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Anthropic documents several concrete limitations in its OpenAI compatibility layer:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;strict&lt;/code&gt; for function calling is ignored;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;response_format&lt;/code&gt;, &lt;code&gt;logprobs&lt;/code&gt;, and several other fields are ignored;&lt;/li&gt;
&lt;li&gt;prompt caching is not supported through this surface;&lt;/li&gt;
&lt;li&gt;system and developer messages are hoisted and combined;&lt;/li&gt;
&lt;li&gt;the full Claude feature set requires the native Claude API.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Some unsupported fields are silently ignored.&lt;/p&gt;

&lt;p&gt;That last behavior is more dangerous than a clean error. Your code can compile, your request can return &lt;code&gt;200&lt;/code&gt;, and your assumption can still be false.&lt;/p&gt;

&lt;p&gt;Ollama uses equally careful wording: it supports &lt;strong&gt;parts&lt;/strong&gt; of the OpenAI API. vLLM documents its own list of supported and additional parameters.&lt;/p&gt;

&lt;p&gt;Compatibility is a spectrum, not a boolean.&lt;/p&gt;

&lt;h2&gt;
  
  
  The migration that “only changes base_url”
&lt;/h2&gt;

&lt;p&gt;Suppose this prototype works:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GATEWAY_API_KEY&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;base_url&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LLM_BASE_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Changing an environment variable may be enough for basic chat completion.&lt;/p&gt;

&lt;p&gt;Then production adds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;parallel tool calls;&lt;/li&gt;
&lt;li&gt;strict JSON schemas;&lt;/li&gt;
&lt;li&gt;images or PDFs;&lt;/li&gt;
&lt;li&gt;prompt caching;&lt;/li&gt;
&lt;li&gt;token-level streaming;&lt;/li&gt;
&lt;li&gt;provider-specific safety controls;&lt;/li&gt;
&lt;li&gt;detailed usage accounting.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now the lowest common denominator begins to cost you.&lt;/p&gt;

&lt;p&gt;The abstraction was not free. You deferred the translation work to a gateway—and accepted its fidelity limits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which approach should you choose?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Situation&lt;/th&gt;
&lt;th&gt;Sensible default&lt;/th&gt;
&lt;th&gt;Main trade-off&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude-first production app&lt;/td&gt;
&lt;td&gt;Native Anthropic SDK&lt;/td&gt;
&lt;td&gt;Best Claude feature coverage; tighter vendor coupling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI-first production app&lt;/td&gt;
&lt;td&gt;Native OpenAI SDK&lt;/td&gt;
&lt;td&gt;Best OpenAI feature coverage; tighter vendor coupling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model evaluation&lt;/td&gt;
&lt;td&gt;OpenAI-compatible surface or gateway&lt;/td&gt;
&lt;td&gt;Fast switching; feature comparisons may be incomplete&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mostly portable text generation&lt;/td&gt;
&lt;td&gt;Common gateway contract&lt;/td&gt;
&lt;td&gt;Simple integration; lowest-common-denominator risk&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Heavy tools, streaming, or multimodal use&lt;/td&gt;
&lt;td&gt;Native provider adapters behind your own interface&lt;/td&gt;
&lt;td&gt;More code; explicit and testable behavior&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local development&lt;/td&gt;
&lt;td&gt;Ollama or vLLM compatibility endpoint&lt;/td&gt;
&lt;td&gt;Convenient; confirm exactly which features are supported&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For a serious multi-provider application, I prefer a small internal interface whose implementation uses native provider SDKs.&lt;/p&gt;

&lt;p&gt;For example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Protocol&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TextModel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Protocol&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;...&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ClaudeModel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Native Anthropic SDK implementation
&lt;/span&gt;        &lt;span class="bp"&gt;...&lt;/span&gt;

&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;OpenAIModel&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Native OpenAI SDK implementation
&lt;/span&gt;        &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is more work than changing &lt;code&gt;base_url&lt;/code&gt;, but it makes the lossy parts visible. Your domain code depends on your contract, while provider-specific capabilities remain available inside each adapter.&lt;/p&gt;

&lt;p&gt;If you choose a universal gateway instead, create contract tests for every feature you rely on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✓ plain text
✓ streaming
✓ tool call arguments
✓ strict structured output
✓ image input
✓ stop reasons
✓ usage accounting
✓ retryable error classification
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not test only whether the first “Hello” request succeeds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mental model to keep
&lt;/h2&gt;

&lt;p&gt;When someone says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“We use the OpenAI SDK.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Translate it to:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;“This code uses a client designed around an OpenAI API shape.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It does &lt;strong&gt;not&lt;/strong&gt; automatically mean:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;OpenAI hosts the endpoint;&lt;/li&gt;
&lt;li&gt;GPT generated the answer;&lt;/li&gt;
&lt;li&gt;every OpenAI feature is supported;&lt;/li&gt;
&lt;li&gt;prompts never pass through a gateway;&lt;/li&gt;
&lt;li&gt;switching providers is lossless.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Remember the chain:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;SDK → API contract → endpoint/router → serving runtime → model
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The SDK speaks a protocol.&lt;/p&gt;

&lt;p&gt;The endpoint receives and may route the request.&lt;/p&gt;

&lt;p&gt;The serving stack runs—or delegates—the generation work.&lt;/p&gt;

&lt;p&gt;The model generates token probabilities.&lt;/p&gt;

&lt;p&gt;Once those responsibilities are separate in your head, “OpenAI SDK calling Claude” stops looking like magic. It becomes what it always was: one client speaking a compatible network contract to an endpoint that knows how to reach Claude.&lt;/p&gt;




&lt;p&gt;What broke first when you switched LLM providers: &lt;strong&gt;streaming, tool calls, structured output, or usage accounting?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Share the failure mode in the comments. Those edge cases are where “compatible” gets interesting.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://platform.claude.com/docs/en/cli-sdks-libraries/libraries/openai-sdk" rel="noopener noreferrer"&gt;Anthropic: OpenAI SDK compatibility&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://huggingface.co/docs/transformers/main_classes/text_generation" rel="noopener noreferrer"&gt;Hugging Face Transformers: Generation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.vllm.ai/en/latest/serving/openai_compatible_server.html" rel="noopener noreferrer"&gt;vLLM: OpenAI-Compatible Server&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.ollama.com/api/openai-compatibility" rel="noopener noreferrer"&gt;Ollama: OpenAI compatibility&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Disclosure: This article is based on the author's original technical material and subject-matter knowledge. AI was used to help restructure the article, edit the English, and create the cover image. The technical claims and cited sources were reviewed before publication.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>llm</category>
      <category>ai</category>
      <category>api</category>
    </item>
  </channel>
</rss>
