当引用信息看似完美时,员工人数依然出错。
有一家薪资统计公司使用了正确的PDF文件,但仍将12名员工误算为1人——通过混合搜索、基于表格的压缩以及流程追踪功能解决了这个问题。
关于薪资发放的问题本应很枯燥。如果你询问2025年4月的薪资名单上有多少人,得到的只会是一个数字、一个引用,没有任何有价值的信息。系统却返回了一句措辞精美的回答:一名员工,PDF来源已标注,页面也有标记。但实际上名单上列出了十二人。
正是这种故障模式使得基于检索的生成技术在实践中如此危险。系统并未抛出异常,日志也毫无反应。给出的答案看起来和同一周的所有正确答案没什么两样。带有备注的轻微故障比彻底崩溃更糟糕,因为没人会去处理一段措辞客气的文字说明。
本文梳理了生成谎言的流程、揭露该谎言的诊断方法,以及将数值恢复到十二的具体调整措施。所使用的工具都很普通:用于处理带布局信息的PDF文本的pdfplumber,仅用于分块的LangChain文本分割器,MiniLM嵌入模型,用于向量存储的Qdrant,混合密集检索与BM25检索的方式,交叉编码器重排工具,以及旨在在生成器运行前压缩上下文的压缩器。这些选择并无特别之处。问题出在于它们在处理表格和标识符时的交互方式。
仅一次数据导入,多次响应查询
文档只需导入一次,而查询则会持续不断。在调试时保持这两者路径的独立性非常重要,因为错误的答案可能源于过时的向量、有问题的过滤条件,或是生成器未能获取到正确的数据行。
该流程会逐个处理每个PDF文件,以保留布局信息的方式提取文本,将其分割为重叠的片段,然后嵌入并上传至Qdrant,同时附带后续过滤操作可使用的元数据:
PDF → extract text per page → cut into chunks
→ convert each chunk to 384 numbers → store in a vector database
回答问题则属于另一种处理逻辑:先将问题嵌入系统,检索候选答案,可选择性地结合关键词匹配结果,对答案进行重新排序和压缩,最后附带引用信息将问题提交给模型:
question → search → rerank → compress → ask the LLM → cited answer
↓ ↓ ↓
5 chunks 3 chunks ~600 chars
理论上这种流程堪称标准范本,但在实际应用中,对于员工登记表、发票表格以及那些答案为结构化数据而非简单口号的文档,这种标准假设就会失效。
为何每个环节都有其存在的必要
pdfplumber并非最快的PDF处理库,但它是为数不多能够保留足够布局信息的工具,从而避免薪资相关数据行合并为一段文字。那些将表格简化为连贯句子的更快处理工具,使得后续“统计员工数量”之类的操作几乎无法实现,因为模型根本无法识别行边界。
在这个项目中,分块处理采用了LangChain的递归字符分割器,未使用该框架的其他功能。目标是实现具有重叠区域的可预测窗口大小,而非某种特定的RAG处理流程。经过短暂比较后,最终选择all-MiniLM-L6-v2作为嵌入模型:较小的模型无法识别标识符标记;较大的模型虽然能减少延迟,却未能解决关键问题。而Qdrant则因其在本地与云端之间的连续性处理能力,以及能与应用程序现有文档类型识别逻辑相匹配的有效载荷过滤功能而胜出。
混合搜索将语义相似度与BM25风格的关键词评分相结合,比例约为0.65/0.35。纯语义搜索在回答“什么是育儿假政策”这类问题时表现优异,但在处理STL/2025-26/003这类问题时则表现糟糕。通过交叉编码器进行重新排序后,候选列表得到了优化。压缩处理的目的是在调用大语言模型之前仅保留信号强度较高的句子。而正是在最后这个阶段,那个“十二个变一个”的故事真正开始了。
看似正确的错误答案
关于薪资的问题找到了一个看似合理的页面并引用了它,但估算的数值偏低。通过查看原始检索分数就能发现问题所在:语义相近的页面看起来“足够接近”,而精确匹配的条目则在与语料库中其他叙事性人力资源文本的竞争中表现不佳。
0.781 Invoice_INV2025001_Arvind.pdf <- returned
0.779 Invoice_INV2025002_Trident.pdf
0.776 Invoice_INV2025003_Welspun.pdf <- actually wanted
这意味着相似度算法无法完成精确的标记级处理。加入BM25并混合计算得分后,排名发生了变化,那些包含标识符和表格内容的片段排名上升了:
0.854 Invoice_INV2025003_Welspun.pdf <- correct
0.549 Invoice_INV2025001_Arvind.pdf
0.545 Invoice_INV2025002_Trident.pdf
仅此一步并未恢复原有的计数方式,它只是让正确的文档进入后续处理流程。接下来的难题存在于文档内部。
员工编号作为生存实验
没有再纠结于各种感觉因素,而是对员工编号ST001到ST012在提取、分块、检索、重新排序、压缩以及最终提示语组装的每个阶段进行了追踪。
retrieved chunk ST001 ST002 ST003 ST004 ST005 ... ST012 (12 present)
after rerank ST001 ST002 ST003 ST004 ST005 ... ST012 (12 present)
after compression ST001 ST002 (2 present)
prompt sent ST001 ST002 (2 present)
在压缩和模型处理过程中,有若干ID消失了。压缩器设有固定的“顶级句子”预算限制——每行数据算作一句句子。过于复杂的薪资表会提前耗尽预算,而后续的行以及解释这些行的文字则永远不会被纳入提示词中。于是模型只能如实报告它所看到的那部分内容。
# before — keep the top N
best = scored[:40]
# after — keep the best until the character budget runs out
budget = MAX_CONTEXT_CHARS // TOP_K_AFTER_RERANK
for sentence, score in ranked:
if best and len(sentence) + 2 > budget:
continue
best.append(sentence)
budget -= len(sentence) + 2
一旦压缩过程打乱并截断了行顺序,薪资表就变成了乱序发放的纸牌。虽然所有数字可能都存在于上下文窗口中,但用于统计不同员工的结构却已不复存在。在已被切割成有序片段的表格中统计人数,与直接阅读整个表格是两码事。
看似合理但依然有害的重新排序得分
关键行附近的交叉编码器得分看起来“合理”。但“合理”并不等同于能够保留完整的表格。
0.446 keep ST001 Rajesh Kumar M CFO 75,000 ...
0.323 keep ST002 Priya Sundaram Finance Mgr 45,000 ...
0.420 keep ST003 Murugan K Sr Accountant 28,000 ...
0.259 DROP ST005 Selvam R Production Mgr 38,000 ... ← below 0.30
0.422 keep ST006 Karthik P Supervisor 18,000 ...
# pick the best by score…
chosen = set(id(p) for p in best)
# …then put them back in document order before joining
best = [p for p in scored if id(p) in chosen]
修复方法并非“处处提高阈值”,而是识别出类似表格的行并免除对其的强力压缩。一个实用的启发式方法:如果某一行包含四个或更多数字,且位于同一数据块中至少三行相似的行之间,则将其视为表格行,无论其句子得分如何都予以保留。
ROW_MIN_NUMBERS = 4
TABULAR_MIN_ROWS = 3
def is_row(sentence):
return len(NUMBER_RE.findall(sentence)) >= ROW_MIN_NUMBERSif looks_tabular(sentences):
survivors = [p for p in scored if is_row(p[0]) or p[1] >= threshold]
else:
survivors = [p for p in scored if p[1] >= threshold]
由于表格行被豁免,所有十二个ID都在下一次运行时保留在了提示词中。生成器终于有了可以统计的结构。
消除其他隐患的细微修改
在表格路径处于开放状态时,还有几类相关问题也出现了同样的状况:空的环境变量悄悄替换了原本设定的默认值;本地 Qdrant 与 Qdrant Cloud 中的文档类型过滤器表现不同;而当操作员需要查看处理流程追踪信息时,返回的结果却只有纯文本。
# the context cap must be at least CHUNK_SIZE × TOP_K_AFTER_RERANK
MAX_CONTEXT_CHARS = 5000 # was 2000 while 1200 × 3 = 3600
现在每个响应都会返回整个处理流程,而不仅仅是最终结果——包括检索到的 ID、得分、压缩决策以及所使用的过滤器——这样就能在不猜测的情况下分析出下次出现错误的原因:
{
"answer": "12 employees are listed...",
"citations": [{"doc": "Salary_Register_April_2025.pdf", "page": 1}],
"pipeline": {
"retrieved": 5,
"after_rerank": 3,
"chars_before_compression": 3847,
"chars_after_compression": 2103
}
}
通过文档类型进行过滤在本地 Qdrant 实例上可以正常工作,但在 Qdrant Cloud 中也会以相同方式失败,直到响应中的索引和过滤器格式与云端实际存储的格式一致为止:
500 — Index required but not found for "doc_type"
Python中相关的实现方式:当变量存在但值为空字符串时,os.getenv("KEY", "default")会返回空字符串,但这并非真正的默认值。若需要确保在为空时使用默认值,则应使用or操作符。
QDRANT_URL = os.getenv("QDRANT_URL") or "http://localhost:6333"
最终结果
经过混合检索、基于表格的压缩处理以及明确的流程监控后,关于4月注册数据的问题最终得到了12个结果,相关引用指向了支撑该数值的对应行。
Retrieval: hybrid, 0.65 semantic + 0.35 BM25
Reranking: cross-encoder, 5 in → 3 out
Compression: sentence-level, table-aware, ~35% reduction
Grounding: prompt + threshold + temperature 0.0
Evaluation: 13/15 on the golden set, refusals tested
Latency: ~4s end to end
Cost: ~$0.003 per fifteen-question run
所有这些都不需要新的基础模型。关键在于将RAG视为一个数据处理流程,能够衡量重要标记的保留率。教程通常宣传“嵌入、检索、生成”,而实际应用则要求“证明相关数据已传递给提示词”。
保持数据准确性的操作习惯
准备一套黄金问题集,其中至少应包含一个针对表格的纯计数问题、一个精确标识符查找问题以及一个语义策略问题。如果只有语义类问题能通过,那就说明该演示在准备程度上存在虚假宣传。
通过压缩过程记录数据块ID。如果在检索后某个ID存在,但在压缩后就消失了,即便最终答案在当天是正确的,这也属于缺陷。
对于包含散文与不同语体风格的文本库,建议使用显式的混合检索方式。仅靠密集检索在文章类任务中表现优异,但在代码类任务中则会失利。
将引用视为必要但非充分的条件。即便有页码标注,若计数仍错误,则该计数依然无效。
从本地Qdrant迁移到云端时,需使用相同的测试用例重新验证负载索引及过滤功能。API兼容性并不意味着索引默认设置完全一致。
预算考量应针对整体结构,而不仅仅是“重要句子”。表格本身也是一种结构,若像处理博客段落那样压缩它们,就会导致十二个元素变成一个。
为何轻微故障也需要更严格的验证机制
团队们常常以“答案在正常流程提示的电子表格中显示良好”作为RAG系统上线的标准。但这一标准忽略了在薪资处理、库存管理及合规性方面更为关键的故障:即对数据量的低估。应添加自动检查机制,确保从已知固定数据中提取的实体集能够完整地保留在提示语中;如果压缩薪资相关数据后ST001–ST012这些元素均未出现,则应终止构建流程。
同样的验证机制还可以确保混合权重设置确实已在运行配置中生效,而不仅仅存在于README文件里。笔记本实验结果与服务配置之间的差异,往往是BM25算法在代码重构后“消失”的常见原因。
最后,要教导操作员不要轻信那些看似完美的引用。对于内部用户,产品界面应在答案旁显示检索与压缩的痕迹,直到整个周期内相关指标始终保持绿色状态。外部用户可以保留简洁的段落形式,而内部用户则需要详细的分析信息。
总结
注册表从未提及只有一名员工。但处理流程在悄悄删除那些本可纠正这一信息的记录后,却给出了这样的说法。要解决这个问题,就需要对标识符采用混合搜索方式,对结构数据运用基于表格特性的压缩技术,并让答案附带完整的证据链。一旦这些要素到位,数字依然保持不变——如此下次再有引用时,就能有真实依据支撑。
这才是值得坚持的标准:不是模型是否听起来充满把握,而是系统能否证明那些支撑这种把握的事实。
深入探讨实际应用中的混合评分机制
密集检索会将问题及每个文本片段编码到同一个向量空间中,再通过余弦值或点积来进行排序。当用户使用的语言与文档语言一致时,这种方法很有效——比如关于育儿假、遣散费、远程工作政策的内容。但当用户粘贴的标记在训练数据中几乎不会出现,且在嵌入空间中也难以与周边词汇共现时,该方法就会失效。员工编号、发票号和合同ID就属于这类标记。
BM25及相关的词汇评分模型则采取了相反的处理方式。它们关注的是某个词元是否出现、在语料库中的稀有程度,以及它在候选片段中出现的频率。它们并不关心“STL/2025-26/003”在语义上几乎毫无关联。将这两种评分结果混合并非出于某种哲学考量,而是因为薪资相关语料库中既包含类似文章的政策文本,也包含类似数据库的表格结构。
此处采用的0.65/0.35的比例仅是一个起点,并非不可更改的规则。以代码为主的语料库可能需要更高的词汇权重,而以叙述为主的语料库则可能不需要那么高。关键在于要对黄金数据集中的两类问题都进行评估,避免仅能通过叙述类测试的混合模型被投入使用。
在实现混合时,需先对分数进行归一化处理再加以合并。原始的密集相似度值与原始的BM25分数处于不同的量级上。那些直接使用未归一化数值的团队往往会发现某个渠道会意外地占据主导地位。如果通过包含精确ID的测试用例进行验证,最小-最大值融合或排名融合都是可行的方法。
作为表格有损编码器的压缩技术
上下文压缩的存在是因为模型的处理窗口是有限的,同时无关句子会分散注意力。通常的处理流程是:根据相关性为句子打分,保留排名靠前的k个句子,舍弃其余的。但这种处理方式假设句子是可互换的意义单元,而表格中的行并非如此——没有第1至6行的第7行并非只是稍差一点的表格,而是一个损坏的表格。
这种豁免判断规则——即一行中包含大量数字,且附近有几行类似情况——是刻意设计得较为粗糙的。它可能会保留一些看似包含数字但并非表格的行,而这也是可以接受的。误报会消耗处理资源,而漏报则会影响准确性。在薪资计算和库存管理中,准确性更为重要。
更复杂的检测工具可以利用pdfplumber提供的PDF结构信息:单元格边界框、对齐的列以及重复的x坐标。当这些信息存在时,能提供非常准确的判断依据。如果在压缩处理之前文本已经被转换为Markdown或纯文本格式,那么基于句子结构的判断规则仍可作为备用方案。
另一种可能出现的问题是行序混乱。即便所有行都保留了下来,按相关性得分重新排序也可能会破坏总计和计数结果。对于需要豁免处理的表格行,应保持文档的稳定顺序;而正文则可自由排序,但表格必须保持阅读顺序。
为错误答案提供支持的引用
Citation UX通常会标出贡献了最多检索内容的PDF文件及具体页面。即便后续压缩导致表格内容减少一半,引用信息依然会指向正确的文件,用户由此可确认信息的准确性。产品设计应当要么明确标注最终提示中使用的具体行数据,要么展示检索轨迹,否则该界面就会成为问题的帮凶。
对于受监管的行业领域,应将完整的提示上下文哈希值与答案一同存储。如果审计人员询问为何系统会显示“一名员工”,只需重新展示被截断的表格并指出其中的错误,无需再争论模型的特性。
本地向量数据库与云向量数据库
在本地可以正常工作但在 Qdrant Cloud 中失败的文档类型过滤器提醒我们:“相同的 API”并不等同于“相同的索引配置”。不同部署模式下的有效载荷索引、关键词过滤器以及空值处理方式都有所差异。测试用例应针对与生产环境相同的部署类型进行运行。本地测试通过而云端测试失败,往往会导致空结果被悄悄传递出去。
空环境变量同样需要引起重视。那些为未设置的密钥输出空字符串的配置系统,在某些语言中会因为空值被视为真值而绕过默认设置。应在进程启动时对配置进行标准化处理:将空值视为缺失,然后应用默认值,若所需键仍不存在则直接报错。
衡量系统稳定性,而非主观感受
员工编号存活实验是一个模板。选出正确答案所必需的实体,然后在数据提取、分块处理、检索、重排序以及压缩之后验证这些实体的存在性。绘制其数量变化曲线,通常出现的第一个下降点就是问题所在。
将这一思路扩展到数值聚合场景。如果是求和问题,需确认所有加数行都已进入处理流程;若是统计不同值的问题,则要确保不同值的集合是完整的。与实际生产中的故障相比,这些检查的成本相当低。
首先不应归咎于什么
当计数出现错误时,人们很容易将责任归咎于大语言模型。有时模型确实无法进行计数;但更多情况下,是模型从未接收到可计数的结构。只有在生存曲线显示为绿色之后才更换模型,否则你只是用一个能生成错误数值的更大模型来“修复”计数故障——直到下一次数据录入为止。
同样,不要因为某个压缩器出现异常就整个废弃整个框架。应隔离出问题环节,添加例外处理机制,进行测试后再继续使用。大规模重写看似有效,却常常会以新名称重新引入同类故障。
最低限度的强化检查清单
- 关键问题:表格计数、精确标识符、语义策略。
- 采用标准化混合方式或RRF的混合检索技术。
- 具备稳定行顺序的表格感知型压缩方式。
- 对每个内部输出结果都进行流程追踪。
在认定薪资相关RAG系统“已完成”之前,请先检查这份清单。四月份的记录不会是最后一个看似简单却意外出错的文档。
对团队的影响
当12名员工的回答系统稳定下来后,同样的监控数据又发现了两个较为隐蔽的漏洞:重新导入数据后集合别名失效,以及重排序功能超时导致系统退而使用未排序的混合结果,且没有将答案状态标记为异常。如果API仅返回字符串,这些问题根本无法被发现。通过返回结构化数据——包括评分、耗时及备用方案——使得操作员能够在凌晨2点依然信任该系统并对其进行调试。
这才是真正的产品启示。RAG系统并非PDF文档的聊天界面外壳,而是以段落形式输出数据的信息处理管道。应将其视作管道来对待:监测数据流失,保持结构完整,绝不能让引用替代实际证据。
再审视一次最初的错误答案
附上追踪信息重新分析那个糟糕的响应结果,几乎让人觉得乏味。检索系统差点就找到了正确的PDF文件,混合评分机制完成了后续工作,而压缩算法则将计算资源优先用于前几行数字数据,其余部分被舍弃。模型统计了剩余数据并引用了对应文件。每个阶段单独来看都有其合理性,但共同作用却制造出了虚假的可靠性。
这就是为什么仅基于单阶段的指标会具有误导性。检索@k的数值可能看似正常,但压缩处理却会导致无法得到正确答案。端到端的实体保留率才是与用户实际受损程度相匹配的指标,应尽早采用,尤其是当文档是以PDF形式呈现的表格时。
如果从那起涉及12名员工的事件中只能记住一点,那就是:在另有证据证明之前,礼貌的RAG系统故障都应视为流程中的缺陷。需对数据路径进行监控、保护数据结构,并在信任系统输出结果之前让其展示处理过程。在用户再次相信系统给出的数据量之前,必须设置严格的过滤机制、重复验证流程,并安排能够仔细核查每一项数据的操作人员。
对于以表格形式为主的文档集而言,通过压缩处理后的实体保留率依然是最简单且可靠的评估标准。
当新的数据集出现时,在再次信任其提供的任何引用数据之前,应重新运行相同的实体保留率分析。