在 OpenRouter 上做嵌入与重排:RAG 那一段怎么拼

2026-08-18

做 RAG 最容易翻车的地方,往往不是”要不要上重排”这种路线问题,而是三段接口拼接时字段对不上:嵌入接口返回的数组该按顺序取还是按 index 取、重排接口收的 documents 到底是字符串还是对象、重排返回的结果直接丢给聊天模型时该取哪一层。这篇只做一件事——把 OpenRouter 官方文档 openrouter.ai/docs/api_reference/embeddingsopenrouter.ai/docs/cookbook/evaluate-and-optimize/rag 这两页里写明的字段与拼接顺序对齐一遍,顺便指出这两页示例代码之间对不上的两处。

一、它解决的是哪一段的问题

如果你已经在用 OpenRouter 发 chat completions,那么 RAG 链路里缺的是前面两段:把文档和问题变成向量,以及把召回结果重新打一遍分。官方文档在 RAG 那一页把这条链路写成四步:Index(切块并嵌入)→ Retrieve(嵌入查询并找相似块)→ Rerank(可选,用 cross-encoder 重新打分)→ Generate(把选中的文档作为上下文交给聊天模型)。文档明确把第三步标为 optional,这一点后面还要说。

三段各有各的端点,都挂在同一个 base 下:

用途端点方法
生成嵌入https://openrouter.ai/api/v1/embeddingsPOST
列出可用嵌入模型https://openrouter.ai/api/v1/embeddings/modelsGET
重排https://openrouter.ai/api/v1/rerankPOST
生成回答https://openrouter.ai/api/v1/chat/completionsPOST

RAG 那一页开门见山的说法是:嵌入、重排、聊天补全这三块积木都由同一套 API 提供。落到写代码上,就是这三段共用同一个 base 和同一套 Authorization / Content-Type 请求头,不必为向量这一段再单独接一家服务商的 SDK。

二、前置条件

这一段最容易被跳过,但它决定了你第一次请求是 200 还是 401。

鉴权与请求头。 文档里所有示例都是两个头:Authorization: Bearer <你的 key>Content-Type: application/json。文档正文里的密钥占位写作 <OPENROUTER_API_KEY>,shell 示例里用的是环境变量 $OPENROUTER_API_KEY。写进代码库时按你自己的密钥管理方式来,不要把值直接写进仓库。

能力前提。 嵌入这一段需要的是一个输出模态为 embeddings 的模型,重排这一段需要的是输出模态为 rerank 的模型——这两类模型和你平时调的聊天模型不是同一批,写错模型名时文档给的是 404(下面错误码那一节会讲)。平台上具体有哪些嵌入与重排模型随时在变,文档给的自查方式是调 GET /api/v1/embeddings/models,或者去官网模型页按输出模态筛。本文不列任何模型清单。

SDK 还是裸 HTTP。 文档的代码组里给了 TypeScript SDK(@openrouter/sdk,用 openRouter.embeddings.generate(...)openRouter.embeddings.listModels())、Python(requests)、TypeScript 的 fetch,以及 shell 的 curl 四种写法。RAG 那一页只给了 Python、TypeScript fetch 和 shell 三种。

Windows 侧要注意的一点。 官方文档的 shell 示例是 POSIX shell 写法:JSON 体用单引号整段包住,密钥用 $OPENROUTER_API_KEY 展开。在 Windows 的 PowerShell 里,单引号不做变量展开、curl 默认还是 Invoke-WebRequest 的别名,这段命令不能照抄就跑——官方文档没有给 Windows 侧的等价写法。这属于通用的命令行差异,不是 OpenRouter 官方文档的内容;实际做法上,在 Windows 上更省事的路径是直接用文档里的 Python 或 TypeScript 示例,或者在 WSL / Git Bash 里跑那段 curl

三、四段的字段与拼接顺序

Index:input 接单条也接数组

最基础的请求体只有两个字段,modelinput

curl https://openrouter.ai/api/v1/embeddings \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENROUTER_API_KEY" \
  -d '{
    "model": "openai/text-embedding-3-small",
    "input": "The quick brown fox jumps over the lazy dog"
  }'

input 传字符串数组就是批量。文档在《Embeddings》和 RAG 两页都把”批量发一次而不是循环发多次”列为 best practice,理由文档自述是减少延迟与开销。

响应结构是 data 数组,每项带 embedding(向量)。这里有本文要指出的第一处不一致:《Embeddings》那一页的示例是按位置取的(data["data"][0]["embedding"],语义搜索例子里更是直接 response.data.slice(1) 拿文档部分);而 RAG 那一页在建索引时是按字段取的:

