文档问答(RAG)怎么接 AI API:从 PDF 解析到可运行代码

先把问题说清楚:文档问答难在检索,不难在模型

你手里有八十页产品手册,想让客户直接问「这款支持多少伏电压」。模型本身不知道答案,你得先把手册里相关的那两段找出来,塞进 prompt 一起发过去。整个文档问答系统里,九成的效果差异来自「找得准不准」,只有一成来自「模型聪不聪明」。所以别急着换模型,先把检索做对。

一个能跑的文档问答只要四步

解析文档成纯文本 → 切成小段并建索引 → 用户提问时检索出最相关的几段 → 把这几段和问题一起发给模型。四步里只有最后一步要花钱,前三步在你自己的机器上跑,一分钱不花。

什么时候不该上文档问答

文档总共不到三千字,直接全塞进 system prompt 就行,别搞检索。文档每天在变、你懒得维护索引,那也不合适。真正适合检索的场景是:资料量在十万字以上、问题集中在少数几个知识点、答案必须能追溯到原文页码。

三个数字先记住

单个片段四百到八百字比较顺手;每次检索取回三到五段;塞给模型的资料总量控制在一万五千字以内。超过这个量,模型开始抓不住重点,答得又慢又飘。这三个数字不是拍脑袋定的,它们对应的是误命中率开始往上爬的拐点。先照这个跑,跑完二十个真实问题,再用自己的数据微调。

从 PDF 到文本:把文档变成能检索的东西

常见格式用什么工具解析

格式推荐工具要注意的地方
PDF(文字版)pypdfpdfplumber双栏排版会串行,优先用 pdfplumber
PDF(扫描件)需要 OCR,内容复杂建议先转文字纯图片 PDF 直接解析只会得到空白
Word(.docx)python-docx表格要单独取,正文段落里没有
Markdown / txt直接读,encoding="utf-8"注意 BOM 和全角空格
Excelpandas.read_excel一整行拼成一句话,别一格一段
pip install pypdf pdfplumber python-docx openai

解析出来全是乱码怎么排查

先看 page.extract_text() 返回的是不是空字符串。空白说明这是扫描件,得走 OCR。返回一堆问号和方块,多半是字体没有内嵌,换成 pdfplumber 再试一次。中文里夹杂空格(「产 品 说 明」)是常见现象,用正则把单字之间的空格去掉就行。

import re
import pdfplumber

def pdf_to_text(path: str) -> str:
    parts = []
    with pdfplumber.open(path) as pdf:
        for i, page in enumerate(pdf.pages, 1):
            t = page.extract_text() or ""
            parts.append(f"\n[第{i}页]\n{t}")
    text = "\n".join(parts)
    text = re.sub(r"(?<=[\u4e00-\u9fff])\s+(?=[\u4e00-\u9fff])", "", text)
    return text

保留 [第N页] 这个标记很重要,后面回答问题时可以让模型附上页码,客户一看就知道你没瞎编。

切块:决定效果好坏的关键动作

按字符切还是按语义切

按固定字符数硬切,会把一句话劈成两半,检索出来的片段读不通。按段落和标题切,长度不齐但语义完整。实操办法是「先按标题和空行切,再对超长的段落做二次切分」,长度落在四百到八百字之间。

def chunk_text(text: str, size: int = 600, overlap: int = 100) -> list[dict]:
    paras = [p.strip() for p in text.split("\n") if p.strip()]
    chunks, buf = [], ""
    for p in paras:
        if len(buf) + len(p) <= size:
            buf += p + "\n"
        else:
            if buf:
                chunks.append(buf.strip())
            buf = (buf[-overlap:] if overlap and buf else "") + p + "\n"
    if buf.strip():
        chunks.append(buf.strip())
    return [{"id": i, "text": c} for i, c in enumerate(chunks)]

重叠多少合适

一百到一百五十字比较稳。重叠太少,跨段的答案会被切断;重叠太多,检索时同一个内容反复命中,白占上下文。别超过片段长度的三分之一。

表格和代码怎么处理

表格一定要在切块前转成文字描述,比如「型号 A:电压 220V,功率 800W」。表格原样留在片段里,模型经常读错列。代码块不要切碎,整块保留,否则检索到半截函数毫无意义。

