这半年我一直在做一件挺“自虐”的事:把一个专门用来测试 RAG 系统信息提取能力的智能客户端工具,从零搭到能稳定产出评估报告。测试对象不是干净整洁的 FAQ 或产品手册,而是文学与学术领域的文档——充满了隐喻、长难句、引用格式、术语定义和参考文献的散文与论文。一开始我以为难点在 RAG 本身,真跑起来才发现,测试客户端的设计才是决定“提取能力”能不能被看见的关键。
这个工具的技术底座是本地部署的 llama.cpp + qwen2-7b 模型,通过 FastAPI 对外提供接入服务,客户端负责文档解析、检索调用、提示词拼装和结果评估。听起来不复杂,但当你面对一篇五十页的文学评论,或者一份带脚注和参考文献的学术论文时,“信息提取能力”这五个字会变得异常沉重。这篇文章会把我从需求分析、架构设计、实测调优到踩坑复盘的过程完整写出来,希望能给同样在做 RAG 评估、文档信息抽取、本地知识库问答系统测试的同学一些参考。
1. 为什么专门做一个“测试客户端”而不是直接调 RAG 接口
1.1 直接从接口测出来的结果,根本没法定位问题
我最初的做法很偷懒:直接把文档丢给 RAG 接口,然后把返回的答案记录下来。结果就是,十条结果里有一半答非所问,但我完全不知道问题出在哪一步。是文档解析阶段把段落切碎了?是向量检索召回了错误的片段?还是大模型生成时把检索到的内容理解偏了?这些问题全都混在一次调用里,接口只给了我一个黑盒子。
测试客户端的第一个价值,就是把这条链路拆开摊平。客户端内部会对每个环节打点:解析用时、分块数量、检索 Top-K 的来源 chunk、重排序得分、提示词最终拼成了什么样、模型生成耗时、原始答案和提纯后的结构化字段。每次测试跑完,客户端会生成一条带唯一 ID 的完整 trace,我可以随时重放某次请求,逐层排查。
code复制request_trace = {
"request_id": "rag_test_20250612_0017",
"parse": {"pages": 42, "blocks": 318, "time_ms": 1560},
"chunking": {"chunks": 127, "avg_chunk_len": 486, "strategy": "mixed"},
"retrieval": [
{"chunk_id": "c_0091", "score": 0.86, "source": "chapter_3"},
{"chunk_id": "c_0114", "score": 0.79, "source": "chapter_4"}
],
"rerank": {"selected": "c_0114", "score": 0.71},
"prompt": "...",
"generation": {"raw_answer": "...", "structured_json": "{...}", "time_ms": 4200}
}
这个 trace 是整个工具的基石。没有它,后面所有“优化”都是盲调。
1.2 文学与学术领域的信息提取目标,和业务文档完全不同
给测试客户端设计提取字段时,我一开始套用了业务文档的思路:提取“合同编号”“甲方名称”“有效期”。但文学和学术文档根本不按这个逻辑出牌。
- 文学文本:读者关心的是主题、人物关系、叙事线索、情感变化、意象对应。比如从《红楼梦》的某个章节里提取“贾宝玉对林黛玉的态度变化”,这需要跨越多个段落甚至章节去综合判断。
- 学术文本:关注的是研究问题、方法、数据、结论、创新点、引用关系。比如从一篇 NLP 论文里提取“该方法的 F1 值”和“与基线模型的对比结论”,这些信息往往分散在正文、表格和结论段落里。
所以测试客户端必须支持“可配置的提取字段模板”。同一个工具,测文学文本时加载“人物-关系-事件”模板,测学术文本时加载“方法-数据集-指标-结论”模板。字段模板不仅定义了要提取什么,还定义了每个字段的证据来源要求,这是后来评估阶段能算分的基础。
1.3 可复现的评估报告,才是这个客户端的灵魂
如果每次测试的结果都不一样,那所谓“能力评估”就是玄学。为了可复现,客户端做了三件事:
- 固定模型参数:温度固定为 0,top_p 固定为 0.9,关闭随机采样。
- 固定检索候选:对同一份文档,检索库内容和向量索引版本完全锁定。
- 统一评估口径:对每个提取字段,只允许输出“字段值 + 来源引用 + 置信度”,并对空值使用统一占位符
UNKNOWN。
客户端最终会生成一份 Markdown 或 JSON 格式的评估报告,包含总体指标、分字段指标、失败样本列表。这份报告可以直接拉到周会上讲,也可以作为回归测试的基线。对我来说,这个能力比 RAG 本身的准确率提升更值钱,因为它让团队的每次修改都变得可以被量化验收。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 本地 RAG 技术栈选型:llama.cpp + qwen2-7b + FastAPI
2.1 为什么坚持本地部署而不是闭源 API
做这个测试客户端之前,我完全可以用现成的云端大模型 API。但想了想还是放弃了,原因有三个:
- 隐私与合规:文学和学术文档经常涉及未公开发表的内容、审稿期论文、内部研究报告。这些东西一旦出网,隐患太大。本地部署从物理上杜绝了数据外流。
- 成本与频率:测试客户端要反复跑几百上千次请求,每次调用云端 API 的成本累积下来很可观。本地跑一次 qwen2-7b 的推理成本几乎可以忽略,跑坏了重来也不心疼。
- 可控性:云端 API 的模型版本会悄悄更新,今天测的结果明天可能就变了。本地部署意味着模型权重和推理环境完全由我掌控,任何一次结果漂移都能追溯到具体版本。
2.2 llama.cpp 部署 qwen2-7b 的量化与上下文配置
qwen2-7b 是阿里开源的中英双语模型,对中文文学文本和学术文本的理解力都很稳。llama.cpp 的优势在于它能把模型量化到很小的体积,同时保持相当不错的推理质量。我最终选了 qwen2-7b-instruct 的 GGUF 格式,量化级别是 Q5_K_M。
选择 Q5_K_M 而不是 Q4_K_M,原因是提取任务对细节很敏感。模型需要记住:一个日期、一个缩写、一个引用标记、一个特定的人名。量化过度会导致这些细粒度信息失真。实测下来,Q4 在“数值正确性”上比 Q5 差约 3 个百分点,Q8 提升又很有限,Q5_K_M 是性价比最高的位置。
上下文长度方面,llama.cpp 启动参数里我设置了 --ctx-size 8192。qwen2-7b 原生支持更长的上下文,但 8K 是一个兼顾内存和效果的折中值。超过 8K 的文本走分层摘要策略,而不是硬塞进上下文。
启动命令大致长这样:
bash复制llama-server \
--model /models/qwen2-7b-instruct-q5_k_m.gguf \
--ctx-size 8192 \
--n-gpu-layers 999 \
--host 127.0.0.1 \
--port 8080 \
--temp 0.0 \
--top-p 0.9 \
--no-mmap
--no-mmap 会把模型一次性加载进内存,减少推理时的磁盘抖动,对延迟敏感的信息提取任务有帮助。这一步是我在实际压测时发现的问题——OpenBLAS 版本下 mmap 会让长文本请求延迟飙升。
2.3 FastAPI 封装检索与生成接口
llama.cpp 自带一个 HTTP server,但它只提供通用的 completions 接口。测试客户端需要的不是“续写”,而是“检索 + 生成”的组合能力。所以我用 FastAPI 包了一层服务,暴露三个核心端点:
POST /retrieve:接收一个查询和文档 ID,返回检索到的 Top-K 片段及分数。POST /generate:接收查询 + 上下文片段,返回大模型生成结果。POST /extract:接收查询 + 文档 ID,完整执行“检索 → 生成 → 结构化提取”链路,直接返回 JSON 字段。
FastAPI 的好处是天然支持异步和类型校验,文档自动生成,测试客户端对接非常顺。qwen2-7b 的推理走 llama.cpp HTTP server,FastAPI 服务通过 httpx 转发请求。为了不阻塞事件循环,所有请求都用 async 方式去调。
python复制from fastapi import FastAPI
from pydantic import BaseModel
app = FastAPI()
class ExtractRequest(BaseModel):
query: str
doc_id: str
template: str = "academic"
class ExtractResponse(BaseModel):
fields: dict
evidence: list[str]
confidence: float
@app.post("/extract", response_model=ExtractResponse)
async def extract(req: ExtractRequest):
chunks = await retrieve(req.doc_id, req.query, top_k=6)
reranked = rerank(chunks, req.query)
prompt = build_extract_prompt(req.query, reranked, req.template)
raw = await llama_complete(prompt)
fields, evidence, conf = parse_structured(raw)
return ExtractResponse(fields=fields, evidence=evidence, confidence=conf)
这个小服务跑了一台 32G 内存的机器上,GPU 是 8G 显存的老卡,qwen2-7b 的 5-bit 量化能塞进去,速度大概 25 token/s,单次提取请求平均 6-10 秒,测试场景完全够用。
3. 测试客户端核心链路:数据摄取、分块、检索、提取
3.1 文学和学术文档的分块策略,不能一刀切
分块这一个环节,我前前后后改了四版。最初是按固定 512 token 切,结果把文学文本里的对话和旁白彻底切散了,学术文本里的“方法”和“实验”也被切到两个块里。后来改成策略混合模式,效果才稳定下来。
对文学文本,我按“叙事单元”分块:优先识别段落间的空行、场景切换标志(如“第二天”“回到书房”)、章节标题,再结合对话上下文做拼接。一个块最少 300 token,最多 1000 token,避免把连续意象切断。对学术文本,我按结构化标题分块:摘要、引言、相关工作、方法、实验、结论作为天然边界,同时保留段落内的脚注标记和引用标记。这样做的原因很简单——学术文献的信息密度极高,一个段落的丢失意味着整个结论链断裂。
分块过程中,客户端会给每个块打上元数据:来源章节、上下文标题、是否属于附录、是否包含表格描述。这些元数据后续会拼进提示词,帮助模型判断“这一段到底在说什么”,对提取任务帮助很大。
3.2 混合检索 + 重排序:按文档类型调权重
纯向量检索对文学隐喻和学术术语都有明显的盲区。比如文学文本里“那个人站在窗前,眼神像冬天的湖”——这种句子很难靠向量嵌入精准匹配;学术文本里 “F1 score” 和 “F 值” 是同一个概念,但向量表示不一定靠近。所以我用了混合检索:
- BM25 关键词检索:负责精确匹配人名、术语、引用标记、专业缩写。
- 向量相似度检索:负责语义召回,抓意思相近但字面不同的表达。
两条结果取并集之后,再用一个交叉编码器重排序。测试客户端里,我把两种检索策略的权重做成可配置参数。文学文本更偏向向量检索(权重 0.7),因为需要理解意象和上下文;学术文本更偏向 BM25(权重 0.6),因为术语精确性和引用格式是强线索。
code复制def hybrid_search(query, chunks, doc_type):
if doc_type == "literary":
vector_weight, bm25_weight = 0.7, 0.3
else:
vector_weight, bm25_weight = 0.4, 0.6
vector_scores = embed_search(query, chunks)
bm25_scores = bm25_search(query, chunks)
merged = []
for c in chunks:
score = vector_weight * vector_scores[c.id] + bm25_weight * bm25_scores[c.id]
merged.append((c.id, score))
return sorted(merged, key=lambda x: -x[1])[:10]
3.3 提取提示词模板:让 qwen2-7b 输出结构化 JSON
信息提取最怕的不是模型不懂,而是模型“自由发挥”。同一个字段,今天输出 “不知道”,明天输出 “未能从文中得出”,后天输出 “文中未提及”。评估脚本根本没法统一处理。所以我在提示词里写死了输出规则:
code复制你是一个信息提取引擎。请仔细阅读给定的文档片段,并从其中提取以下字段:
{template_fields}
输出要求:
1. 以 JSON 对象形式输出,不要添加任何解释性文字。
2. 如果某个字段在文档片段中没有明确依据,请输出 null。
3. 每个字段必须附带 evidence 字段,用文档原文或原文改写句说明来源。
4. 如果字段值来自多个片段,请在 evidence 中拼接所有引用。
文档片段:
{context}
输出:
这个模板跑了两次基本就稳定了。qwen2-7b 对 JSON 输出的遵从性比我想象中好,只要温度设为 0,很少出现额外的解释文字。但偶发情况还是有的——所以客户端会加一层 json 解析兜底,解析失败就自动重试一次,再失败就把该样本标为“解析错误”,而不是直接让整个流程崩掉。
4. 文学与学术文本提取的实测难点
4.1 文学语言的多义性与指代消解
文学文本最麻烦的是指代消解。“他”“她”“那个人”“这个念头”在小说里往往跨越很长的距离才能找到指代对象。而检索召回的是分散片段,每个片段里的 “她” 可能指代完全不同的人。我做过一个测试:从一篇短篇小说里提取“叙述者对母亲的情感”,检索返回的三个片段里各有一个 “她”,但分别指母亲、邻居、女儿。模型直接全部当作母亲处理,结果自然是一团糟。
应对办法是给检索阶段加一个前置的“指代追溯提示”。客户端在拼提示词的时候,不只给检索到的目标片段,还会向前额外取一段上下文。比如目标片段在第 12 段,就把第 9-13 段都作为上下文喂给模型。实测下来,这个方法让文学类字段的 F1 显著提升,代价是上下文占用变大。
4.2 学术文献的引用、术语与图表信息
学术文档的信息提取坑更多。引用格式本身会干扰模型的注意力:有时候模型把 “(2021)” 当作正文的一部分提取出来,而不是当作引用标记。术语缩写首次出现时有个全称,后续可能只用缩写,模型必须靠上下文判断它提取的“NLP”到底是自然语言处理还是神经语言学。图表信息在纯文本提取里几乎拿不到——PDF 里的表格一旦转成文本,行列结构可能完全错乱。
我针对这些问题做了两个处理。第一,在解析阶段对参考文献区域做特殊标记,让模型知道“这一段是参考文献,不必提取单独内容,但可以作为证据”。第二,对图表描述区域单独提取说明文字,并拼接在对应正文后面。比如“图 2 展示了不同提升方法在验证集上 F1 值的对比”,客户端会把这个说明作为正文的一部分,而不是试图把表格本身转成文本——因为表格转文本几乎必乱,还浪费 token。
4.3 上下文窗口不够,用分层摘要来凑数
qwen2-7b 的 8K 上下文窗口对短篇文学文本够用,对学术论文就很吃紧。一篇论文全文动辄 1 万-2 万 token,如果全塞进提取提示词,不仅慢,还会让模型注意力被稀释。我的做法是分层摘要:
- 第一层:按章节做摘要,每个章节压缩成 400-600 token 的浓缩描述,保留关键数值、人名、术语。
- 第二层:把所有章节摘要拼成一个“全文压缩快照”,作为提取提示词的全局背景。
- 第三层:对需要精确提取的字段,再拿检索召回的原文片段做细节支撑。
简单说,就是把大型文档先“地图化”,再“逐区放大”。模型看到的是先摘要后原文的组合,既能把握全局,又能找到细节证据。实测下来,分层摘要比硬塞全文的 F1 高出不少,而且推理延迟下降了一半。
5. 信息提取能力的评估指标与一组实测数据
5.1 指标设计:字段级 P/R/F1 和忠实度
信息提取的评估不能只看“回答得像不像”。我参考信息抽取的标准做法,做了字段级别的评估。每个字段定义三个值:
- 精确率:模型提取出的字段值中有多少是文档里真正存在的。
- 召回率:文档里本该被提取出的字段值中,模型真正提取出了多少。
- F1:两者调和平均值。
同时增加一个“忠实度”指标:提取结果是否忠于原始文档,有没有幻觉。这个指标用 LLM-as-judge 来判断——让另一个模型(我这里用的是同一个 qwen2-7b,但提示词完全不同)判断提取结果是否能在给定文档片段中找到依据。
code复制评估提示词:
请判断以下提取结果 {predicted_fields} 是否严格基于文档片段 {evidence}。
如果有任何字段无法从文档中找到依据,请输出 FALSE。
如果所有字段都能找到依据,请输出 TRUE。
只输出 TRUE 或 FALSE。
5.2 一组实测数据复盘
我拿一个包含 30 篇文学短文和 20 篇学术论文的小型测试集跑了一轮。以下是部分字段的实测结果:
| 测试集 | 提取字段 | 精确率 | 召回率 | F1 | 主要问题 |
|---|---|---|---|---|---|
| 文学短文 | 人物姓名 | 0.94 | 0.91 | 0.92 | 同人不同名、绰号误识 |
| 文学短文 | 人物关系 | 0.71 | 0.66 | 0.68 | 跨章节关系缺失 |
| 学术论文 | 研究指标 | 0.82 | 0.78 | 0.80 | 数值单位没保留 |
| 学术论文 | 引用关系 | 0.76 | 0.69 | 0.72 | 引用标记错位 |
人物姓名提取相对简单,因为实体边界清晰;人物关系是最难的,因为往往需要综合多个段落才能推断;引用关系则是被参考文献格式干扰严重,模型偶尔会把 “et al.” 后面的年份当作研究指标的一部分。每个字段的清晰度差异很大,这提醒我在设计测试用例时,不能只报一个总体的“准确率”,必须分字段看。
5.3 失败案例拆解:它为什么会答错
我挑一个典型的失败案例拆一下。一次测试中,文档是一篇文学评论《论张爱玲小说中的空间叙事》,字段要求提取“作者对空间叙事的定义”。检索召回的是第 2 段和第 5 段,第 5 段里有一句话“空间叙事在这里被理解为一个动态的感知过程”。模型提取出的答案是“空间叙事即动态的感知过程”,这没问题。
但同样的对话,如果查询改成“作者如何定义空间”,检索召回的第二位变成第 7 段,第 7 段讨论的是电影改编中的空间,模型就把电影相关的定义也混进去了,最终输出把“动态感知过程”和“电影镜头下的物质空间”糅在一起。根因是:检索召回时没有对“定义”这种元需求做过滤,导致不相关片段污染了结果。修复方案是:提取字段模板里给每个字段绑定一个“证据类型约束”,比如“定义类字段只接受包含‘定义’‘理解为’‘即’等标志词的片段”。这个思路本质上是从检索阶段就开始做字段级引导,而不是把过滤都丢给大模型。
6. 避坑清单与后续扩展思路
6.1 我踩过的六个坑
踩坑才是这个项目最有价值的部分,在这里分享几个最有代表性的:
- 固定 token 分块切碎了对话。文学文本里的人物对话经常跨多个短段落,按 token 切会出现“上半句在下个块、下半句在上一个块”的惨剧。解决方式:分块前先做段落合并,识别连续对话。
- 温度设成了 0.5,导致结果漂移。第一次跑测试时我以为大模型参数默认就行,结果同样的问题每次跑答案都不一样,评估报告根本没法用。后来统一强制
temperature=0。 - 向量维度不一致导致检索直接报错。我换了嵌入模型之后忘了重建索引,向量维度从 768 变成了 1024,检索结果变成随机排序。
- FastAPI 服务内存不断上涨。排查发现是 httpx 客户端没有复用连接,每次请求都新建连接,长期跑下来句柄泄漏。解法是初始化一个全局
httpx.AsyncClient。 - 提示词里没有要求“输出 null”,导致模型乱写“未知”。后来明确模板里写死 null,评估逻辑才统一。
- 检索阶段没有过滤表格与页眉页脚。PDF 转出来的页眉页脚经常带着章节名和页码,混进去之后模型老把页码当成内容。解决方式:解析后立刻剔除页眉页脚块。
6.2 测试客户端还能扩展成什么
现在的版本已经能完成“文档进、字段出、评估出报告”的主流程,但它还有很大的扩展空间。
- 人工标注工作台:让专家在客户端界面上直接修正提取结果,修正后的数据可以回流成 fine-tune 数据。
- 难例集自动生成:把每次测试中 F1 最低的样本自动收集起来,积累成对模型和策略的针对性挑战集。
- 多模型对比:同一个测试集,同时跑 qwen2-7b、llama3、deepseek 等多个本地模型,输出横向对比报告。
- 多语种支持:文学领域会碰到文言文、方言、英法文,这些对解析、分块和检索都会提出新挑战,扩展空间不小。
6.3 最后一点个人心得
做完这个项目,我对 RAG 系统的理解变了很多。以前我总觉得 RAG 的核心是模型够不够聪明,现在我认为,对信息提取类任务来说,链路设计与测试机制的重要性一点不比模型本身低。一个能精确告诉你“哪一层出了问题”的测试客户端,远胜于一个偶尔答对但永远无法诊断的无限 API。
如果以后再有人问我怎么做 RAG 知识库问答系统,我一定会建议他先把“测试与评估”这个环节跑通,再谈模型和算法优化。没有尺子,你永远不知道自己是不是真的进步了。这套工具和思路我已经沉淀成了一套模板,下一步打算把它做得更通用,让不了解技术细节的同事也能一键跑完整个评测流程。
