cover
cover

上一篇文章主要写了数据面和 Context 相关的内容和知识结构,是一篇知识串联型文章:

https://x.com/i/status/2075171293029179866

这一篇以项目实战为主,为了深刻理解底层运作原理,我做了一个实际项目 + Notebook 配套实战课程,完整的演示了一次 RAG 的搭建。并开源到了 github:

https://github.com/mate-matt/rag-memory-lab

项目选择了 OpenAI Cookbook 公开 Markdown 作为原始文档:15 篇文档,真实切分后得到 210 个 Chunk。数据量不大,但足以在 Notebook 里观察每一步每个 Notebook 只新增一个关键能力,上一课产生的概念和数据,正好成为下一课的输入。

关注我,我会持续更新 Agent / Context 架构系列文章。

一、先把 Markdown 切成“可检索的 chunks”

本章节对应 Notebook 第一课内容。

文档切分,就是把海量的 md/pdf 等文档拆分为若干 chunks,然后提前建好索引,数据库。方便后续检索。

一般一个 .md 文件是 Document,但检索的最小单位通常是 Chunk。直接拿整篇文档检索,信息太粗;切得过碎,意思又会断掉,所以 chunk 切分也有讲究。

Chunk 切分有很多种,业界没有唯一标准,实际工程中最常见的不是单一算法,需要根据实际情况考虑。最常见的方案是:结构感知切分 + 长度限制兜底;这样不容易出现某个 chunk 切分不合理导致的语义不完整。

例如:“...今天的会议我没有记录摘要...” 如果随意切分,可能出现:

chunks 1: ......今天的会议我没 chunks 2: 有记录摘要....

如果 chunk 2 被检索到,就会造成语义反转。所以一般采用结构感知切分,即保证每个 chunk 都是结构完整的段落或者句子。

切分工具也有很多,当前项目中用的是 LlamaIndex 这个 Python 框架,项目代码中的实际切分实现:

  1. MarkdownNodeParser 先按标题层级保留语义边界;
  2. SentenceSplitter 再处理过长段落;
  3. 每个 Chunk 保留 chunk_id、document_id、source_path、heading_path、正文与长度。

整个处理过程:

原始 Markdown ↓ 文档结构解析 ↓ Document 元数据 ↓ Chunk 切分 ↓ 质量检查 ↓ 稳定的 chunks.jsonl

这一阶段最终产物不是向量,而是一堆 chunks jsonl 文件:

json
{
  "chunk_id": "chk_001",
  "source_path": "articles/openai-harmony.md",
  "heading_path": ["OpenAI Harmony", "Tool calls"],
  "content": "...",
  "start_line": 48,
  "end_line": 76
}

如果原始文档是 pdf/docs 类型文档,则需要其他能处理该类型文档的工具,例如:Unstructured、Docling、Apache Tika 等。文章结尾的索引部分会统一整理常用的工具。

构建好文档结构和 chunks 后,后续的数据库倒排索引建立和向量索引建立都是在 chunks 基础上进行的。

二、FTS5、建立倒排索引、BM25 使用(不是向量检索)

章节对应 Notebook 第二课内容。

FTS5 是 SQLite 的全文检索模块,除了 SQLite ,其他数据库也有 FTS5 模块。FTS5 走的是词法 / 统计检索路线,不主动理解语义。可以简单的理解为关键词检索,要和向量检索区分开。

数据库写入阶段,FTS5 主要的工作流程:

文本 -> tokenizer 分词 -> 建倒排索引

例如某个 chunk 块内容为:

"Tools allow models to call external functions."

FTS5 会先将 chunk 经过 token 处理,处理后大致为:

tools allow models to call external functions

token 通常接近单词,但具体规则由 tokenizer 决定:它会处理空格、标点、大小写、Unicode 字符等。

然后,FTS5 会建立类似这样的倒排索引:

plain text
tools      → [Chunk 7, Chunk 22]
call       → [Chunk 7, Chunk 41]
external   → [Chunk 7]
functions  → [Chunk 7, Chunk 22]

所谓倒排索引,其实就是把 token 作为 key,chunks 数组作为 value 的表,编程里通常叫字典。倒排索引表中,通常还会有词频统计。在 Notebook 关联的课程中,读者可以运行程序,就会看到倒排表的真实数据展示:

BM25:基于词频、IDF、长度的排序模型

BM25 是个打分机制,在数据召回阶段,根据用户 query 从倒排索引中 match 到数据后,FTS5 会对每条数据进行 BM25 打分,BM25 打分建立在 TF-IDF 机制之上;