每个片段还要带上元数据:来自哪个文件、第几页、属于哪个章节。检索时可以先按章节过滤一轮,客户问售后的,就别把产品参数那几段捞出来掺和,准确率立刻上一个台阶。元数据不占请求成本,但能省掉大量人工排查。

检索:不用向量库也能先跑起来

方案一:关键词检索,零依赖

把所有片段放内存,用 jieba 分词后算词频匹配。十万字以内完全够用,代码四十行,不装任何数据库。缺点是问「怎么退货」时,找不到正文里只写了「退换货流程」的那段。

方案二:向量检索,同义改写也能命中

把片段转成向量存起来,查询时算余弦相似度。它能理解「退货」和「退换货」是一回事,这就是它比关键词强的地方。代价是要多一步向量化,或者调一次向量接口。片段上千个以后,向量检索的召回质量会明显甩开关键词方案,所以资料规模一上来就该换。

中文分词别用默认模式

jieba.cut 的默认模式会把「充电器」切成「充电」和「器」,检索时容易误命中。用 jieba.cut_for_search 或者加自定义词典,把你行业的专有名词先塞进去,召回率立刻不一样。词典就用一个纯文本文件,一行一个词,加载时传给 jieba.load_userdict,改词不用改代码。

混合检索:两路结果合并去重

关键词和向量各取前十,按命中次数和相似度加权排序,取前五。这套组合在实测里比单用任何一路都稳,尤其是资料里既有大量专有名词、又有口语化提问的时候。别一开始就上它,先用关键词跑通,效果不够再加向量。

完整代码:一个能跑的问答接口

依赖与目录

rag/
  docs/            放原始文档
  index.jsonl      切好的片段
  build_index.py   建索引
  ask.py           问答接口

建索引脚本

import json, os, re, pdfplumber

def clean(t):
    return re.sub(r"(?<=[\u4e00-\u9fff])\s+(?=[\u4e00-\u9fff])", "", t)

def chunk_text(text, size=600, overlap=120):
    paras = [p.strip() for p in text.split("\n") if p.strip()]
    chunks, buf = [], ""
    for p in paras:
        if len(buf) + len(p) <= size:
            buf += p + "\n"
        else:
            if buf:
                chunks.append(buf.strip())
            buf = (buf[-overlap:] if buf else "") + p + "\n"
    if buf.strip():
        chunks.append(buf.strip())
    return chunks

out = []
for name in os.listdir("docs"):
    path = os.path.join("docs", name)
    if name.lower().endswith(".pdf"):
        with pdfplumber.open(path) as pdf:
            text = "\n".join((p.extract_text() or "") for p in pdf.pages)
    else:
        text = open(path, encoding="utf-8").read()
    for c in chunk_text(clean(text)):
        out.append({"source": name, "text": c})

with open("index.jsonl", "w", encoding="utf-8") as f:
    for i, c in enumerate(out):
        f.write(json.dumps({"id": i, **c}, ensure_ascii=False) + "\n")
print("片段总数:", len(out))

问答接口代码

import json, os, re
import jieba
from openai import OpenAI

client = OpenAI(
    api_key=os.environ["XYU_API_KEY"],
    base_url="https://xyuapi.top/v1",
    timeout=90.0,
)

DOCS = [json.loads(l) for l in open("index.jsonl", encoding="utf-8")]
for d in DOCS:
    d["tokens"] = set(jieba.cut_for_search(d["text"]))

def retrieve(question: str, top_k: int = 4):
    q = set(jieba.cut_for_search(question))
    scored = []
    for d in DOCS:
        hit = len(q & d["tokens"])
        if hit:
            scored.append((hit / (len(q) + 1), d))
    scored.sort(key=lambda x: -x[0])
    return [d for _, d in scored[:top_k]]

def answer(question: str) -> dict:
    hits = retrieve(question)
    if not hits:
        return {"answer": "资料里没有相关内容,建议换个说法再问。", "sources": []}
    ctx = "\n\n".join(f"【来源:{h['source']}】\n{h['text']}" for h in hits)
    prompt = (
        "只根据下面的资料回答,资料里没有的就直说没有,不要补充你自己的知识。\n"
        "回答控制在三句话内,末尾用一行写明依据来自哪份资料。\n\n"
        f"资料:\n{ctx}\n\n问题:{question}"
    )
    resp = client.chat.completions.create(
        model="gemini-2.5-pro",
        messages=[{"role": "user", "content": prompt}],
        temperature=0.2,
    )
    return {
        "answer": resp.choices[0].message.content.strip(),
        "sources": sorted({h["source"] for h in hits}),
    }