document_embeddings = [
    {"text": chunks[item["index"]], "embedding": item["embedding"]}
    for item in data["data"]
]

同一个响应,两页用了两种取法。按 item["index"] 回指原始 chunks 显然更稳,因为它不依赖返回顺序;但要注意文档并没有在任何一处写明”响应顺序保证与输入顺序一致”——官方文档没有说明这一点,所以按 index 对齐是文档里就有依据的写法,别自己想当然。

多模态输入:嵌入和重排的形状不一样

《Embeddings》页写明”部分嵌入模型支持图像输入”。它的多模态形状是:input 里放对象,对象里有 content 数组,数组项是 {"type": "image_url", "image_url": {"url": ...}}{"type": "text", "text": ...},两者可以放在同一个 content 里生成联合嵌入,示例还额外带了 "encoding_format": "float"

而 RAG 页里的多模态重排是另一套形状documents 里放的是扁平对象,字段就是可选的 text 和可选的 image,文档写明 image 的值可以是 http/https 远程 URL,也可以是 base64 的 data URI(data:image/...),并且每个文档至少要有 textimage 之一

"documents": [
    {"text": "AI enables robots to perceive, plan, and act autonomously."},
    {"image": "https://example.com/robot-diagram.png"},
    {
        "text": "A robotic arm assembling components.",
        "image": "https://example.com/robot-arm.png",
    },
],

嵌入侧用 content 数组套 image_url 对象,重排侧用扁平的 text / image 字符串字段——同一条链路上两个接口的多模态写法不通用,把一边的形状抄到另一边去是这两页里最容易犯的错。文档在嵌入侧给 400 的定义就是「输入格式非法或缺必填参数」,形状抄串了首先要往这个方向查;重排接口的错误码文档在这两页里没有单独列,官方文档没有说明这一点。另外文档在提示框里写明:纯文本文档仍然可以直接传字符串,也就是说同一个 documents 数组里可以混着放 "一段纯字符串"{ "image": "..." }

Retrieve:查询和文档拼在同一次请求里

RAG 页的检索这一步是把查询单独嵌入一次,再和已存的文档向量算余弦相似度。《Embeddings》页那个语义搜索例子则更省一次请求:把查询放在数组第一位,文档接在后面(input: [query, ...documents]),响应里 data[0] 就是查询向量,其余是文档向量。这个顺序是人为约定的,不是接口语义——你要是把查询放最后,取值那两行也得跟着改。

至于比较方法,文档的建议是用余弦相似度而不是欧氏距离,理由文档自述是余弦相似度对尺度不敏感、在高维向量上表现更好。

Rerank:documents 进,results

重排请求体四个字段:modelquerydocumentstop_n

def rerank(query, documents, top_n=3):
    response = requests.post(
        "https://openrouter.ai/api/v1/rerank",
        headers={
            "Authorization": f"Bearer {OPENROUTER_API_KEY}",
            "Content-Type": "application/json",
        },
        json={
            "model": "cohere/rerank-v3.5",
            "query": query,
            "documents": documents,
            "top_n": top_n,
        },
    )

    data = response.json()
    return data["results"]

返回的是 results。它每项有哪些字段,文档在 TypeScript 版本里写得最全,标注的类型是 { index: number; relevance_score: number; document: { text: string } }。三个字段各有用处:relevance_score 是重排后的分数,index 回指你传进去的 documents 数组下标(多模态那个例子打印的就是 relevance_scoreindex,压根没碰 document.text——因为那批文档里有的只有图没有文),document.text 是文本本身。

上面代码里的 openai/text-embedding-3-smallcohere/rerank-v3.5 都是官方文档当时用的示例值,平台上有哪些模型随时在变,别当清单用。以上片段按官方文档中的参数语义组合,未经实测,以官方文档最新内容为准。

Generate:拼上下文时取哪一层

这是本文要指出的第二处不一致,也是最容易踩的一处。RAG 页 Step 4 的 generate_answer 直接吃 rerank 的原始结果,拼接时要往下钻两层:

context = "\n\n".join(
    f"[{i+1}] {doc['document']['text']}"
    for i, doc in enumerate(context_docs)
)

但同一页的完整示例里,generate 收的已经是抽好的字符串列表(调用前先做了 context_texts = [r["document"]["text"] for r in reranked]),拼接写的是 f"[{i+1}] {doc}"。两个函数同名不同签名,连中间那个 retrievetop_n 默认值也不一样。照着页面往下抄、抄串了行,报错会出在字符串拼接这一步而不是 API 调用上,排查时容易找错方向。

