AI Warning
本文由人类提供大纲,AI 辅助编写完成。调了几次,但是总的来看效果比较差。对于部分地方可以略过。
背景
最近在做一个叫 Later 的个人知识库,主要是把论文、博客和自己的笔记存下来,用来做主题阅读和问答。我会在想弄懂一个问题的时候集中读几篇相关资料,记录其中有用的内容,最后希望能组织成一篇博客;但随着资料越来越多,很多时候我只记得自己读过某个观点,却已经想不起来它出自哪里,更不用说把几篇文章里的论述放在一起比较了。
比如说「Dynamo 如何处理版本冲突」,直接问 LLM,大概率能得到一段关于 vector clock 的解释,但我想知道的还包括这个解释出自哪篇文章、原文是怎么说的,以及回答有没有把某些前提省略掉。因此 Later 需要把回答和原文关联起来,让我能直接打开支撑某个结论的段落;如果来源是 PDF,还要能跳回原文件里的位置,否则仍然得自己从几十页里重新找一遍。这个项目的主要诉求也就是「从原文回答,回到原文」。
另外,这个知识库预期会用很久,短期可能只有十几篇论文,长期则会逐渐积累书籍、项目文档、网页和笔记,大致可以理解成一个长期的 memory dump。因此除了回答质量,每次查询的成本、等待时间,以及半年后我能不能重新理解和维护这套系统,也会影响技术上的选择。
本文主要讨论其中的检索、上下文组织和引用,假定读者有一定计算机基础,不需要事先了解 RAG。
RAG
RAG,Retrieval Augmented Generation,检索增强生成,做的事情是把检索到的资料放进 LLM 的 context,让模型结合这些资料回答问题。为了找到文章里的具体内容,通常会把文档切成较小的片段,也就是 chunk,再为这些片段建立索引;问题进来以后,系统负责搜索候选、筛选和组织上下文,模型则根据这些输入生成回答。Later 的整体结构如下,BM25 和 Vector Search 负责两种不同的检索方式,RRF 用来合并它们的排名,Jev 则进一步判断候选片段是否有用。
flowchart TD
A["网页 / PDF / 笔记"] --> B["解析正文,保留原始资料与位置"]
B --> C["切片、生成摘要、提取实体"]
C --> D["全文索引 + 向量索引"]
Q["用户问题"] --> R["BM25 + Vector Search"]
D --> R
R --> F["RRF 合并候选片段"]
F --> J["Jev 筛选"]
J --> E["组装原文段落与来源编号"]
B -. "取回原文" .-> E
E --> L["LLM 生成带引用的回答"]在这种结构下,检索和生成之间的关系很重要:模型能利用哪些材料,取决于前面实际送进来的内容,而材料即使找对了,也可能在切片、筛选或者上下文组装时丢失信息。对于我这个需要查证原文的场景,除了检查最终的回答,还需要能够看到它依据了哪些段落,以及这些段落为什么会被选进来,因此可观测性在本系统里其实特别的重要。
混合检索
BM25
如果已经知道自己要找的概念叫 vector clock,最直接的做法就是搜索这个词,但全文搜索还需要决定命中的文章怎样排序。同一个词在十篇文章里都出现过,其中一篇只是顺带提到,另一篇则用整个章节解释它,这两篇的相关程度显然不一样;如果搜索的是 the 这种几乎到处都有的词,它所提供的区分信息又会很有限。
TF-IDF 就是在描述这种关系:一个词在某篇文档里频繁出现,在其他文档里却很少出现,它就更能代表这篇文档。 Term Frequency 对应当前文档里的词频,Inverse Document Frequency 则考虑这个词在整个语料库里有多稀有。BM25 可以沿着这个直觉来理解,只是还需要考虑词频的收益递减,以及文档长度对词频的影响。一个词出现一次和五次可能有很大差别,出现一百次和一百零一次就未必如此;而一本书天然比一篇短文更容易重复某个词,因此也需要做长度调整。
下面是一种常见的 BM25 写法,其中 是词频, 是文档长度, 是语料的平均文档长度, 和 分别控制词频的饱和与长度调整。对查询里的词分别计算,再把贡献加起来,就得到文档的排序分数。

