你手里有八十页产品手册,想让客户直接问「这款支持多少伏电压」。模型本身不知道答案,你得先把手册里相关的那两段找出来,塞进 prompt 一起发过去。整个文档问答系统里,九成的效果差异来自「找得准不准」,只有一成来自「模型聪不聪明」。所以别急着换模型,先把检索做对。
解析文档成纯文本 → 切成小段并建索引 → 用户提问时检索出最相关的几段 → 把这几段和问题一起发给模型。四步里只有最后一步要花钱,前三步在你自己的机器上跑,一分钱不花。
文档总共不到三千字,直接全塞进 system prompt 就行,别搞检索。文档每天在变、你懒得维护索引,那也不合适。真正适合检索的场景是:资料量在十万字以上、问题集中在少数几个知识点、答案必须能追溯到原文页码。
单个片段四百到八百字比较顺手;每次检索取回三到五段;塞给模型的资料总量控制在一万五千字以内。超过这个量,模型开始抓不住重点,答得又慢又飘。这三个数字不是拍脑袋定的,它们对应的是误命中率开始往上爬的拐点。先照这个跑,跑完二十个真实问题,再用自己的数据微调。
| 格式 | 推荐工具 | 要注意的地方 |
|---|---|---|
| PDF(文字版) | pypdf 或 pdfplumber | 双栏排版会串行,优先用 pdfplumber |
| PDF(扫描件) | 需要 OCR,内容复杂建议先转文字 | 纯图片 PDF 直接解析只会得到空白 |
| Word(.docx) | python-docx | 表格要单独取,正文段落里没有 |
| Markdown / txt | 直接读,encoding="utf-8" | 注意 BOM 和全角空格 |
| Excel | pandas.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")
出处在产品上就是信任,在排查时就是日志。客户说答错了,你一眼能看出是检索拿错了片段,还是模型自己编的。这两件事的修法完全不同。
三段起步,五段封顶。取太少,答案缺上下文;取太多,模型被无关内容带偏。先固定四段跑二十个真实问题,看错的那几个是「没检索到」还是「检索到了但被淹没」,再决定加减。改完记得重新跑同一批问题,不看基线的调参等于没调。
得分低于阈值就直接回「没找到」,不要硬送。给模型一段不相关的资料,它一定会编出一个看着合理的答案,这是文档问答里最伤客户信任的失败方式。
prompt 里写死「资料里没有的就直说」,再补一句「不要补充你自己的知识」。这两句能挡掉大部分编造。再把 temperature 压到 0.2 左右,同一问题两次回答不会差太多。
别只回一句冷冰冰的「未找到」。把检索得分最高的那两段的标题给出来,附一句「你是想问这个吗」,客户点一下就能重新问。这比让人重新组织语言问一遍的体验好上不少。
建索引、切块、分词、算相似度,全在你自己的机器上,零成本。真正产生费用的只有最后那一次请求。按次计费的机制在这里特别划算:你把四段资料一共三千字塞进去,和塞三百字是同一个价格。
| 模型 | 单价 | 每天 500 问 | 每月 15000 问 |
|---|---|---|---|
| gemini-2.5-pro | 0.031 元/次 | 15.5 元 | 465 元 |
| deepseek-v3.2-thinking | 0.049 元/次 | 24.5 元 | 735 元 |
| claude-sonnet-4-5-thinking | 0.09 元/次 | 45 元 | 1350 元 |
每天 100 次 × 0.031 元 = 3.1 元/天,小团队的内部知识库按这个量估就够。如果索引阶段还想让模型给每个片段生成摘要和标签,那是一次性开销:1000 个片段 × 0.031 元 = 31 元,跑完就没了。
高频问题缓存答案,命中就跳过模型调用。检索得分很高的简单问题用便宜模型答,只有复杂推理才升级。把 Top-K 从 5 降到 3,成本不变但响应更快,因为按次计费不看长度,这里省的是延迟不是钱。反过来说,别为了省钱把资料裁短,裁短了模型答不准,多问几轮反而更贵。
九成是扫描件。用浏览器打开确认一下能不能选中文字,不能选中就是图片,得先走 OCR。另一种情况是 PDF 加了权限限制,换个工具读。
| 报错或现象 | 原因 | 动作 |
|---|---|---|
extract_text() 返回空字符串 | 扫描件或加密 PDF | 先 OCR,或换 pdfplumber |
UnicodeDecodeError | 文件不是 UTF-8 | 试 gbk、utf-8-sig |
JSONDecodeError | 索引文件里混进了换行 | 写入时用 json.dumps 逐行写 |
| 检索永远命中同一段 | 片段里有超高频词 | 加停用词表,或降低该段权重 |
先打印实际送进模型的那段资料,肉眼读一遍。如果资料里确实有答案而模型没答对,是 prompt 的问题;如果资料里根本没答案却是被检索出来的,是切块或阈值的问题。这两种情况别混着调,一次只改一个变量,改完拿同样那二十个问题重跑一遍,对比错题数量有没有下降。凭感觉调参是这类项目里最容易走进去的死胡同。
单次塞进去的资料别超过一万五千字。真需要更长上下文,先让便宜模型把候选片段压缩成要点,再把要点交给贵模型组织语言,两段式处理比一次硬塞稳。
DOCS 是进程启动时读进内存的,改了 index.jsonl 要重启进程才会生效。想做热更新,就在每次请求前对比文件修改时间,变了就重读一遍。