TF-IDF 是一种词项加权方法,通常建立在词袋表示之上: TF-IDF = TF × IDF。 TF:Term Frequency 词在当前文档中出现的频率。某词在当前文档出现越多,TF 越高。 IDF:Inverse Document Frequency 词在整个文档集合中的稀有程度。出现在很多文档中的词,IDF 较低,只出现在少数文档中的词,IDF 较高。一般建库阶段,就会完成对 TF-IDF 的计算。

Notebook 的课程中有实际查询后的 BM25 打分的例子:

注意 FTS5 内置 BM25() 的排序方向:它返回的分数通常是负数,越小越相关,所以代码使用 ORDER BY bm25_score。这和许多“分数越大越好”的接口正好相反,也是 Notebook 特意展示的细节。

另外项目中普通 SQLite 表 chunks 负责存放完整元数据;FTS5 虚拟表 chunks_fts 负责建立全文索引。两者不是竞争关系:前者是事实记录,后者是高效搜索结构。

三、向量检索 \ Embedding

章节对应 Notebook 第三课 内容

前一节中的 FTS5 检索是关键词检索,工作在词项空间 (lexical / token space)中:文本经 tokenizer 变成 token,再通过倒排索引和 BM25 进行匹配与排序。

向量检索则工作在稠密向量空间 (dense vector space) 中:文本先由 Embedding 模型转换为数值向量,再通过点积或余弦相似度寻找语义接近的 Chunk。

离线建库阶段,先对每个 Chunk 使用 Embedding 模型生成文档向量,并保存到本地向量检索结构中:

plain text
Chunk
  → Embedding 模型
  → dense vectors
  ├─ NumPy:直接精确点积搜索,用于教学观察
  └─ Milvus Lite:持久化本地向量库

在线查询阶段,用户 Query 才被即时转换为查询向量,再与已保存的文档向量进行相似度计算:

plain text
用户 Query
  → 同一个 Embedding 模型
  → Query 向量
  → 与已保存的 Chunk 向量计算相似度
  → 返回 Top-K

Notebook 示例项目使用 Sentence Transformers 框架加载本地 Embedding 模型 paraphrase-multilingual-MiniLM-L12-v2,将文本转换为 384 维稠密向量。

例如,用当前项目的真实模型,对这句话:

How do I run gpt-oss locally with Ollama?

编码后,得到的是一个 384 维 float32 数组:

plain text
[
  0.021111, -0.018984,  0.083323, -0.019714,
 -0.073149, -0.006974, -0.097372,  0.019315,
  0.001029,  0.004076,  0.010035,  0.051506,
 -0.038331, -0.018693,  0.045456, -0.060644,
  ... 共 384 个数字 ...
  -0.003398, -0.111640, -0.047939,  0.050816,
  -0.118925,  0.025786, -0.019816, -0.038646
]

向量检索部分并行采用两种实现:NumPy 用于在 Notebook 中直观看到归一化向量点积和精确 Top-K;Milvus Lite 用作可持久化的本地向量数据库。

NumPy 版本用全量矩阵点积做精确搜索,可以直观看到向量检索到底在算什么;Milvus Lite 则把相同向量持久化为本地向量库。当前项目数据只有 210 个 Chunk,因此 Milvus Lite 使用 FLAT 精确索引,而不是为了演示而过早引入 HNSW 近似检索。

如何直观理解向量索引的点乘:

https://x.com/i/status/2074479762257682917

阶段总结

文章到目前为止,基本完成了前置工作准备阶段,如果觉得概念多,可以直接保存下图:

对应的是 Notebook 中的前 3 课。整体可以总结为:

1、md 文档 chunks 切分 2、基于 chunks 建立 FTS5 数据库 (倒排索引,词项空间,词频统计) 3、基于 chunks ,使用 Embedding 模型建立向量数据库

现代 RAG 系统的索引,一般都需要采用这种混合索引机制:

层次关键词检索路线 (FTS5)向量检索路线
检索范式词法 / 统计检索语义 / 表征学习检索
匹配对象查询词与原文词项Query 与 Chunk 的 Embedding
核心能力精确词、术语、代码名同义表达、语义接近
本项目实现FTS5 + 倒排索引 + BM25Embedding + NumPy / Milvus Lite
是否天然理解语义在模型学习到的范围内,能一定程度理解

两个维度,一个关注关键词条的精准匹配的频率,一个关注语义相似性词条的检索。二者结合所以是常用的检索手段。

四、建立评测集,才谈得上“检索变好了”

章节对应 Notebook 第四课内容

