LGOS RAG¶
lgos-rag is a model-backed documentation assistant that answers questions
about langgraph-openai-serve from the docs of the installed LGOS release. It
searches every docs page and cites the exact sections it used, so answers
describe the release the demo runs.
The graph needs the gateway's chat and embedding models and outbound HTTPS access to GitHub, which serves the release's docs. It has no checkpointer or LangGraph Store.
LangGraph Topology¶
Every request runs the same three nodes; only the planning call decides what to search.
graph TD;
__start__ --> plan;
plan --> retrieve;
retrieve --> answer;
answer --> __end__;
Request Flow¶
sequenceDiagram
actor User
participant UI as Chainlit / Open WebUI
box LGOS API process
participant Graph as lgos-rag
participant Index as Docs index
end
participant GitHub
participant Gateway as Gateway models
User->>UI: Ask about LGOS
UI->>Graph: /v1/responses, stream=true
Graph-->>UI: Status "Understanding the question"
Graph->>Gateway: Private plan call
Gateway-->>Graph: Up to three search queries
Graph-->>UI: Status "Searching the docs: …" per query
opt First search in this process
Index->>GitHub: GET the v{version} source archive
Index->>Gateway: Embed every chunk
end
Graph->>Index: Search every query in parallel
Index-->>Graph: Top ten sections per query
Graph->>Gateway: Streamed answer call
Graph-->>UI: Answer with URL citations
planmakes one private call with structured output. It first decides whether the documentation answers the message, then writes one standalone query per distinct part, at most three, resolving references to earlier turns. Greetings, thanks, questions about the conversation, unrelated requests, and messages too vague to search get no queries. LangGraph'snostreamtag keeps every planning token out of the answer stream.retrieveis code only. It publishes a status per query, which streaming Responses shows as commentary, searches the queries in parallel, and merges the top ten sections of each. With no queries it does nothing, so a greeting never loads the index.answeris the only streamed call. With sections, it answers from them alone and cites them, or asks one clarifying question when the question could mean clearly different things. Without sections, it replies briefly, asks what a vague message means, or declines. Markdown links and bare URLs that exactly match a retrieved section become OpenAIurl_citationannotations, so a citation does not depend on the model's link formatting. Other links stay ordinary Markdown.
Both calls run at temperature 0: sampling adds nothing to a documentation answer, and would vary the searches and answers for the same question.
Why A Workflow¶
A tool-calling agent could search too, but for one library's docs the model's only useful freedom is writing and splitting queries, and one structured call does that. The fixed graph has a hard limit of two model calls, a search-or-skip decision that tests can check, a status for each phase, and no text written beside a tool call to reconcile with the streamed answer.
Five runs of the 57-case evaluation below failed at most one case each, with a median latency of about 4 seconds per case. Questions whose second search depends on the first search's results are the case a workflow cannot handle; add a bounded, opt-in agent mode if evaluation shows them failing.
The graph asks its clarifying questions as ordinary replies, which the next
message answers through the conversation. An LGOS interrupt would pause and
checkpoint a read-only answer for nothing to approve, and declaring
interrupts would make the model unavailable through Chat Completions.
Documentation Index¶
The graph finds the installed release in the docs site's versions.json, which
names each release as its tag does, so a pre-release finds its docs although
Python normalizes its installed version. It indexes the Markdown under docs/
in the GitHub source archive of tag v<release>, and links every section to the
published docs of that release,
https://ilkersigirci.github.io/langgraph-openai-serve/<release>/. The Markdown
keeps tables, code blocks, and links as the docs authors wrote them; the API
reference that the site generates from docstrings is not part of it.
Each page splits at its headings, outside code blocks, and long sections split
into chunks of at most 2,000 characters, after a code block rather than inside
one: a chunk with half an example invites the model to invent the rest, such as
an import of a class the example defines. A section links to its page and the
anchor Python-Markdown's table of contents gives its heading, which is how
Zensical builds the published anchors, including the numbered anchors of
repeated headings. Relative links become docs-site URLs, as they do on the
site, so a link the model copies still works. Each chunk starts with its
heading path, such as Run The LGOS Server > Settings, so embeddings, keyword
search, and the model all see where it belongs.
The graph answers only from a tagged release's docs. An unreleased LGOS
checkout, such as the one the --dev Compose stack installs, reports the
placeholder version 0.0.1, because releases stamp their version from the tag.
For it, the graph uses the release that the docs site's versions.json marks as
latest, so its answers can trail docs changed in the checkout. A release whose
docs are not published yet, or whose archive has no docs, fails the request with
a server error, and the API log says which.
Client Settings¶
Clients that support runtime settings show two options; other clients send them
in metadata.lgos_settings.
scope, shown as Docs scope, is all, library, or demo and defaults to
all. library searches every page outside the Demo Stack section, and demo
searches only that section, so a question about an application of your own is
not answered from the demo stack's setup. For example, with library, "How do I
put LGOS behind LiteLLM?" finds the proxy guide instead of the demo's LiteLLM
sync page. The scope filters the search itself, and a restricted scope shows in
the status, such as "Searching the library docs: …". When a restricted search
does not answer the question, the answer says so and suggests setting
Docs scope to All.
bm25, shown as BM25 keyword search, defaults to true. Embedding search
finds sections by meaning and is weakest on exact names such as
LGOS_INTERRUPT_TTL_MINUTES, GraphError, or GraphFeature.BACKGROUND, so the
graph can also rank sections with BM25 keyword search. Each ranking contributes
its top ten, and reciprocal rank fusion orders them before the top ten are
returned. Only the strongest keyword matches take part: deeper down, pages that
mention every word of a question, such as the docs home page, can outrank the
sections that answer it.
Replaying the 235 sets of queries that the planner wrote in five evaluation runs, this put an answering page in the results for every part of every question. Embedding search alone missed 6, five of them for "What does a client receive when a graph raises GraphError?".
Lifetime And Failure¶
The index lives in the API process. Its first search downloads the release
archive, about 2 MB, and embeds about 480 chunks, a few seconds of work and well
under a cent with text-embedding-3-small. Later searches reuse it until the
process restarts. If the download or embedding fails, the request fails instead
of being answered from the model's prior knowledge, and the next search tries
again.
The graph keeps no state between requests. Conversation history comes from the messages the client sends.
Evaluate The Graph¶
demo/api/experiments/lgos_rag.py evaluates the graph as a Langfuse
experiment, with the gateway's models and the published
docs:
It holds 57 cases: questions in a user's words, exact names, follow-ups that need the conversation, multi-part questions, scoped searches, greetings, unrelated requests, and the reply's shape after a restricted miss, a vague message, or small talk. Each check that applies to a case becomes a pass or fail score:
| Score | Passes when |
|---|---|
route |
The graph searches exactly when the message needs the docs |
retrieval |
Each part of the question has an answering page among the retrieved sections |
scope |
A scoped case retrieves only sections in its scope |
links |
Every Markdown link in the answer comes from the retrieved sections |
citation |
The answer cites at least one retrieved section |
reply |
A reply-shape case shows the scope hint, asks a clarifying question, or answers small talk, without declining |
A run fails when more than three cases fail. The
grounded judge also scores each answer's content in
Langfuse, without failing runs. Add a case in the script whenever a
real question goes wrong, and rerun the experiment after changing prompts,
chunking, ranking, or models.
Cases curated in Langfuse use the script's case format. The input is the conversation, ending with the user's message, and the graph's client settings:
{
"messages": [{"role": "user", "content": "How do I put LGOS behind LiteLLM?"}],
"settings": {"scope": "library"}
}
The expected output lists, for each part of the question, the docs pages that answer it, as paths after the release. The search must find one page of each part:
Empty parts mean that no page in the scope answers the message, so the graph
must not search. reply checks the reply's shape instead: "clarify" expects
a clarifying question, "small_talk" expects an answer to a greeting, thanks,
or a question about the assistant or the conversation instead of a refusal,
and "scope_hint" expects a search of the restricted scope and a reply that
suggests the Docs scope All.
Judge The Answers¶
The grounded LLM-as-a-Judge
evaluator fails an answer with a fabricated LGOS setting, command, symbol, or
import path, or with a statement that the retrieved sections contradict. Its
rule, lgos-rag answers in experiments, picks the GENERATION observations
whose metadata langgraph_node is answer in experiments of
lgos-rag/regression and lgos-rag/curated, so the judge sees the sections
and the conversation that the answer call saw.
It ignores overstatements and missing caveats: graded twice, the same answers
flipped between verdicts, so a human review of them is more reliable. It uses
gpt-6-luna, which failed an import of a class that the docs example defines
and an invented setting, while gpt-4.1-mini passed the import.
Try It¶
| Path | Prompt |
|---|---|
| No search | Who are you? |
| One search | How do I enable Langfuse tracing? |
| Exact name | What does LGOS_INTERRUPT_TTL_MINUTES control? |
| Several queries | How do I resume an interrupted run in the background, and what does LGOS_INTERRUPT_TTL_MINUTES do? |
| Follow-up | How do I enable Langfuse tracing?, then How do I point it at a self-hosted instance? |
| Library scope | Set Docs scope to library, then ask How do I put LGOS behind LiteLLM? |
| Scope hint | Set Docs scope to library, then ask What does DEMO_GRAPH_GATEWAY_API_KEY set? |
| Vague message | Why is it failing? |
Python SDK¶
With the demo API client, search only the library docs, without BM25, for one request:
response = client.responses.create(
model="lgos-rag",
input="How do I put LGOS behind LiteLLM?",
metadata={"lgos_settings": '{"scope": "library", "bm25": false}'},
store=False,
)
print(response.output_text)
The implementation is in demo/api/src/lgos_demo_api/graphs/lgos_rag.py.