具体 IDF 写法在不同实现里会有差异,完整的概率模型推导可以看 BM25 原始综述 的 §3.4–3.5。这里需要记住的是,分数服务于排序,不能直接读成「有多少概率可以回答问题」;而且公式的输入已经是处理好的词项,至于原始文本怎样变成这些词项,还涉及分词、词形处理等问题。
中文就很容易遇到这一层的问题,比如「分布式数据库」究竟要切成哪些词,需要分词器来决定,ICU、jieba 等工具都可能用在这里。如果索引和查询的处理方式对不上,同一个概念也可能无法匹配。英文虽然可以从空格和标点开始拆,但同样会遇到单复数、词形变化和拼写错误,可以分别考虑 stemming、fuzzy matching 等处理;这些选择会影响检索结果,也需要和 BM25 的排序公式分开理解。
Vector Search
关键词匹配的前提是词能对上,但我用中文问「两个副本同时修改,冲突怎么解决」,原文可能用英文讨论版本分支和应用层 reconciliation,即使是在回答同一个问题,字面上也未必有多少交集。因此需要另一种表示文本的方式,让查询能够匹配意思相关、措辞却不同的材料,这就是 Embedding 在这里的用途。
Embedding 模型把文本转换成固定长度的向量,问题也转换到相应的向量空间,再通过相似度找出候选。常用的余弦相似度是在比较两个向量的方向,如果向量已经做了单位长度归一化,就可以直接用点积计算:


向量的距离能够反映文本关系,是模型训练的结果。以检索任务为例,训练数据会提供问题、相关文档和不相关文档,通过训练目标让相关组合获得更高的相似度;预训练提供的文本表示也会在这个过程中继续调整。Qwen3 Embedding 的论文 介绍了它使用的对比学习目标,以及在基础模型之上训练检索表示的过程。
至于可变长度的文本怎样变成固定长度向量,需要看具体模型采用的表示方式。有些模型会对 token 的表示取平均,Qwen3 Embedding 则使用末尾 [EOS] token 的最后一层隐藏状态,因为隐藏状态的维度固定,所以最终向量的长度也是固定的。向量的维度也取决于具体模型,例如 Qwen3-Embedding-8B 的输出维度最高为 4096,并支持较低的维度。
RRF
BM25 对明确的术语和字面匹配很有用,向量检索则能补充另一种召回方式,因此 Later 同时运行两路检索,各取 Top 50,再合并成 Top 30 的候选片段。这里的麻烦是两种分数没有统一的刻度:BM25 的 12 分和余弦相似度的 0.8 不是可以直接比较的数,把它们相加也很难解释究竟偏向了哪一路。
RRF,Reciprocal Rank Fusion,提供了一种只依赖排名的合并方式。对一个候选片段,取它在各路结果中的排名,分别计算 再求和,其中 表示包含片段 的结果列表,排名从 1 开始,没有出现在某一路中就不贡献那一项。 用于缓和最前面几个名次的差异,原论文 的实验采用了 60。
例如令 ,A 在 BM25 中排第一、向量检索中排第三十,B 在两路中都排第八,计算结果如下。这个例子里 B 最后排在前面,说明一个在两路中都有不错排名的片段,可以超过某一路特别靠前、另一路较靠后的片段,而整个计算不需要先对齐原始分数的数值范围。相当于合并了两路查询。
| 片段 | BM25 排名 | 向量排名 | RRF 分数 |
|---|---|---|---|
| A | 1 | 30 | 约 0.02750 |
| B | 8 | 8 | 约 0.02941 |
Aside: 我曾经让 AI 给 BM25 增加权重,结果它通过重复结果来实现加权。。。
向量索引的取舍
向量检索还涉及两个很实际的问题:存储和速度。假设一个向量有 4096 维,每一维用 float32,单个向量就是 bytes,也就是 16 KiB;一百万个这样的向量,光原始数值就需要约 15.3 GiB,还没有计算文本、索引和其他元数据。查询时如果逐个计算问题向量与所有文档向量的相似度,数据越多,需要扫描和计算的内容也越多,因此我们会考虑 Approximate Nearest Neighbor(近似最近邻搜索,ANN),允许漏掉部分精确近邻,以减少查询时需要检查的候选数量。
HNSW(Hierarchical Navigable Small World)的做法是把向量组织成分层的图,搜索从比较稀疏的上层开始,通过图上的连接逐步接近查询向量,再进入更密的下层细找。它可以类比成先沿着主干道走到附近,再进入小路,不过实际算法还会维护候选集合,不能只理解成每次选一条最近的边走下去。这样的结构需要额外保存图的连接关系,具体的构建和搜索方式可以看 HNSW 论文。