检索系统不能只看几个主观案例。你必须准备一组 query,并人工标注每个 query 对应哪些 relevant_chunk_ids。这就是 Goldens,或者说 Ground Truth。也就是测评集,测评集不是面向用户的,而是面向开发者,维护人员,用来测试:混合检索的建立是否合理,如果不合理还可以根据测评集进行调整。

测评集虽然对用户不可见,但是对整个 RAG 系统至关重要。

常用的测评指标如下:

指标要回答的问题直觉
召回率 Recall@K已知的相关 Chunk,有多少出现在前 K?有没有漏掉重要材料
精确率 Precision@K前 K 个结果中,有多少真的相关?列表有多干净
命中率 Hit@K前 K 中是否至少有一条相关?用户能否找到入口
平均倒数排名 MRR第一条相关结果排第几?最有用的内容是否足够靠前

1、Recall@K:召回率

plain text
Recall@K = 前 K 个结果中命中的相关 Chunk 数
           ÷
           全部相关 Chunk 数

例子:

实际相关 chunk 总数:A、B、C = 3 Top-5 找到:[A, F, B, E],其中匹配的 A、B = 2 Recall@5 = 2 / 3 = 0.667 如果检索系统只返回了 A: Top-5 = [A, X, Y, Z, W] Recall@5 = 1 / 3 说明它漏掉了 B、C。

2、Precision@K:准确率 / 精确率

plain text
Precision@K = 前 K 个结果中命中的相关 Chunk 数
              ÷
              K

例子:

Top-5 = [X, A, Y, B, Z] 相关项:A、B = 2 Precision@5 = 2 / 5 = 0.4 即:用户看 5 条,只看到 2 条真正有用的,另外 3 条是噪声或弱相关内容。

3、Hit@K:命中率

前 K 条中,是否至少出现过一条正确结果。

例子:

Top-5 中有 A、B Hit@5 = 1 如果返回: Top-5 = [X, Y, Z, W, V] Hit@5 = 0

它很适合回答一个简单问题:用户有没有机会顺着搜索结果找到正确入口?

4、MRR (Mean Reciprocal Rank):平均倒数排名

例子:

Top-5 = [X, A, Y, B, Z] A 是第一条相关结果,排第 2 名 RR = 1 / 2 = 0.5

若正确结果在不同位置:

第一条相关结果的位置RR
第 1 名1.0
第 2 名0.5
第 3 名0.333
第 5 名0.2
没找到0

多条 Query 的 RR 再求平均,才叫 MRR。

Recall 看“漏没漏”;Precision 看“脏不脏”;Hit 看“有没有”;MRR 看“第一条对的来得够不够早”。

Notebook 第 4 课,有对应的示例运行案例展示:

五、用 RRF 对两种混合检索做召回互补

章节对应 Notebook 第五课内容

关键词检索与向量检索都已经能独立工作,但它们各自有盲区。关键词检索擅长“词是否精确出现”;向量检索擅长“意思是否相近”。两者分别从词法和语义两个角度召回,因此适合并行使用、互相补位。

例如,文档标题是:

How to run gpt-oss locally with Ollama

用户的两种问法:

用户 QueryBM25 表现向量检索表现
gpt-oss Ollama很强:两个精确 token 都命中通常也能找到
如何在自己电脑上启动这个开源模型?可能较弱:没有 gpt-oss、Ollama 等精确词通常更强:能理解“本机启动模型”的意图

反过来,用户若搜索:

function_tool

向量检索可能召回 “tool calling” “external tools” 等语义相近内容,但未必把包含精确符号 function_tool 的 Chunk 排在第一;此时 BM25 往往更可靠。

因此 RRF 混合召回的工作机制就是:

BM25:别漏掉用户明确说出的关键字。 Vector:别漏掉用户没有用原文措辞表达的意思。 RRF:让两边都认可的结果优先。

BM25 分数和余弦相似度的数值范围没有共同含义,不能简单相加。所以需要使用类似 RRF (Reciprocal Rank Fusion),只融合“名次”,不直接融合原始分数:

plain text
RRF(d) = Σ 1 / (k + rank_i(d))

这里 rank_i(d) 是某个 Chunk 在第 i 路检索中的排名,k 是平滑常数;项目用 k=60。一个结果若同时在 BM25 和向量列表中靠前,它会自然获得更高的 RRF 分数。不同模型、不同分数尺度的问题被绕开了。

真实评测的 Top-5 平均结果如下:

方法Recall@5Precision@5Hit@5MRR
BM250.88890.21670.91670.7222
向量检索0.77780.18330.83330.7778
RRF 混合检索0.88890.21670.91670.8194
RRF 是强而稳健的默认方案,不是理论上永远最优的方案。

必要知识补充总结