拼接顺序本身两处是一致的:每条上下文前面加 [n] 序号、条与条之间用空行(\n\n)分隔,然后 system 提示要求模型基于给定上下文回答并用方括号标注来源、上下文不足时直说,user 消息则是 Context:\n{context}\n\nQuestion: {query} 这个顺序——上下文在前、问题在后。文档另外建议把来源元数据(标题、章节、URL)一并放进上下文,好让模型能给出可核对的引用。

四、边界:文档明说不支持与没说的部分

  • 嵌入不支持流式。文档在 Limitations 里写明:不同于 chat completions,嵌入是一次性返回完整响应,不支持 streaming。
  • 嵌入输出是确定性的。同一段文本每次返回的向量相同,没有 temperature 之类的随机性。文档据此建议把嵌入结果缓存下来。
  • 有最大输入长度。文档写明每个模型都有输入长度上限,超出的文本会被截断或拒绝,需要先切块。具体数值随模型而异,本文不写。
  • 语言支持看模型。文档写明部分模型是针对特定语言优化的,要看模型自身说明。
  • 重排是可选步骤。文档把它列为 optional,并给了取舍:知识库大、召回噪声多、精度比延迟更重要时值得加;知识库小、要最低延迟、或者只是搭原型时可以跳过——因为它多一次 API 调用。
  • 多模态是”部分模型”的能力。嵌入与重排两侧文档用的都是 “Some models” 的措辞,并没有在这两页给出支持清单,只指向官网模型页按输出模态筛。
  • 换模型就得重建索引。文档把”索引和查询用同一个嵌入模型”列为 best practice,写明混用会产生不兼容的向量空间、检索结果会变差。
  • 这两页没有标 beta / preview / deprecated。我们在这两页的落盘文本里没有检索到这类标记,但这不等于其中每个字段都长期稳定,仍以官方文档最新内容为准。

五、怎么验证拼对了

分层验证,别等到最后看回答质量再回头猜。

第一层,链路通不通。 先发一条最短的单字符串嵌入请求。文档列出的错误码含义是现成的诊断表:

状态码文档给的含义
400输入格式非法或缺必填参数,检查 inputmodel
401密钥无效或缺失,检查 Authorization 头
402账户额度不足
404模型不存在,或该模型不是嵌入模型
429触发限流,文档建议做指数退避重试
529上游供应商过载,文档建议开 allow_fallbacks: true 走备选

404 这一条尤其值得记住:拿聊天模型去打 /embeddings,症状就是 404 而不是 400。

第二层,索引对没对齐。 检查 data 数组长度与你送进去的块数一致,再抽一条按 item["index"] 回指原文看看文本对得上。文档示例里打印的是向量维度(len(embedding)),批量时打印每一项的维度也能顺手发现有没有哪条返回异常。

第三层,重排接上没有。results 的条数是否不超过你给的 top_n,再用 index 回指原 documents 数组核对一下——index 对得上,说明你传进去的顺序和取回来的映射没错位。顺带一句:文档的示例是直接按 results 的返回顺序往下打印的,但它并没有写明 results 一定按 relevance_score 降序返回——要靠顺序取「最相关那条」的话,自己按分数排一遍更稳。文档还建议设一个相关性阈值,把分数过低的文档滤掉,免得把不相干的内容塞进上下文。

第四层,上下文真的进 prompt 了。 在发 chat completions 之前把拼好的 context 字符串打出来看一眼:序号是不是 [1] 起、条与条之间是不是空行、有没有出现 {'document': {'text': ...}} 这种没钻到底层的字典字面量。上面说的那处签名不一致,症状通常就在这里现形。

顺带一个可用的自检技巧:嵌入是确定性的,同一段文本发两次应该拿到同样的向量。这条来自文档的 Limitations 一节,可以拿来确认你的缓存层没有把不同文本的向量串错。

嵌入与重排这两段接口本身不复杂,真正花时间的是 data / results / document.text 这几层壳,以及两页示例之间的写法差异。把上面这几处字段和顺序钉死,剩下的调优就回到切块策略和召回数量上了——那属于另一个话题。


本文依据 OpenRouter 官方文档(openrouter.ai/docs)于 2026-08-18 的公开内容整理。 该平台闭源,本文只复述官方文档写明的机制,不推断其内部实现我们没有对文中涉及的功能做过实测,因此不涉及界面外观与运行表现的任何描述。 该平台的供应商、模型与路由策略随时变动,文中不列具体供应商名单与模型清单; 价格、额度与限流的具体数值请以官方定价页与用量说明为准。

安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。