IVF(Inverted File Index)则把向量分到不同区域,每个区域有一个代表性的中心,查询时先找接近的几个区域,再检查里面的向量。这样一来,访问多少个区域就成为速度和召回之间的一个调节参数:区域选得少,需要搜索的范围就小,但可能漏掉邻近区域里的结果;扩大范围能够减少这类遗漏,同时也增加计算量。Faiss 的 IVF 说明 用一个实验展示了这种关系。从 locality 的角度看,IVF 在分区内集中扫描数据比较容易理解,但它和 HNSW 谁更快,还取决于数据分布、硬件、参数,以及要求达到的召回率。

索引减少的是搜索范围,而 Quantization 还可以减少每个向量的表示成本。Product Quantization(PQ)会把向量拆成多个子向量,为每一部分训练一组代表值,也就是 codebook,存储时只保存对应的代表值编号。例如每部分有 256 个代表值,就可以用 8 bit 保存一个编号,原本的一组浮点数因此变成一个较短的编码,查询时也利用这些编码近似计算距离,当然 codebook 本身仍然需要存储。Faiss 的 PQ 介绍 展示了这种压缩对存储和距离计算的影响。
Later 采用的 IVF-PQ 就是把分区搜索和向量压缩结合起来,在资源占用、检索时间和召回之间做取舍。PQ 有点像 JPEG 的有损压缩,用信息损失换取空间,只是这里关心的效果变成了候选顺序是否受到影响、需要的资料有没有因此漏掉。最终要接受多大的损失,需要放到实际查询里检查,单看向量压缩了多少还不足以判断它是否适合这个知识库。
Jev 和证据筛选
检索的相关程度和回答所需的信息之间仍然有距离,比如一篇大量讨论 Dynamo 架构的文章,可能和问题在词汇、语义上都很接近,却没有解释冲突究竟怎样解决。因此,即使已经合并出一份候选列表,仍然需要判断每个片段对当前问题是否有用。Reranker 通常处理的就是这一层问题,其中常见的 cross-encoder 会把问题和候选一起送进模型,直接判断这一对文本的相关性;因为它需要在线处理这些组合,所以一般用于已经缩小的候选集。
Later 在这里使用 Jev,从候选中筛选对应的原文段落,再交给生成回答的 LLM。Jev 是 TypeSafe 的 System One 模型,可以理解自然语言输入,但输出的类型和可选范围由程序定义,例如从给定选项里选择、按指定标准打分,或者判断一个命题是否成立,同时返回概率。因此可以把这里需要的判断表达成下面这种形式,模型只需判断候选是否包含有用信息,原文的读取、段落合并和来源关联则由代码完成。
问题:Dynamo 如何处理版本冲突?
候选:文档标题、章节路径、原文片段
判断:这段内容是否包含回答该问题需要的信息?这也是我觉得 Jev 很像一个接口的原因:程序给出内容和判断标准,拿回能够直接处理的值,就可以继续按照已有逻辑执行。语义判断有了明确的输入和输出以后,周围的代码也更容易理解。不过类型受限只能约束输出形式,不能保证判断正确,官方也明确说明概率校准是在一组预测上衡量的,不代表单次回答一定可靠。因此评测这个阶段时,既要看它去掉了多少无关内容,也要看有没有把关键证据删掉,否则 context 虽然短了,回答所需的信息也可能一起没了。
这种遗漏在筛选发生得更早时会更加明显。我最开始设计过 Topic,想先根据问题选择主题,再搜索主题里的文档,这样看上去可以省掉很多不相关的检索;但主题选错,或者一篇文章里恰好有一小段跨主题的内容,就可能在检索开始之前把相关资料排除掉。后续排序无论做得多好,都只能在剩下的集合里选择,benchmark 下来召回不好,也就放弃了这条提前筛选的路径。它的问题在于分类承担了限制搜索范围的职责,分类错误会直接变成后面无法补回的召回损失。
切片和引用的粒度
搜索片段的大小会同时影响检索和引用。如果把整本书压成一个 embedding,得到的表示很难突出其中某一小段关于 vector clock 的内容;但如果切得非常碎,留下的可能只是「这个问题可以通过上述方法解决」,脱离上下文又不知道在说什么。因此切片需要兼顾长度与内容完整性,尽量保留自然段和章节关系,而搜索时用于定位的片段,与最后交给模型、展示给人的原文,不一定要采用相同的粒度。
Later 对 PDF 使用 PaddleOCR 解析,同时保留原文件和 bbox,也就是文本在页面上的位置,这样切出的片段还能对应回原始资料。入库时生成的摘要和提取的实体,则提供了另外一些组织与查找内容的方式,例如用较短的文字概括文章、把 Dynamo 和 vector clock 这些具体概念提取出来。由于这些都是从原文派生的表示,可能丢失细节或提取错误,最终引用的证据仍然要能对应到原文,不能只有一段摘要和一个看上去真实的来源编号。
上下文组装时也需要保留这种对应关系。一段解释的前提可能在上一段,例外可能在下一段,如果只取检索命中的几句话,模型读到的含义就可能已经发生变化,所以需要补齐必要的自然段,把同一篇文章的相关内容尽量按原文顺序组织,同时合并重叠片段带来的重复内容。我遇到过 reranker 漏掉相关材料,也遇到过筛选之后 context 组装又漏了一段,最后都表现成回答不完整,但实际问题在生成之前就已经发生了。
引用本身则要兼顾定位与阅读。可以给原文段落分配编号,让回答通过编号关联到原始资料,再利用 bbox 跳回 PDF 的对应位置;但编号有效只能证明引用的片段存在,片段是否支持回答里的结论,仍然需要检查内容。如果一句回答附上两页原文,即使出处没有错,核查的人还是得重新找一遍,因此通常需要把长引用拆成段落,同时保留影响解释的上下文。对我来说,这部分节省的是验证答案的时间,也是这个系统值得做的原因。
Context Engineering
Context window 的容量和模型能有效利用哪些内容是两个相关的问题:材料越多,输入成本和处理时间通常也会增加,而那些重复、错误或与问题无关的内容,并不会因为塞进窗口就变得有用。因此在控制 token 用量之前,首先需要处理的是内容本身,包括筛掉无用片段、合并重复段落,以及减少 OCR 带来的重复页眉、错误断行和乱码,这些处理也会影响模型最终读到的文本质量。
AI 最开始为了精确计数,下载了模型对应的 tokenizer,结果切换模型时又要维护这一套逻辑;但对于这里的材料选择预算,真正的要求是把输入规模控制在可接受范围,并给问题、指令和输出留出余量,近似估算就已经足够。
对于重复请求,还可以利用 Prefix Cache 来省掉部分重复计算:如果推理服务能够复用相同前缀的 KV cache,就不必重新处理那部分输入,因此稳定的规则可以放在前面,变化的问题和材料放在后面。具体能否命中取决于实际输入与服务实现,而且这类缓存主要减少 prefill 的工作,生成新 token 的 decoding 仍然需要执行,vLLM 的说明 对这两部分有明确区分。这也意味着评价优化效果时,开始输出要等多久、完整回答需要多久,应该分开记录。
Benchmark 和 Observability
RAG 的评测比较容易被最终回答的流畅程度带偏,因为模型即使没有拿到足够的资料,也可能组织出一段看起来完整的文字。要判断系统是否做对,需要知道问题的答案应该出现在哪些资料里,再检查这些材料有没有被找出来、有没有被筛掉,以及最终引用是否真的支持结论。如果正确片段没有进入候选,应该查检索;如果进入候选却没有出现在 prompt 里,应该查筛选和组装;只有证据已经完整送进去之后,才适合继续分析模型怎样理解和使用了它。
查询也需要覆盖实际使用中的差异,例如 Leiden community algorithm 这样的完整术语、同一问题的不同说法、中文问题对应英文资料,以及库里根本没有答案的问题。比较 BM25、向量检索和混合检索时,应尽量固定问题和候选数量,否则同时调整切片、模型与筛选规则,即使最终效果变好了,也很难说明是哪个改动带来的收益。对个人知识库来说,用自己真正关心、也知道原文位置的问题检查这些变化,会比只看几条漂亮的回答更有用。
Observability 提供的就是上述判断需要的中间信息,包括每一路检索返回了什么、筛选保留了什么、实际发送的 prompt,以及各阶段的耗时和 token 用量。只记录最终回答会丢失大部分排查依据,而 prompt 本身也需要像其他逻辑一样检查:每遇到一个失败案例就追加一条规则,时间久了可能已经不知道哪条有效、哪几条互相冲突,所以调整 prompt 之后也应该回到那些代表性问题上看结果。
性能同样需要明确口径。早期版本的等待时间到了几十秒量级,引入 Jev 并做其他优化之后,记录过约 3 秒开始流式输出,但这个数字描述的是当时条件下的开始输出时间,不能据此推断完整回答耗时或每项优化的贡献。入库时的解析、摘要和 embedding 可以离线处理,有些工作可以 batching 来提高吞吐,在线问答则更在意一次请求要等多久;如果把这些指标混在一起,很容易优化了一个数字,却没有改善实际使用体验。
长期维护的成本
Later 做过两遍,第一遍基本是完全 vibe,生成代码很快,但也留下了过于复杂的设计和很多防御性逻辑,数据库一开始有十多张表,重新梳理需求以后最后收敛到三张。对这种准备长期使用的个人项目来说,多出来的结构不只增加当前的代码量,还意味着以后修改需求、替换模型,或者隔半年重新理解系统时,都要继续承担它的维护成本。
前面的 token 预算就是一个例子:允许近似之后,可以省掉一套与模型强绑定的计数逻辑;其他地方也可以接受某些 breaking change、简化迁移过程,或者重新生成派生索引。这些取舍需要以原始资料不丢失为前提,而兼容哪些历史实现,则可以根据实际需求决定。哪些要求可以放松、哪些错误不能接受,需要在设计时讲清楚,否则 AI 很可能把每一种可能性都当成需要处理的需求,再给这些需求补上更多边界逻辑。
所以我更在意向 AI 解释为什么需要一个功能、它服务什么场景,以及最重要的约束是什么,同时自己仍然要理解整个系统,才能判断新增的代码有没有解决实际问题。后续还想往辅助主题阅读和学习的方向继续做,但在当前这个阶段,我会更愿意按半年后自己重新理解它的成本来设计,让资料逐渐积累的同时,这套系统仍然能够被自己维护。