整个召回阶段,是个双塔模型(two-tower model)工作机制,或者叫 Bi-Encoder,Bi 指

Query 一路编码:Query ──→ Encoder ──→ Query 向量 (在线处理) Chunk 一路编码:Chunk ──→ Encoder ──→ Chunk 向量 (离线工作)

两条编码路径通常使用同一个模型权重,但它们可以独立运行,因此 Chunk 向量能提前建库。

BM25 关键词检索 + 向量检索,是两种不同的召回路线,叫作 Hybrid Retrieval / 混合检索:

plain text
┌─ BM25 关键词召回 ─┐
用户 Query ──────┤                    ├─ RRF 融合
                 └─ 向量语义召回 ────┘
                         ↑
                    Bi-Encoder
                 Query 与 Chunk 分别编码

六、重排 Reranker:不负责“找”,负责“精挑细选”

章节对应 Notebook 第六课内容

之前的召回阶段面对的是整个库,因此要足够快 (关键词检索与向量检索都很快,但不够精确);重排阶段只面对召回得到的 20 (示例项目中使用的召回数)个左右候选,因此可以更慢、更精确。这是检索系统里非常重要的“先广后精”分工。

示例项目先让 RRF 取 Top-20,再把每个 (query, chunk) 对送进 CrossEncoder 打分,最后保留 Top-5。这一过程就叫做重排(Reranker)。

Cross-Encoder 重排模型:

Cross-Encoder → 逐个评估 (Query, Chunk) 对 → 把 Top-20 精排成最终 Top-5

Cross-Encoder 的主要用途就是 Reranking / 重排。它会把 Query 和每个候选 Chunk 放在一起做联合理解;但很慢,不能拿去逐一比较全库几十万、几百万个 Chunk。所以只针对召回后的 Top-20 进行重排。

示例项目中 Cross-Encoder 使用的模型是:BAAI/bge-reranker-v2-m3;过程:

RRF Top-20 → BAAI/bge-reranker-v2-m3 → 最终 Top-5

最终拿到 Top-5 的重排数据,即可以返回给 Agent 的精确检索数据,但是也不是一定精确,所以不会直接把裸 Top-5 数据返回给 Agent,而是将相关检索证据和数据整理为结构化数据返回。

七、交付 Evidence Card

章节对应 Notebook 第七课内容

检索链路到重排结束时,得到的是一个排序列表。但交给上层应用时,更有价值的格式是 Evidence Card:它不仅告诉你“这段内容相关”,还说明“它来自哪、为什么排在这里”。

一张 Card 记录:

plain text
- `content`:实际命中的原始 Chunk;
- `source_path` 与 `heading_path`:回到原文的位置;
- `chunk_id`、`document_id`:稳定的关联标识;
- `rrf_score`、`reranker_score`:两阶段的分数;
- `bm25_rank`、`vector_rank`、`hybrid_rank`、`rerank_rank`:完整检索轨迹。

至此,一次完整的 RAG 数据检索才算完成。

开源实战项目地址:https://github.com/mate-matt/rag-memory-lab ,欢迎 start。关注我,我会持续更新系列文章。

附录 A:工具

类别本项目使用作用常见替代方案
Markdown 切分LlamaIndex标题感知与递归长度切分LangChain Text Splitters、按业务对象自定义规则
PDF / 复杂文档解析先将复杂版面抽取为有结构的文本Unstructured、Docling、Apache Tika
全文检索SQLite FTS5本地倒排索引、MATCH 查询、BM25 排序Elasticsearch / OpenSearch、Meilisearch
EmbeddingSentence Transformers在本地将文本转换为稠密向量FlagEmbedding、Ollama Embeddings、云端 Embedding API
向量检索Milvus Lite + NumPy本地持久向量库;在 Notebook 中观察精确余弦计算FAISS、Qdrant、Chroma、pgvector
重排Sentence Transformers CrossEncoder对 Query–Chunk 候选对进行本地精排FlagEmbedding Reranker、FlashRank、Cohere Rerank
检索评测自建 Goldens + 指标模块透明、可复现地计算 Recall / MRR 等指标DeepEval、Ragas、TruLens

Notebook 中优先选择的是:能本地运行、能在 Notebook 中观察内部过程、数据量增大后又可以替换成更专业组件的方案。

附录 B:术语、缩写与人话解释

基础数据与建库

