去年有段时间,我被一个看似普通的问题折腾得够呛:内部知识库问答机器人,用户问“设备采购审批的截止时间是每月几号”,系统抓回来的却是整整三页的《采购管理制度》开篇介绍。理由很简单——那段话里有“采购”“审批”“制度”,语义相关度足够高,就是没有任何一行字回答截止时间。这种场景在RAG落地中太常见了:我们花大力气做了向量化、建了向量库,结果检索环节一直在“猜”,而不是在“找”。正是在那段摸爬滚打里,我逐渐放弃了对向量检索的迷信,慢慢梳理出一套以文档逻辑页为单位、以字段倒排索引为核心、以条件推理为检索方式的替代路径。后来我们在自己的文档问答系统里把它做了出来,内部代号PageIndex。这篇文章就是关于“无向量推理式RAG”的一次完整复盘,适合所有做知识库问答、私域文档检索、企业内部搜索的人参考。不需要你懂神经网络,甚至不需要你会向量数据库,但你得先愿意承认:向量检索不是万能的。
1. 从“检索到一堆相关废话”说起:向量检索的盲区到底在哪?
1.1 三个让我决定重做检索层的失败现场
第一个现场是精确属性查询。用户问“南部大区负责人是谁”,文档里白纸黑字写着“南部大区负责人:张某某”,但系统返回的是“南部大区组织架构介绍”这个大段落,里面密密麻麻列了十几个人名,向量相似度很高,却没有任何上下文告诉你谁是负责人。这类问题不是模型不够聪明,而是它本质上需要把一个“属性名”和一个“属性值”精确对应起来,连续向量空间压根不擅长表达这种离散的键值关联。
第二个现场是数值范围与表格查询。用户问“去年合同额超过五百万的客户有哪些”,文档里的合同汇总表清清楚楚列着客户名和金额,可向量化之后表格被拆成了零散的文本块,数字和客户名之间的对应关系全丢了。检索系统抓回来的常常是“某客户合作案例”这类情感上相关、实际上没用的内容。
第三个现场是否定和排除类问题。问“哪些城市没有开通夜间配送”,向量检索会优先匹配含有“夜间配送”的段落,结果给你列出一堆“已开通夜间配送的城市”。它根本没有能力执行“没有”“除开”“不包括”这类逻辑操作。这三个现场让我意识到,向量检索擅长的是“模糊召回”,而大量企业知识问答真正需要的是“条件定位”。
1.2 被向量化过程抹掉的文档结构
再往深一层看,向量检索的盲区很大程度来自预处理阶段的“结构压平”。主流做法是先把文档切成chunk,然后对每个chunk做embedding。切块的时候,标题层级、页眉页脚、表格行列、编号列表这些结构信息,基本都被当成普通文本扔进向量模型里了。
比如一份制度文件,小标题“报销审批时间”后面跟着一条表格,表格第一行是“费用类型”,第二行是“审批截止时间”。如果把整页切成一个chunk,模型可能记住“报销”“审批”这类词,但“每月5日下午5点”这个数值和“截止时间”这个字段名的对应关系,在连续向量里很难被稳定编码。更麻烦的是,长文档切块经常会把表格拦腰截断,上一块有表头,下一块只有数值,检索的时候自然对不上。
我举个生活化的例子:向量检索像是一个记性很好但有点马虎的同事,你问他“会议室在哪层”,他记得你昨天提到过“会议室”也提到过“楼层”,但他不记得原文里“三楼302”前面主语是“打印机”而不是“会议室”。这种结构性失忆,不是把embedding模型换大一点就能解决的。
1.3 什么场景下我应该怀疑向量优先?
经过几次失败的落地之后,我给自己列了一个清单,只要命中其中三条以上,我就会开始怀疑当前场景到底适不适合用向量检索做主力:
- 问题依赖文档的标题层级、表格、字段键值等结构信息;
- 查询里包含明确的数值、日期、枚举值、型号代码等离散条件;
- 答案需要有据可查,用户会追问“你凭什么说截止时间是5号”;
- 领域词表相对封闭,比如内部系统名称、产品型号、制度术语;
- 文档更新频繁,每次改版都希望检索结果同步准确。
这个清单不是否定向量检索,而是提醒团队:如果问题类型偏“查事实”“找字段”“判条件”,那我们在用一个擅长联想的工具去做一道需要精确计算的题。PageIndex就是在这样的质疑声中开始设计的。
2. PageIndex的核心设计:把“语义猜测”变成“条件求解”
2.1 逻辑页:一个带结构上下文的信息单元
PageIndex的第一步,是把文档按照结构切成“逻辑页”。这里的页不是PDF里那个物理页,而是一个相对完整的信息单元:一个章节小节、一段制度正文、一组问答对、一张表格,各自都可以算作一个逻辑页。切分规则不统一,完全看文档类型。
每个逻辑页会保存一组结构化字段,包括:页面唯一ID、所属文档ID、层级路径、标题链(比如“第三章 > 报销管理 > 3.2 报销时间”),然后是正文内容、表格结构(可选,通常存成键值对列表),以及元数据(比如版本号、发布生效日期、适用区域)。之所以叫“页”,是因为它把定位信息的坐标从“第几块文本”变成了“文档里某个有确定边界的页面”。
这个设计的初衷很简单:当用户问“报销审批的截止时间是几点”时,系统不应该去所有chunk里找“报销”“截止时间”的语义相似向量,而应该直接定位到“报销管理”这个逻辑页,然后在这个页面的表格字段里,用“审批截止时间”这个键去取对应值。定位信息,比匹配语义重要得多。
2.2 两级索引:结构树加字段倒排表
PageIndex的索引分成两级。第一级是结构树,保存文档之间的层级关系,用于回答“这个页面属于哪章”“它的前后页是什么”。第二级才是核心的字段倒排表,对每个逻辑页的标题、正文、表格键值、元数据分别建倒排索引。
倒排索引在这里不像向量库那样算相似度,而是记录“哪个词、出现在哪个逻辑页、哪个字段、第几个位置”。下面是一个简化数据结构:
# 字段倒排表核心结构 inverted_index = { # term -> list of entries "截止时间": [ {"page_id": "doc_1_p_12", "field": "title", "pos": 3}, {"page_id": "doc_1_p_13", "field": "table_key", "pos": 0}, ], "报销": [ {"page_id": "doc_1_p_12", "field": "body", "pos": 10}, ] }检索的时候,先根据条件拿到候选逻辑页集合,再结合字段权重和位置信息打分。这样最大的好处是“可解释”:为什么返回这个页面?因为标题命中了“截止时间”,表格键值命中了“报销”,每个分数都能拆出来讲给用户听。
2.3 从自然语言到条件表达式树:查询解析器怎么做
查询解析是PageIndex最容易被低估的一环。比如用户问“2023年南部大区收入超过100万的项目有哪些”,一段自然语言需要被拆成一组可执行的条件:
- 年份字段等于2023(通常在元数据里);
- 区域字段包含“南部大区”(同样在元数据或正文标题链里);
- 收入字段大于100万(通常在表格字段里,需要先做单位归一化);
- 类型字段等于“项目”(可能在正文或元数据里)。
这些条件会组成一个表达式树,检索时先对每个叶子条件分别从倒排表里捞候选页面,再按逻辑运算求交或求并,最后进入打分器。整个流程没有一步依赖向量相似度,但每一步都有明确依据。我当时把这个过程形容为“把AI的语义猜测,降维成数据库的条件查询”。
2.4 为什么管它叫“推理式”而不是“规则匹配”
有人会问:这不就是传统搜索引擎那套倒排索引加布尔查询吗?为什么敢叫“推理式”?我的理解是,普通规则匹配只会做“包含词A或词B”这种并列判断,而PageIndex做的是“字段感知的条件组合”。
同一个词出现在标题和出现在正文,权重完全不同;出现在表格键里和出现在表格值里,语义角色也完全不同。PageIndex的打分器会把字段匹配分、结构权重分、关键词位置距离分融合在一起,最终给出一个可解释的综合分。比如“特殊地区审批条件”这个页面,标题链命中“审批条件”,表格键命中“特殊地区”,段落里又同时出现“南部大区”和“材料清单”,哪怕它和用户问题里某个词没有字面重复,也能因为结构上下文的相关性被推举出来。
所以,“推理式”指的是:系统能回答“我是靠什么条件、从哪个字段、为什么把这个页面排到最前面的”这个问题。向量检索做不到这一点,而企业知识场景里,可解释性往往比一点点召回率更重要。
3. 手把手拆解无向量RAG的实现链路:解析、建索引、查询推理、生成
3.1 文档解析与逻辑页切分:三个容易被忽略的坑
第一步是从原始文档里提取结构化内容。PDF、Word、Markdown的处理方式完全不同。PDF要按文本块抽取,不能逐行硬切,尤其表格区域要尽量保住行列布局;Word要先解析段落样式,标题层级依赖大纲级别;Markdown相对好办,直接按标题标记切分就行。
这里我踩过三个坑。第一个是扫描件:老制度文档全是扫描PDF,直接解析出来是一堆乱码或空白,必须先做OCR。OCR的准确率直接影响后边精确匹配的效果,所以对错别字比较多的扫描件,宁可在源头做一次人工校核。第二个是页眉页脚和目录:它们会在倒排索引里产生大量噪声词,比如“内部资料”“第 x 页”,建索引之前必须过滤。第三个是表格跨页:一个表格被切成两半,上半有表头,下半只有内容,逻辑页切分时如果按物理页切,这个表格就废了。我的做法是让解析器把跨页表格先拼回来,再接成一张完整逻辑页。
3.2 倒排索引构建:字段是灵魂
解析完之后,每个逻辑页会有多个字段。倒排索引要按字段分别记录,因为后续打分时字段权重完全不同。我这里给一个简化版的构建逻辑:
from collections import defaultdict def tokenize(text): # 实际项目中需要做分词、繁简归一、数字归一化 return text.split() def build_inverted_index(pages): index = defaultdict(list) for page in pages: field_values = { "title": page["title_chain"], "body": page["body"], "table_key": page["table_keys"], "table_value": page["table_values"], "meta": page["meta"], } for field, text in field_values.items(): for pos, token in enumerate(tokenize(text)): # 这里提前做了字段上的过滤和权重标注 index[token].append({ "page_id": page["page_id"], "field": field, "pos": pos, }) return index关键词一定要保留数字token和型号token,不要一律小写化。比如产品编号“A-100-02”和金额“100万”,如果大小写归一化,可能把两个完全不同的东西混成一个词。索引层面最好还要做繁简归一化和全半角统一,中文场景尤其明显。
字段权重的设计也很有讲究。我常用的初始权重是:标题链命中权重3.0,表格键命中2.5,表格值命中2.0,元数据命中1.5,正文命中1.0。这个值不用迷信固定公式,跑完测试集之后按问题类型微调就行。关键是权重设计要体现“结构信息比正文噪声更可信”的直觉。
3.3 查询解析器的设计:正则、词典与实体识别如何协同
查询解析器不能一上来就做大而全的语义解析。我的经验是三层递进:先上正则,再上词典,最后补轻量NER。
- 正则负责明显的模式:日期、金额、型号、时间段。比如“超过100万”“2023年”“每月5日”这些模式用正则提取又快又稳。
- 词典负责领域术语的归一化:内部叫“合同额”还是“签约金额”,用户问题里写“客户”还是“合作方”,都要映射到同一个标准字段名上。
- NER负责兜底:识别部门名、区域名、产品名这类开放实体,但不要把希望全押在NER上,它产生的噪声比正则大得多。
解析器输出不要直接变SQL,而是先输出条件表达式树,每个叶子条件包含字段、操作符、值。比如“收入大于100万”会变成{field: "revenue", op: ">", value: 1_000_000}。“南部大区”会变成{field: "region", op: "contains", value: "南部大区"}。这样后面所有检索逻辑都建立在统一的数据结构上。
3.4 检索结果如何喂给LLM:上下文打包与可追溯性
索引和推理只负责“定位”,最终生成答案还是靠LLM。但这里有个关键操作:不要把检索到的原始页面一股脑塞给模型,而是先按模板打包成结构化的上下文片段。
比如一个命中页面,我会打包成这样一段文本:
[来源] 文档《采购管理制度》第3章 3.2节 [标题链] 第三章 > 报销管理 > 3.2 报销时间 [正文] 报销审批的截止时间为每月5日下午5点...... [表格] 费用类型 | 截止时间 | 审批人这样的好处有两个:一是LLM更容易理解字段对应关系,减少胡编;二是生成答案时可以明确引用来源,用户看到答案能直接翻到对应页面复核。可追溯性在内部知识库场景里不是可选项,是刚需,你总不希望法务拿来背书的时候连出处都找不到。
3.5 兜底与混合策略:召回不足怎么办
PageIndex不是万能钥匙,它的主要弱点是“字面敏感”。如果用户问题和文档里的措辞差异太大,比如文档写“例外情况”,用户问“突发状况”,倒排索引可能完全召回不到。所以我一定会加一个兜底策略:当PageIndex的候选集合得分低于某个阈值时,自动切换到向量检索或者两者融合。
我推荐的做法不是一股脑地混合所有结果,而是加一个“检索路由”层:在请求进来的时候,先用一个轻量分类器判断问题类型。如果问题里带明显的数字、日期、型号、枚举值,就优先走PageIndex;如果问题比较开放、更像“给我讲讲某个概念”,就优先走向量检索。这个路由不用太复杂,简单的基于正则和词典的规则就能覆盖大部分情况。
4. 实测结果:同一批文档、同一批问题,两类检索方式的差距有多大?
4.1 测试集构造方式
为了验证PageIndex到底是不是银弹,我建了一个内部测试集。语料是200份正式文档,内容包括产品手册、项目案例、财务制度、故障排除指南,解析之后得到约2000个逻辑页。然后整理了50个真实员工会问的问题,按类型分成五类,每类10个:
- 精确属性查询:问负责人、截止时间、审批人;
- 数值范围查询:问金额区间、天数、人数门槛;
- 多条件组合查询:同时限定年份、区域、类型、数值;
- 同义改写查询:用户用词和文档用词差别很大;
- 跨章节语义联想:答案分散在多个章节,需要综合理解。
“命中”的标准是:检索结果Top5里存在能直接支撑答案的页面,并且没有相互矛盾的信息。每个问题由两个同事独立标注,有分歧就讨论到一致。
4.2 对比结果:命中率与主观感受
跑下来之后,两张检索方式的差异非常清楚,我用一张表总结:
| 问题类型 | 向量检索命中率 | PageIndex命中率 | 说明 |
|---|---|---|---|
| 精确属性查询 | 40% | 92% | 向量经常抓到大段落,PageIndex能精准定位属性名 |
| 数值范围查询 | 20% | 86% | 表格字段加数值归一化是核心优势 |
| 多条件组合查询 | 35% | 82% | 布尔逻辑求交明显优于语义相似度 |
| 同义改写查询 | 75% | 68% | 倒排索引对措辞变化敏感,向量模型反而稳 |
| 跨章节语义联想 | 80% | 45% | PageIndex容易碎岛,向量能跨块聚合 |
看到第二行20%对86%,我一开始不敢相信,后来仔细复盘发现,向量检索在“数值范围查询”上天生劣势,它只能匹配“客户”“合同”这些词,根本没有“大于500万”这种符号运算能力。而PageIndex把金额统一解析成数值字段,再用区间检索一次定位,这种优势是结构性的。
4.3 为什么PageIndex会赢/输:两个典型case详细复盘
赢的案例是这样的:用户问“南部大区2022年合同额超过500万的客户有哪几个”。向量检索返回了一堆“客户案例”页面,每篇都谈到了某客户和合作效果,但没有任何一页同时满足“南部大区”“2022”“合同额超过500万”三个条件。PageIndex解析出三个条件后,直接去表格字段里做区间筛选,定位到一份客户合同汇总表,答案又准又快。
输的案例也很有代表性:用户问“制度里提到特殊情况一般怎么处理”。文档里大量使用“例外情况”“突发情形”,正文很少出现“特殊情况”四个字。PageIndex只能按字面匹配,召回率掉得厉害;向量检索反而能通过语义相似度把“例外情况”和“特殊情况”关联起来。这个案例让我明白:PageIndex对词表外表达几乎无能为力,同义词扩展表和NER兜底只能缓解,不能根治。
4.4 我的判断:它不是替代,而是理性骨料
做完这轮实测,我得出的结论是:PageIndex和向量检索不是替代关系,而是分工关系。企业文档问答里,大量问题本质是“查事实、找字段、满足条件”,这类问题用PageIndex做主力,命中率能提升一截,而且答案可解释、可追溯。另一些需要发散联想、概念理解的问题,向量检索依然更擅长。最好的落地方式是在路由层让两者各司其职,而不是盲目地二选一或者简单拼接。
5. 那些没有写在标题里的工程代价:分页粒度、同义词扩展与性能取舍
5.1 逻辑页粒度:粗了不准、细了碎
逻辑页切多细,直接决定整个系统的上限。我最早试过按物理页切,看起来方便,结果很多页面把两三个不相关的小节揉在一起,条件命中了,上下文却很杂乱。后来又试过按自然段切,页面碎成几百片,一个完整表格被拆得七零八落,连字段键都找不全。
折腾几次之后,我总结出一套相对稳的规则:优先按“标题链+主题相干”来聚合,一个逻辑页尽量只包含一个主题;表格、问答对、规范条目这类强结构化内容单独成页;正文中的连续多个自然段如果属于同一个小节,就合成一页,不要硬拆。这套规则没法完全自动化,需要针对每类文档写适配脚本,前期成本不小,但值得。
5.2 同义词与错别字的妥协:扩展表与繁简归一
同义词是PageIndex的第一大天敌。用户说“员工”文档写“职员”,用户说“甲方”文档写“委托方”,倒排索引直接失配。我不得不维护一张领域同义词表,把高频业务上的等价说法都映射到一个规范词上。这个表不用一开始做全,每次从线上日志里捞漏检问题,发现一个补一个,三个月下来就能覆盖大部分高频场景。
错别字和扫描件噪声又是另一层麻烦。我的做法是在tokenize阶段做编辑距离容错,但只对长度超过4的词启用,否则会产生大量误匹配。还有一个容易被忽略的细节是数字格式:用户写“5百万”,文档写“500万”,解析器必须统一换算成纯数字字段,不然区间查询永远是空的。
5.3 索引更新与性能取舍:增量重建的必要性
企业内部文档不是静态的,制度会改版,项目案例会新增。如果每次都全量重建索引,几百份文档还好说,几千份就很痛苦。我后来做了增量重建:监控文件变更,只重新解析变更的文档,删除旧的逻辑页索引,再插入新页。实测下来,增量重建的时间能压缩到全量重建的十分之一以下,基本可以做到分钟级更新。
查询侧也要注意性能。复杂条件组合的求交运算会随着逻辑页数量线性增长,到十万量级时单次查询可能超过百毫秒。我的做法是对高频条件组合加查询结果缓存,同时把倒排表的posting list按page_id排序存储,求交时用跳表或者双指针,明显快得多。另一个点是要区分“常驻热数据”和“归档冷数据”,冷文档可以放到单独索引里按需加载。
5.4 和向量混合时的权限控制与安全边界
最后提醒一个很容易被忽视的问题:权限过滤必须在索引查询层完成,不能等检索结果出来之后再做后续过滤。如果用户没有权限查看某些文档,他的查询条件从一开始就应该把不可见的文档ID排除掉,这样才能保证召回结果里根本不会出现越权内容。向量混合路由也一样,不管走PageIndex还是向量召回,都要先按权限过滤再进入后续流程。很多项目在检索层做得很漂亮,最后栽在权限一致性上,这个坑我建议你提前留好。
把这套方案沉淀下来之后,我最大的体会是:检索的本质是定位,定位越精确,生成越靠谱。向量检索本身没有问题,错误的是我们在很多结构化企业知识场景里,把一个擅长联想的工具当成了唯一的答案来源。如果你现在的RAG经常出现“相关废话”,先别急着调prompt,回去看一眼你的检索层到底是怎么工作的。也许你也需要一个像PageIndex这样的模块,只负责把条件类问题“锁”到正确的位置,剩下的发散、总结、表达交给生成模型去发挥。无论你的索引结构具体怎么设计,核心思路都是一样的:让每一次检索都有据可查,让每一次生成都不再建立在一堆“看起来相关”的模糊记忆上。