第 6 篇解决了"一份文档怎么喂给模型":上传拿 file_id,input_file 块引用,一份 PDF 经得起连番追问。可文档一多,这条路立刻见顶——几十份手册、几百页笔记,总不能每问一句就把全部原文塞进上下文,窗口装不下,token 账单先撑爆。业界的标准解法是 RAG(Retrieval-Augmented Generation,检索增强生成):提问先检索相关片段,只把片段交给模型。OpenAI 把这条流水线做成了托管服务:Files API 管上传,Vector Stores 管归集与索引,file_search 工具管检索,应用侧一行检索代码都不用写。
本篇是《OpenAI Python SDK 从入门到实战》系列第 10 篇,继续工程落地阶段:把第 6 篇的单文件用法展开成完整的文件体系,把 RAG 从"自己搭"换成"开箱用"。文中 Vector Stores 用法与计费口径核实自官方文档及社区讨论(2026 年 10 月)。全文按"三部曲全景 → 存储管理 → 工具声明 → 结果与引用 → 与自建 RAG 分界 → 计费账 → 个人文档问答实战 → 总结"组织。
1、文件到答案的三部曲
先摆全景。托管检索把"文档进、答案出"拆成三步:上传 → 归集 → 检索。上传沿用第 6 篇的 Files API,purpose 仍是 user_data,一步没变;归集是新增的一层——把一批 file_id 装进 Vector Store(向量存储),服务端自动完成解析、切分、向量化与索引;检索则是 Responses API 的内置工具 file_search,按用户问题查片段、只把命中内容注入上下文。全链路如下图:
解读:与第 6 篇单文件路线的分岔点在"归集"这一步——单文件用法把整份 PDF 当一块素材直接放进 content,文档一长 token 立刻失控;进了向量存储的文档被切成小片段另建索引,问题到来时只有最相关的几段进入模型,几百页的库问一句也只花两三千 token。三步各有各的入口:上传 client.files、归集 client.vector_stores、检索只消在 responses.create 的 tools 里声明一行——下文逐个展开。
2、向量存储的创建与管理
2.1、创建存储并批挂文件
Vector Store 在 SDK 里是独立资源,入口 client.vector_stores。先创建一个空库:
from openai import OpenAI
client = OpenAI()
store = client.vector_stores.create(
name="说明书库",
expires_after={ # 活跃 30 天后自动清理
"anchor": "last_active_at", "days": 30,
},
)
print(store.id) # vs_ 开头,后续引用全靠它
解读:name 只是给人读的标签,可以重名,程序里认的是 vs_ 开头的 id;expires_after 是值得养成习惯的参数——以最后活跃时间为锚,30 天没人用就自动清理整个库,第 6 节的计费账会解释为什么推荐它。库建好是空的,接下来把文件批量挂进去:
import glob
file_ids = [
client.files.create(
file=open(p, "rb"),
purpose="user_data", # 与第 6 篇单文件口径一致
).id
for p in glob.glob("docs/*.pdf")
]
batch = client.vector_stores.file_batches.create_and_poll(
vector_store_id=store.id,
file_ids=file_ids, # 单批至多 500 个文件
)
print(batch.file_counts) # 各状态文件计数
解读:上传这一步与第 6 篇完全同源,purpose 仍是 user_data;变化在挂载——file_batches 把一批文件一次关联到库,create_and_poll 是 SDK 的便捷方法,内部"提交批次 + 轮询到终态",返回时解析基本完成。batch.file_counts 里有 total、in_progress、completed、failed 四个计数,failed 大于零通常是个别文件损坏或格式不支持,挑出来单独处理即可。规模口径:单批至多 500 个文件,更多就再提一批;单个库至多 1 万个文件,单个文件 512 MB、500 万 token 封顶——与第 6 篇 Files API 的上限口径一致。
2.2、解析状态与轮询等待
挂载不是瞬间完成的:服务端要解析、切分、向量化,几百页的 PDF 要等一阵。不想用便捷方法、或要在等待间隙干别的活,可以手动挂载并轮询:
import time
vs_file = client.vector_stores.files.create(
vector_store_id=store.id,
file_id=file_ids[0], # 单个文件也能挂
)
while vs_file.status not in ("completed", "failed"):
time.sleep(2)
vs_file = client.vector_stores.files.retrieve(
vector_store_id=store.id, file_id=vs_file.id,
)
print(vs_file.status) # completed 或 failed
解读:单个文件挂载走 vector_stores.files.create,返回对象自带 status 字段,从 in_progress 走向 completed 或 failed,retrieve 负责刷新;库自身也有同名的 status,全部文件解析完才变 completed——create_and_poll 帮你轮的就是它。工程上建议把建库放进离线任务(脚本启动或定时同步),问答链路只管用现成的 store.id,别让用户等解析。
3、声明 file_search 工具
库就绪后,检索的接入简单到不像话——在 responses.create 的 tools 里声明一行。第 5 篇自定义工具的形制还记得的话,对比着看更清楚:
resp = client.responses.create(
model="gpt-6.1-terra",
instructions="只依据文档库回答,检索不到就直说不知道。",
input="医保报销需要准备哪些材料?",
tools=[{
"type": "file_search", # 内置工具,服务端执行
"vector_store_ids": [store.id],
"max_num_results": 5, # 每次取片段数,默认 5
}],
)
print(resp.output_text)
解读三个要点。其一,type 是 file_search 而非 function,没有 name、description、parameters——它不是"模型发指令、你来执行"的外挂工具,而是服务端托管的内置工具:模型决定检索时发一条 file_search_call 条目,OpenAI 自己执行检索、自己把片段拼进上下文,没有 function_call_output 回传这一环,第 5 篇的调用循环整个省掉。其二,要不要检索由模型自己判断:寒暄它直接回,问库里的事才发检索——instructions 里那句"检索不到就直说"因此很重要,能把"库里没有"与"现场编一个"分开。其三,max_num_results 控制每次取几段,默认 5,追求省钱省延时可调小;配 ranking_options 的 score_threshold 还能按相关度过滤弱命中。另外注意:file_search 是 Responses 线的内置工具,Chat Completions 那条线没有它,第 1 篇的选型结论在这里又多了一条注脚。
4、检索结果如何进入上下文
片段怎么进模型、引用怎么带出来,是这套机制最值得看透的部分。模型作答前,服务端把命中片段拼进本次请求的输入侧——它们计入 input token、正常计费,但不出现在你写的 input 参数里;自动切分的默认口径约每段 1500 字符、相邻段重叠 300 字符,一段折合几百 token,默认 5 段就是两三千 token 的量级。想把命中片段拿到手上核对,用 include 参数:
resp = client.responses.create(
model="gpt-6.1-terra",
input="文档对备份频率有什么要求?",
tools=[{"type": "file_search", "vector_store_ids": [store.id]}],
include=["file_search_call.results"], # 要求带回命中片段
)
for item in resp.output:
if item.type == "file_search_call":
print("检索语句:", item.queries) # 模型改写后的查询
for hit in item.results: # 每条含来源与片段内容
print("命中文件:", hit.file_id)
解读:include 加上 file_search_call.results 后,output 里的 file_search_call 条目携带完整命中——queries 是模型改写后的检索语句,未必与原问题逐字相同;results 逐条给出来源文件与片段内容,排查"为什么没检到"就靠它。答案文本侧还有一层引用:正文中被标注的位置挂着 file_citation 类型的 annotation,含 file_id 与命中文本,前端据此渲染"依据《使用手册》第几节"式的角标即可。两个位置各管一段:results 给程序对账,annotation 给用户看溯源。
5、托管检索与自建 RAG 的分界
file_search 这么省事,为什么业界仍有大量自建 RAG?因为托管换来的是控制权的让渡。两条路线的分界对比如下:
| 维度 | 托管检索 | 自建 RAG |
|---|---|---|
| 上手成本 | 十几行代码当天跑通 | 切分、嵌入、向量库、重排全套自备 |
| 检索控制 | 自动切分与重排,可调参数有限 | 块大小、混合检索、重排全可定制 |
| 数据边界 | 语料与索引存于 OpenAI 侧 | 语料不出自己的基础设施 |
| 费用结构 | 存储按天加检索按次加 token | 一次嵌入费加向量库托管费 |
| 规模上限 | 单库 1 万文件、单文件 512 MB | 无硬上限,看工程能力 |
量级是第一判据:个人知识库、产品手册、团队文档,几个 GB 以内,托管路线的工程性价比碾压;上了规模或上了合规要求,自建的基础设施才回本。还有个常见的折中姿势——embedding 仍调 OpenAI,向量库与检索自持,保住数据边界的同时少写一半解析代码。
文档进了向量存储,语料与索引就都落在 OpenAI 侧;涉及客户隐私或内部机密的语料,先过合规评估再上传。
6、向量存储的计费账
托管的账单分三段,各段口径如下(现价以官方定价页为准):
| 计费项 | 口径 | 量级参考 |
|---|---|---|
| 向量存储 | $0.10 每 GB 每天,首 1 GB 免费 | 3 GB 存一个月约 $6 |
| 检索调用 | $2.50 每 1000 次 | 折合单次 $0.0025 |
| 模型 token | 片段与生成照常计费 | 每问约两三千输入 token |
代一笔总账:3 GB 的文档库存一个月,超出免费额度的 2 GB 存储费是 2 × $0.10 × 30 即 $6;每天问 100 个问题、每问触发一次检索,一个月 3000 次是 $7.50;token 侧按第 8 篇的单价,Terra 档一个月几美元。三段合计每月十几美元,换来零运维的知识库——这是量级感受,真账以 response.usage 与平台账单为准。
注意:存储费按天收、与有没有人提问无关。用完的库要显式删除,或建库时配好
expires_after自动过期——忘删的库会安静地每天扣钱。
7、个人文档问答实战
把三部曲组装成一个能用的知识库助手:docs/ 文件夹里放几份 PDF——说明书、保单、租房合同——两段代码跑通。第一段建库,收成可复用的函数:
import glob
from openai import OpenAI
client = OpenAI()
def build_store(name, folder):
"""上传文件夹内全部 PDF 并建成向量存储"""
ids = [client.files.create(file=open(p, "rb"),
purpose="user_data").id
for p in glob.glob(f"{folder}/*.pdf")]
store = client.vector_stores.create(name=name)
client.vector_stores.file_batches.create_and_poll(
vector_store_id=store.id, file_ids=ids)
return store
store = build_store("个人文档", "docs")
print(store.file_counts.completed, "份文件就绪")
解读:build_store 把上传、建库、批挂载、等就绪收拢在一次调用里,几份 PDF 几秒到几十秒解析完;打印 file_counts.completed 确认全部就绪再放行。第二段是多轮问答,第 7 篇的 previous_response_id 直接派上用场:
def ask(question, prev_id=None):
resp = client.responses.create(
model="gpt-6.1-terra",
instructions="你是文档助理,回答要注明依据哪份文件。",
input=question,
tools=[{"type": "file_search",
"vector_store_ids": [store.id]}],
previous_response_id=prev_id, # 续接上一轮对话
)
return resp.output_text, resp.id
answer, sid = ask("保修期是多久?")
print(answer, "\n")
answer, sid = ask("那清洗滤网怎么操作?", sid)
print(answer)
输出类似:
依据《保修条款.pdf》:整机保修一年,电机保修三年。
依据《使用手册.pdf》:断电后取出滤网,清水冲洗晾干再装回。
解读四个要点。其一,store.id 全程只出现在 tools 声明里,问题本身怎么提都不用管文件——检索是模型的事。其二,previous_response_id 把上一轮问答连同检索条目一并续上,追问"那清洗滤网呢"里的"那"字,模型靠链上历史才读得懂。其三,多轮里每一轮都可能触发新的 file_search_call,问题换了主题就换一批片段——自建 RAG 里"多轮改写查询"的活,托管版白送。其四,用完删库一条命令 client.vector_stores.delete(store.id),第 6 节的账别忘了结。
8、总结
本篇把文件体系补完整:上传 client.files、归集 client.vector_stores、检索 tools 里的 file_search,三部曲各管一段;内置工具由服务端执行,没有回传循环,检索与否模型自断;命中片段注入输入侧计 token,include 与 annotation 一个给程序对账、一个给用户溯源;计费存储按天、检索按次、token 照常,首 1 GB 免费适合起步。选型建议:文档几个 GB、团队内部用,托管检索当天上线;语料敏感或规模上去,再认真评估自建。对于囤了一堆 PDF 的开发者,建议先把第 7 节两段代码跑通,把"翻文档找条款"换成"直接问一句"。细节口径见官方文件检索指南与 Vector Stores API 参考,SDK 辅助方法的实现可对照官方仓库。
留一道练习:给第 7 节的 ask 函数加引用展示——遍历 output 里 message 条目的 annotation,把每处 file_citation 对应的文件名拼在答案末尾;再故意问一个库里没有的内容,打印 file_search_call 的 queries 看模型怎么改写检索语句,并检验 instructions 里"直说不知道"的约束是否生效。

