从文档生成带引用的回答
本应用使用 Grok API 构建文档问答助手,根据你自己的文件回答问题,并标注引用来源。它适用于需要核实回答的场景,例如查询支持文章、政策或内部手册。
将一整个文件夹的文档上传到集合中,即可针对它们提问。检索会找出匹配的段落,Grok 仅依据这些段落作答,为每处依据标注来源并摘录原句,方便逐项核对。元数据筛选器决定哪些文档参与检索。如果文档未提供答案,则回复“I couldn't find that in your documents.”。程序可在小型网页应用或终端中运行,附带虚构户外用品商店的简短手册,以及一套含 20 道题的评测。
你将学到什么
创建包含元数据字段的集合,并将文档及其字段添加到集合中
用 AIP-160 筛选条件搜索集合,例如
version="2026",使检索仅覆盖符合条件的文档让 Grok 在作答前先摘录原文,并逐条核对引文与原段落
找不到依据时返回“I couldn't find that in your documents.”,避免猜测
流式传输结构化输出,随引文和答案生成而展示
通过小规模评测检查检索结果和引用的准确性
运行示例
需要 Node.js 22.13 或更高版本,以及两把密钥:
XAI_API_KEY为 API 密钥,用于上传文件、搜索和向 Grok 提问。XAI_MANAGEMENT_API_KEY为管理密钥,用于创建集合并将文档添加到其中。这两项操作使用管理 API,该 API 不接受普通 API 密钥。请前往 SpaceXAI Console,在 Settings → Management Keys 中创建一把管理密钥。授予它AddFileToCollection权限,以及 Collections Endpoint 分组下用于创建集合和读取集合中文档的权限。管理密钥与 API 密钥必须在同一个团队中创建,因为文件使用一把密钥上传,再使用另一把密钥加入集合。只有npm run ingest会用到管理密钥。
将两把密钥都写入 .env(位于仓库根目录),或将其导出为环境变量,然后上传示例手册:
cd examples/docs-to-cited-answers/typescript
npm install
npm run ingest这会创建名为 handbook 的集合,加入 handbook/ 中的六份文档,并附上 handbook/metadata.json 中列出的字段。待文档可检索后,将集合 ID 保存到 output/collection.json。整个过程约需 15 秒。每次运行都会创建新集合,可以在控制台中删除。
然后启动网页应用:
npm run web打开 http://localhost:3000,选择一个问题,或输入问题后点击 Ask。页面左侧的筛选器决定哪些文档参与检索,被排除的文档在下方资料库中变淡。中间区域会在 Grok 阅读文档时于问题下方展示其推理,然后流式显示带编号引用的答案。右侧列出检索到的段落、所属文件、相关性得分和字段;当 Grok 摘录原句时,相应句子会被高亮。将鼠标移至引用编号可查看原文段落。尝试询问“特价商品购买超过 30 天后能否退货”,先将筛选条件设为 version="2025",再设为 version="2026",答案会从可以变为不可以。点击 Stop 或关闭页面即可取消。
也可从终端提问:
npm start -- "Can I return a sale item after 30 days?" --filter 'version="2025"'默认筛选条件为 version="2026",而 --filter "" 会搜索所有文档。回答、引文及其原文段落会保存到 output/answers/。
每次回答约需 3 至 8 秒:检索约半秒,推理几秒,然后生成引文和答案。Grok 读取约 1,100 个 token,生成 150 至 350 个 token,其中多数用于推理。每次约 0.003 美元,按 grok-4.7 的标价计算。网页和终端均显示每个回答的 token 数量和 API 报告的费用。检索本身不另行报告费用。
要使用自己的文档,请将文档放在同一个文件夹,并在其中提供 metadata.json,逐个文件设置 version 和 team。格式可参考 handbook/ 中的示例。然后运行 npm run ingest -- path/to/folder。要使用其他字段,请修改 FIELDS(位于 src/ingest.ts)。页面根据实际文档字段生成筛选按钮。
工作原理
src/ingest.ts 调用 POST /v1/collections 管理 API 端点创建集合,在以下配置项中声明两个字段:field_definitions,version 和 team 均为必填,缺少任一字段的文档都会被拒绝。添加文档分两步:client.files.upload() 使用 Files API 上传文件,再通过 POST /v1/collections/{collection_id}/documents/{file_id} 将文件及其字段加入集合。脚本会检查各文档,直到状态变为 DOCUMENT_STATUS_PROCESSED。集合按 500 个字符分块,每块约为政策条文的一节;如果采用默认的 4,000 字节,每份短政策就只会有一个段落。SDK 未封装管理 API,因此这些调用通过以下方式发出:fetch。
共享代码位于 src/answers.ts。answer() 分两步运行,并通过回调报告各步进度,网页和终端可使用各自的方式展示相同进度:
searchDocuments()将问题、集合 ID 和筛选条件发送到POST /v1/documents/search,获得最匹配的六个段落。每个结果都有正文、所属文件、相关性得分、页码以及文档字段。默认结合关键词匹配和语义匹配。筛选条件使用 AIP-160 语法,例如version="2026",team="Finance" AND version="2026"或version="2025" OR team="People"。字段值必须完全匹配,若没有文档包含指定字段,就不会匹配到任何文档。SDK 也未封装此端点,因此通过fetch携带 API 密钥访问。如果没有文档符合筛选条件,应用会直接回答“I couldn't find that in your documents.”,无需调用 Grok。writeAnswer()为各段落编号并发送给grok-4.7,同时提供分为三部分的 JSON Schema:引文列表(每项包含段落编号和摘录文字)、这些段落是否足以回答问题,以及答案(每项事实后附方括号标注的段落号)。将引文放在前面,可以让 Grok 在动笔前先选定证据。JSON 流式生成时,json事件会提供截至当前的输出,因而页面可在 Grok 摘录时高亮相应句子。Grok 按 Schema 中的字段顺序输出,开始作答时引文已经完整,应用会在展示答案前对其进行核验。除空白、大小写及弯引号的差异外,引文必须与原文逐字相符才有效。每个答案至少要有一条有效引文,否则应用会返回“I couldn't find that in your documents.”。从六个短段落中摘录原句不需要很强的推理,因此选择较低推理强度,数秒内即可开始输出引文。
Grok 也提供内置检索工具 collectionsSearch(),可自行搜索集合。本应用未使用它,因为向该工具传入 filters 时,API 会返回 Argument not supported: filters,从而无法排除任何文档。内置工具的结果包含各段落的文本和得分,但没有页码和元数据字段;实测文件名也为空。先独立检索再将段落交给 Grok,也能确保每次执行相同的检索逻辑,便于评测。
src/server.ts 调用 answer() 为网页生成答案,并以服务器发送事件逐步推送进度。它会将 AbortSignal 传入每次请求,因此关闭网页或点击 Stop 即可取消检索或回答。服务器还会提供 output/collection.json,网页据此生成资料库和筛选按钮。public/index.html 使用原生 HTML 和 JavaScript。如果筛选条件由多个 key="value" 键值对组成,并由 AND 相连,对应按钮会被选中,被排除的文档会变淡。其他表达式(如 OR 或 !=)会原样传给检索端点。src/index.ts 在终端完成相同的工作。
npm run eval 调用 src/eval.ts,逐一测试以下文件中的 20 个问题:eval.json,每次并行处理四个。其中 17 题给出文件名及正确段落应包含的短语。评测会检查目标段落在检索结果中的排名,以及答案是否通过方括号中的编号或有效引文引用它。其余三题在指定筛选范围内没有答案,例如学生折扣,或在以下条件下查询特价商品退货:team="Finance"。只有回复“I couldn't find that in your documents.”才算通过。实测每次检索的第一名都是正确段落,所有有答案的问题都正确引用了它,三道无答案题也都提示没有找到。如果请求因限流等原因失败,该题不会计入总数,而会在末尾单独列出。每次评测约需 25 至 45 秒,用量约 25,000 个 token,按标价约 0.06 美元。结果保存到 output/eval.json。
检索会返回 page_number 字段,用于标明每个段落的页码,文档说明单页文档的该值为 0。但对示例手册中两页的 PDF,实测所有段落也都返回 0,因此只有检索实际报告了页码时,网页才会显示页码。