文档 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.py从DISTRIBUTION_CONFIG_PATH找到它,或者从代码树中唯一那个非 example 的 overlay 推出来。没有 overlay 的检出解析出的是一个并不存在的路径,在补上之前/v1/rag/chat一律回503。RAG_INDEX_PATH和RAG_CORPUS_DIR可以直接覆盖掉这两者。为什么要(走 HTTP)以用户身份调用网关,而不是直接用进程内的 router?为了让 RAG 请求可观测、可计量。直接调
RouteExecutor或 adapter 会绕开/v1/*路由处理器里的逐请求日志、成本、配额和并发控制。把模型调用绕回这些端点,就等于把它们全部免费复用了一遍。
代码地图
路径 |
作用 |
|---|---|
|
按标题切分 markdown |
|
|
|
JSON 向量库 + 余弦检索 |
|
prompt 组装 + 来源(sources)载荷 |
|
|
|
|
|
聊天界面( |
|
流式 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_KEY 认 X-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?"}]}'
配置
全部可选;合理的默认值相对仓库根目录解析。
环境变量 |
默认值 |
用途 |
|---|---|---|
|
(未设置) |
处理器调用网关时所用的用户 API key(服务时必需) |
|
|
处理器调用的网关(自调用,为的是日志和配额) |
|
若存在 overlay,则为它的 |
向量索引位置 |
|
若存在 overlay,则为它的 |
markdown 语料 |
|
|
|
|
|
ingest 使用的网关(gateway 模式) |
|
|
embedding 模型 id(gateway 模式) |
|
|
生成答案所用的模型。那个默认值是遗留下来的、某个具体部署专用的 id,本仓库并不提供它,所以要把它设成你自己的网关真正注册了的模型。 |
|
falls back to |
ingest 向 |
|
|
每次查询检索的 chunk 数 |
|
|
答案的 token 预算 |
|
|
生成温度 |
原型阶段的局限
索引是手工刷新的(
make rag-ingest),没有定时任务——在重新生成之前,它可能落后于文档。HashEmbedder这个回退方案存在的唯一理由,是让流水线在没有网关时也能跑(开发/CI);它的检索质量很弱。没有答案缓存,没有重排序,历史被截断到最近几轮。向量库按进程加载进内存(带缓存,按 mtime 失效)。