文档 RAG 助手

一个检索增强生成(RAG)聊天功能:以某个部署自己的公开用户文档为知识库,回答用户关于该部署的问题。检索和生成都经由网关自身。

架构

/v1/rag/chat 是一层很薄的编排:它在进程内完成检索,然后以普通用户的身份调用网关自己的公开 API 来做模型部分的活。

   POST /v1/rag/chat  (JWT-gated)
     1. embed query   ── HTTP ─►  POST {RAG_API_BASE_URL}/embeddings  (RAG_EMBED_MODEL)
     2. cosine top-k over the JSON vector index             (in-process)
     3. build grounded prompt with citations                (in-process)
     4. generate      ── HTTP ─►  POST {RAG_API_BASE_URL}/chat/completions (RAG_CHAT_MODEL)
     → SSE: sources event, then the proxied OpenAI chunks, then [DONE]

   Both HTTP calls carry RAG_API_KEY, so they flow through the standard
   /v1/embeddings and /v1/chat/completions handlers → logged to api_logs and
   counted toward cost / quota / concurrency. They also carry
   X-On-Behalf-Of: <end-user id>, so that attribution lands on the real end
   user (verified by JWT at /v1/rag/chat), not on the shared RAG_API_KEY account.

用文字说明一遍:内部这两次调用都是携带 RAG_API_KEY 的普通已鉴权网关请求,因此和其他流量一样会写进 api_logs,并计入成本、配额和并发;X-On-Behalf-Of 把这份归属从共享的 RAG 账号挪到经 JWT 验证的最终用户身上。

  • 语料(corpus):生效的 distribution overlay 的文档源文件(<overlay>/content/docs/docs/source/*.md)——也就是构建该部署公开文档站的同一批 markdown。没有 overlay 的检出没有语料;把 RAG_CORPUS_DIR 指向你自己的文档即可。

  • 向量库:一个普通的 JSON 文件,用纯 Python 的余弦相似度扫。文档语料很小,所以不需要 numpy 或 ANN 索引。和语料一样,索引属于 distribution 内容而不是源码:本仓库不提交任何索引,默认路径(<overlay>/content/rag/docs_index.json)解析到该部署实际运行的那个 overlay 内部——apps/backend/serving/rag/config.pyDISTRIBUTION_CONFIG_PATH 找到它,或者从代码树中唯一那个非 example 的 overlay 推出来。没有 overlay 的检出解析出的是一个并不存在的路径,在补上之前 /v1/rag/chat 一律回 503RAG_INDEX_PATHRAG_CORPUS_DIR 可以直接覆盖掉这两者。

  • 为什么要(走 HTTP)以用户身份调用网关,而不是直接用进程内的 router?为了让 RAG 请求可观测、可计量。直接调 RouteExecutor 或 adapter 会绕开 /v1/* 路由处理器里的逐请求日志、成本、配额和并发控制。把模型调用绕回这些端点,就等于把它们全部免费复用了一遍。

代码地图

路径

作用

apps/backend/serving/rag/chunker.py

按标题切分 markdown

apps/backend/serving/rag/embedder.py

GatewayHTTPEmbedder,以及离线用的 HashEmbedder

apps/backend/serving/rag/store.py

JSON 向量库 + 余弦检索

apps/backend/serving/rag/pipeline.py

prompt 组装 + 来源(sources)载荷

apps/backend/serving/rag/ingest.py

python -m serving.rag.ingest 命令行工具

apps/backend/serving/servers/routers/rag.py

/v1/rag/status + /v1/rag/chat

apps/frontend/src/app/chat/page.tsx

聊天界面(/chat,在 ProtectedRoute 之后)

apps/frontend/src/lib/api/chat.ts

流式 SSE 客户端

重建索引

用默认的 gateway embedder 构建索引,它会经由某个网关调用真实的 embedding 模型:

RAG_CORPUS_DIR=path/to/docs RAG_GATEWAY_API_KEY=hyi-xxx make rag-ingest

只有当你的语料不是生效 overlay 的 content/docs/docs/source 时,才需要设 RAG_CORPUS_DIR;没有 overlay 时,ingest 会失败并提示你去设它(apps/backend/serving/rag/ingest.py)。

RAG_GATEWAY_BASE_URL 默认是 http://localhost:8080/v1——做 embedding 是要花钱的,所以克隆出来的部署应当消耗自己的网关,而不是消耗写下这个默认值的人的网关。把它指向你想借以做 embedding 的那个网关;key 必须是那个网关上有效的用户 API key。切出来的 chunk 用的是服务端点在查询时所用的同一个 RAG_EMBED_MODEL,这样查询向量和文档向量才处在同一个空间里。

如果要在没有网关、没有 key 的情况下离线跑一遍(检索质量弱——仅限开发/CI):

RAG_EMBEDDER=hash make rag-ingest

服务端点会用当初构建索引的那个 embedder(记录在索引元数据里)来 embed 查询;如果查询向量的维度与索引不符,它会直接报错(HTTP 502)。换过 embedder 之后一定要重建索引。

部署

索引是部署产物,不属于镜像构建的一部分:用 make rag-ingest 构建它,并让生成的 JSON 文件在 RAG_INDEX_PATH 处可读。在 Compose 部署里,overlay 目录以 bind mount 的方式挂在被展平的应用目录旁边(deploy/docker/docker-compose.yml),所以写进 overlay 的 content/rag/ 的索引不必重建镜像就会被读到;向量库按进程缓存,并以文件的 mtime 作为失效判据,因此直接替换文件就够了。/v1/rag/status 会报告 index_loaded、embedder 模式和 chunk 数量,可用于部署后的检查。如果查询时 embedding 后端不可用,/v1/rag/chat 会体面地返回 503,而不是 500。

每个部署必须设置的环境变量:把 RAG_API_KEY 设成一把有效的用户 API key,并把 RAG_API_BASE_URL 指向该环境中网关自身的地址。默认值 http://localhost:8080/v1 对应 Compose backend 监听的端口(deploy/docker/docker-compose.yml);如果某个部署把 backend 绑在了别处,就必须设它,否则每个 RAG 请求都会在自调用这一步失败。内部调用出示的凭据是 RAG_API_KEY,但会带上 X-On-Behalf-Of: <end-user id>,于是成本、配额、日志和按用户的并发都记在真正的终端用户头上(其身份由 /v1/rag/chat 处的 JWT 验证),而不是记在共享的服务账号上——每个用户的 RAG 用量都算进他自己的每日配额。verify_api_key 对配置的那把 RAG_API_KEYX-On-Behalf-Of;其他 key 带的这个头一律忽略,而如果 RAG_API_KEY 没设置,身份代持(impersonation)整体关闭。

端点

两个端点都挂在网关下,用仪表盘的 JWT(get_current_user)鉴权,所以 Next.js 聊天页面拿它手上已有的会话 token 就能调。

  • GET /v1/rag/status——索引是否已构建、chunk 数量、使用的模型。

  • POST /v1/rag/chat——请求体为 { messages, top_k?, stream? }。生成用的模型在服务端固定(RAG_CHAT_MODEL),不能由客户端选择,所以这个端点没法用来够到按角色限制的模型。

    • 流式(默认):SSE——先是一个 {"type":"sources", ...} 事件,接着是 OpenAI 格式的补全 chunk,最后是 [DONE]

    • 非流式:{ answer, sources, model }

日志与配额

RAG 的模型调用走的是网关自己的 /v1/embeddings/v1/chat/completions,所以会落进 api_logs,并计入成本、每日配额和按用户的并发——通过 X-On-Behalf-Of 头归属到真正的终端用户(即经 JWT 验证的 /v1/rag/chat 调用者),而出示的凭据是 RAG_API_KEY。一行日志是不是来自 RAG,看它的 metadata.user_agent = "doc_assistant" 就能认出来。要把 RAG_API_KEY 设成一把有效的用户 API key;没设置时该端点返回 503。上游返回的 429(配额或限流)会透传给调用方——注意现在它可能来自终端用户本人的每日配额,而不是服务账号的。

curl -sN https://your-gateway.example/v1/rag/chat \
  -H "Authorization: Bearer <jwt>" -H 'Content-Type: application/json' \
  -d '{"messages":[{"role":"user","content":"How do I get an API key?"}]}'

配置

全部可选;合理的默认值相对仓库根目录解析。

环境变量

默认值

用途

RAG_API_KEY

(未设置)

处理器调用网关时所用的用户 API key(服务时必需

RAG_API_BASE_URL

http://localhost:8080/v1

处理器调用的网关(自调用,为的是日志和配额)

RAG_INDEX_PATH

若存在 overlay,则为它的 content/rag/docs_index.json

向量索引位置

RAG_CORPUS_DIR

若存在 overlay,则为它的 content/docs/docs/source

markdown 语料

RAG_EMBEDDER

gateway

gateway(经由 RAG_EMBED_MODEL 调用真实的 embedding 模型)或 hash(离线)

RAG_GATEWAY_BASE_URL

http://localhost:8080/v1

ingest 使用的网关(gateway 模式)

RAG_EMBED_MODEL

bge-m3

embedding 模型 id(gateway 模式)

RAG_CHAT_MODEL

qwen3.6-35b

生成答案所用的模型。那个默认值是遗留下来的、某个具体部署专用的 id,本仓库并不提供它,所以要把它设成你自己的网关真正注册了的模型。

RAG_GATEWAY_API_KEY

falls back to LOCAL_API_KEY

ingestRAG_GATEWAY_BASE_URL 出示的用户 API key;必须在那个网关上有效

RAG_TOP_K

4

每次查询检索的 chunk 数

RAG_MAX_TOKENS

1024

答案的 token 预算

RAG_TEMPERATURE

0.3

生成温度

原型阶段的局限

  • 索引是手工刷新的(make rag-ingest),没有定时任务——在重新生成之前,它可能落后于文档。

  • HashEmbedder 这个回退方案存在的唯一理由,是让流水线在没有网关时也能跑(开发/CI);它的检索质量很弱。

  • 没有答案缓存,没有重排序,历史被截断到最近几轮。向量库按进程加载进内存(带缓存,按 mtime 失效)。