if __name__ == "__main__":
    while True:
        q = input("问:").strip()
        if not q:
            break
        r = answer(q)
        print(r["answer"], "\n出处:", r["sources"], "\n")

为什么一定要把出处返回

出处在产品上就是信任,在排查时就是日志。客户说答错了,你一眼能看出是检索拿错了片段,还是模型自己编的。这两件事的修法完全不同。

把答案质量提上去的四个参数

Top-K 取多少

三段起步,五段封顶。取太少,答案缺上下文;取太多,模型被无关内容带偏。先固定四段跑二十个真实问题,看错的那几个是「没检索到」还是「检索到了但被淹没」,再决定加减。改完记得重新跑同一批问题,不看基线的调参等于没调。

相似度阈值别省

得分低于阈值就直接回「没找到」,不要硬送。给模型一段不相关的资料,它一定会编出一个看着合理的答案,这是文档问答里最伤客户信任的失败方式。

让模型只依据资料回答

prompt 里写死「资料里没有的就直说」,再补一句「不要补充你自己的知识」。这两句能挡掉大部分编造。再把 temperature 压到 0.2 左右,同一问题两次回答不会差太多。

检索不到时给什么

别只回一句冷冰冰的「未找到」。把检索得分最高的那两段的标题给出来,附一句「你是想问这个吗」,客户点一下就能重新问。这比让人重新组织语言问一遍的体验好上不少。

成本核算

检索不花钱,只有回答花钱

建索引、切块、分词、算相似度,全在你自己的机器上,零成本。真正产生费用的只有最后那一次请求。按次计费的机制在这里特别划算:你把四段资料一共三千字塞进去,和塞三百字是同一个价格。

每天五百问的账

模型单价每天 500 问每月 15000 问
gemini-2.5-pro0.031 元/次15.5 元465 元
deepseek-v3.2-thinking0.049 元/次24.5 元735 元
claude-sonnet-4-5-thinking0.09 元/次45 元1350 元

每天 100 次 × 0.031 元 = 3.1 元/天,小团队的内部知识库按这个量估就够。如果索引阶段还想让模型给每个片段生成摘要和标签,那是一次性开销:1000 个片段 × 0.031 元 = 31 元,跑完就没了。

三个省钱手段

高频问题缓存答案,命中就跳过模型调用。检索得分很高的简单问题用便宜模型答,只有复杂推理才升级。把 Top-K 从 5 降到 3,成本不变但响应更快,因为按次计费不看长度,这里省的是延迟不是钱。反过来说,别为了省钱把资料裁短,裁短了模型答不准,多问几轮反而更贵。

常见报错与排查

PDF 解析出来是空白

九成是扫描件。用浏览器打开确认一下能不能选中文字,不能选中就是图片,得先走 OCR。另一种情况是 PDF 加了权限限制,换个工具读。

报错或现象原因动作
extract_text() 返回空字符串扫描件或加密 PDF先 OCR,或换 pdfplumber
UnicodeDecodeError文件不是 UTF-8gbkutf-8-sig
JSONDecodeError索引文件里混进了换行写入时用 json.dumps 逐行写
检索永远命中同一段片段里有超高频词加停用词表,或降低该段权重

检索到了却答非所问

先打印实际送进模型的那段资料,肉眼读一遍。如果资料里确实有答案而模型没答对,是 prompt 的问题;如果资料里根本没答案却是被检索出来的,是切块或阈值的问题。这两种情况别混着调,一次只改一个变量,改完拿同样那二十个问题重跑一遍,对比错题数量有没有下降。凭感觉调参是这类项目里最容易走进去的死胡同。

长文档请求超时

单次塞进去的资料别超过一万五千字。真需要更长上下文,先让便宜模型把候选片段压缩成要点,再把要点交给贵模型组织语言,两段式处理比一次硬塞稳。

索引更新后答案还是旧的

DOCS 是进程启动时读进内存的,改了 index.jsonl 要重启进程才会生效。想做热更新,就在每次请求前对比文件修改时间,变了就重读一遍。

相关阅读

🚀 想要立即使用?来小鱼API体验全系列AI模型

查看全部产品

支持Gemini / Claude / GPT / Grok / DeepSeek · 国内直连 · 支付宝/微信