术语 / 缩写英文全称或原词人话解释
RAGRetrieval-Augmented Generation先检索外部资料,再把资料交给上层回答或推理流程的架构。本文重点是其中的检索底座。
MarkdownMarkdown markup language用 #、列表、代码块等轻量标记写成的文档格式。
DocumentDocument一份原始资料;本项目中通常是一篇 .md 文件。
ChunkChunk由 Document 切出的可检索最小内容块。
MetadataMetadata描述内容的附加信息,如来源路径、标题层级、Chunk ID、长度。
JSONLJSON Lines一行一条 JSON 记录的文件格式;适合逐条写入和流式读取 Chunk。
CSVComma-Separated Values用逗号分隔的表格文本格式。
IndexingIndexing把原始文档预处理成可快速检索的索引结构的离线过程。
TokenToken检索或模型处理文本时使用的最小文本单位;对 FTS5 而言通常接近词。
TokenizerTokenizer把文本拆成 token 的规则或程序。
FTS5Full-Text Search 5SQLite 的全文检索模块,内部使用倒排索引。
Inverted Index倒排索引从“词”反查“哪些 Chunk 包含它”的索引结构。
SQLStructured Query Language与关系型数据库交互的查询语言。
BM25Best Matching 25(名称来源;通常直接称 BM25)使用词频、词的稀有度和 Chunk 长度,对关键词命中的候选进行排序的信息检索算法。
TFTerm Frequency某个词在当前 Chunk 中出现的次数或频率。
IDFInverse Document Frequency一个词在全库中的稀有程度;越少见,通常区分度越高。
TF-IDFTerm Frequency–Inverse Document Frequency将词在当前文档中的频率与它在全库的稀有程度结合的经典词项加权方法。
BoWBag of Words把文本视为“词的集合及其次数”、忽略词序的经典文本表示模型。|

向量检索与模型

术语 / 缩写英文全称或原词人话解释
EmbeddingEmbedding用机器学习模型将文本压缩成固定长度数字数组的过程与结果。
Dense Vector稠密向量大多数维度都有数值的向量;本项目每个文本是 384 个浮点数。
Sparse Representation稀疏表示大多数维度为 0 的词项表示;可用于理解 FTS5 的词法检索,但 FTS5 实际保存的是倒排索引。
Vector Database向量数据库专门保存向量并按相似度查找邻居的数据库或检索引擎。
Top-KTop K排名最靠前的 K 条结果;例如 Top-20 候选、Top-5 最终结果。
Cosine Similarity余弦相似度比较两个向量方向是否接近的指标;向量归一化后,等于两者点积。
NumPyNumerical PythonPython 数值计算库;本项目用它直观演示向量点积和精确 Top-K 搜索。
Milvus LiteMilvus Lite可嵌入本地文件的轻量向量数据库。
FLATFLAT exact vector search逐一比较所有向量的精确搜索方式;小数据量下简单且结果精确。
HNSWHierarchical Navigable Small World常见近似最近邻索引,适合大规模向量检索,以少量精度换速度和内存效率。
Bi-EncoderBi-Encoder / Two-Tower EncoderQuery 与 Chunk 分别编码成向量;快,可提前为 Chunk 建索引,适合召回。
Cross-EncoderCross-Encoder将 (Query, Chunk) 成对输入模型直接判断相关性;更准但更慢,适合重排。

召回、排序、评测与交付

术语 / 缩写英文全称或原词人话解释
RetrievalRetrieval根据用户 Query 从知识库中找出相关 Chunk 的过程,也常译为召回。
Keyword Retrieval关键词检索 / Lexical Retrieval基于 token 精确命中及统计权重的检索路线,如 FTS5 / BM25。
Vector Retrieval向量检索 / Semantic Retrieval基于 Query 与 Chunk 向量相似度的语义检索路线。
Hybrid Retrieval混合检索并行使用关键词检索与向量检索,再融合结果。
RRFReciprocal Rank Fusion只依据各路结果的名次进行融合的算法,不直接相加 BM25 与向量原始分数。
Reranking重排对已召回的小批候选进行更精细的相关性排序。
Golden / Ground TruthGoldens / Ground Truth人工标注的“这个 Query 对应哪些 Chunk 才算相关”的标准答案。
Recall@KRecall at K已知相关 Chunk 中,有多少出现在前 K 个结果里;看是否漏资料。
Precision@KPrecision at K前 K 个结果中有多少真的相关;看结果列表干不干净。
Hit@KHit Rate at K前 K 个中是否至少有一条相关;看用户是否至少找到入口。
RRReciprocal Rank第一条相关结果排名的倒数,例如排第 2 名则为 1/2。
MRRMean Reciprocal Rank多个 Query 的 RR 平均值;看第一条正确结果整体是否足够靠前。
Evidence CardEvidence Card最终交付的证据对象:原文 Chunk、来源、标题路径、分数与排名轨迹都在其中。
APIApplication Programming Interface程序之间调用能力的接口约定。