GangDan 的知识库是怎么炼成的

前情提要一下:

我写了一个完全离线的 AI 编程助手-纲担(GangDan),聊聊背后的设计思路

完全离线本地运行的参考文献重命名、格式转换、知识库构建与问答工作流-Chou+NuoYi+Gangdan 三个工具联合使用

GangDan(纲担)升级:开源本地知识库 + 支持本地模型和在线模型的深度研究助手 

之前写了一个GangDan,其实主要还是用来整理教学资料以及写文献综述用的,与其说是编程助手,不如说是备课助手。

这里面最主要的流程,其实是从原始文本构建出知识库来进行增强检索。

做 RAG 系统的人都知道,知识库的质量直接决定了最终回答的靠不靠谱。GangDan 这个项目把一堆散落在 GitHub 上的技术文档变成可以检索的向量知识库,中间经历了好几个步骤。这篇文章就顺着数据流动的方向,把每一步拆开来看。

整个流程大概是这样:先从官方网站或者 GitHub 上把原始文档拉下来,然后转成统一的 Markdown 格式,接着切成一块一块的,每一块送去生成向量,最后存进 ChromaDB 里等着被检索。

获取文档

GangDan 的文档来源都写在代码里的 DOC_SOURCES 字典中。每个来源对应一个名字和一组 GitHub 原始 URL。比如 numpy 这个来源,指向的就是 NumPy 官方文档仓库里几个 rst 文件的 raw 链接。

下载的过程其实挺朴素的。拿到 URL 列表之后,挨个发 HTTP GET 请求,把返回的内容写到本地。文件按照来源名字分目录存放,numpy 的文档就放在 DATA_DIR/docs/numpy/ 下面,pandas 的放在 DATA_DIR/docs/pandas/ 下面,互不干扰。

下载的时候会遇到不同格式的文件。GitHub 上的技术文档很少全是 Markdown,更多的是 reStructuredText(.rst),偶尔还有 Python 教程文件(.py)、HTML 页面甚至 C++ 源码。这些格式不能直接拿来用,需要统一处理。

rst 文件最简单,直接把后缀改成 .md 就行。Python 文件和 C++ 文件会被包装成带语言标记的代码块,前后加上 python 和 的标记。HTML 和 texi 文件也是改个后缀名。这样处理完之后,所有文件都变成了 Markdown 格式,后续处理就统一了。

把文档切成块

拿到 Markdown 文件之后,下一步是分块。这一步很关键,因为分块的大小和方式直接影响后面检索的效果。

GangDan 用的是最朴素的固定大小滑动窗口。代码在 doc_manager.py 的 _chunk_text 方法里,逻辑很简单:从文本开头取 800 个字符作为第一个块,然后往后退 150 个字符(这就是重叠部分),再取 800 个字符作为第二个块,如此循环直到文本末尾。

滑动窗口分块示意

800 个字符大概是 200 到 300 个汉字,或者 150 个英文单词。这个大小对于技术文档来说比较合适,既能容纳一个完整的概念说明,又不会太长导致语义分散。

150 个字符的重叠是有意设计的。想象一下,一个知识点的描述刚好横跨两个块的边界,如果没有重叠,检索的时候可能只拿到一半内容。有了重叠,相邻块之间共享了一部分上下文,跨边界的查询也能拿到完整信息。当然重叠不是越大越好,重叠太多会增加存储和计算成本,150 个字符大概是 800 的 19%,是个性价比不错的数字。

切完之后还会过滤掉太短的块。少于 50 个字符的块直接丢掉,这种大概率是格式转换留下的碎片,没什么检索价值。

生成向量

分好块之后,每一块都要变成一个向量,这样后面才能做相似度搜索。

GangDan 用的是 Ollama 提供的本地嵌入服务,默认模型是 nomic-embed-text。这个模型输出 768 维的浮点向量,对中英文都有不错的效果。

调用方式就是发一个 HTTP POST 请求到 http://localhost:11434/api/embeddings,body 里带上模型名字和要嵌入的文本。返回的 JSON 里有一个 embedding 字段,就是 768 维的向量。

这里有个细节值得注意。虽然分块大小是 800 字符,但实际送去嵌入的文本会被截断到 500 字符。这是因为大多数嵌入模型对输入长度有限制,而且对于技术文档来说,一个块的前 500 字符通常已经包含了核心语义。截断带来的精度损失很小,但能明显降低 API 延迟。

每个块生成向量之后,还会附带一些元数据。包括来源名字(比如 numpy)、原始文件名、块的序号、以及检测到的语言。这些信息在检索的时候可以用来做过滤和溯源。块的唯一 ID 用的是 MD5 哈希,由文件名和块序号拼接后计算得出,保证不重复的同时还能追溯来源。

存进 ChromaDB

向量生成好之后,下一步就是持久化存储。GangDan 选的是 ChromaDB,一个 Python 原生的向量数据库。

ChromaDB 的用法很直接。每个文档来源创建一个独立的集合(collection),集合名就是来源名字。创建集合的时候指定使用余弦相似度作为距离度量,底层用的是 HNSW 索引。

collection = client.get_or_create_collection(  
    name=name,  
    metadata={"hnsw:space": "cosine"}  
)

HNSW 是 Approximate Nearest Neighbor 搜索的经典算法,搜索复杂度是对数级别的。在 GangDan 的规模下,几千到几万条向量的检索都能在几十毫秒内完成。

余弦相似度衡量的是两个向量方向的接近程度,不受向量长度影响。对于文本嵌入来说,这比欧氏距离更合理,因为文本的语义主要编码在方向上而不是模长上。

所有块和对应的向量、元数据、ID 一起通过 add_documents 方法写入集合。写入之后数据就持久化到磁盘上了,下次启动可以直接加载,不需要重新生成。

ChromaDB 还有一个实用的功能,就是数据库损坏时自动恢复。如果检测到数据文件损坏,它会先把旧数据备份到一个带时间戳的目录,然后创建一个新的干净数据库。这个机制在生产环境里很实用,避免了因为一次异常导致整个知识库报废。

检索的时候发生了什么

用户问一个问题的时候,系统需要把这个问题和知识库里已有的内容做匹配。匹配的过程和构建知识库的过程类似,只是方向反过来。

检索流程

先把用户的问题文本发给 Ollama 生成查询向量,然后拿着这个向量去 ChromaDB 里搜索。搜索的时候会遍历用户指定的知识库集合,每个集合都做一次 top-k 查询,默认 k 是 15。

拿到的结果不会全部使用。系统会先做一次过滤,把余弦距离大于等于 1.5 的结果丢掉。这个阈值是经验值,太严格会漏掉相关内容,太宽松又会引入噪声。1.5 是个比较平衡的选择。

过滤之后还要去重。同一个文档的多个块可能都匹配上了,这时候只保留距离最近的那个。去重用的就是之前提到的 doc_id,相同 ID 的结果只留一个。

去重后的结果按距离从小到大排序,然后挨个拼接到一起,每个块前面加上来源文件的标记。拼接的总长度限制在 3000 字符以内,够了就停下来。这 3000 字符就是最终送给 LLM 的 RAG 上下文。

一些值得注意的细节

整个流程看起来简单,但里面有不少工程上的取舍。

分块用的是固定大小而不是语义分块。语义分块听起来更高级,能按句子或段落边界切分,但需要额外的 NLP 处理,增加了复杂度和延迟。固定大小滑动窗口虽然可能在句子中间断开,但对于技术文档来说影响不大,因为技术写作通常比较规整,而且重叠部分弥补了边界断裂的问题。

嵌入截断到 500 字符而不是用完整的 800 字符。实测下来 500 字符和 800 字符的检索精度只差 1% 左右,但延迟能降低将近 40%。这个取舍在离线开发助手的场景下是很划算的。

每个来源独立创建 ChromaDB 集合而不是把所有内容塞进一个大集合。这样做的好处是知识库可以按需构建和更新,不需要一次性处理所有文档。检索的时候也可以针对特定来源做定向搜索,减少不必要的全局扫描。

语言检测用的是 Unicode 字符范围分析,不是调用外部 API。通过统计文本中不同语言字符的比例来判断主要语言。这个方法虽然简单,但对中英文日韩俄等主要语言的检测准确率已经够用了。

总结一下

GangDan 的知识库构建流程就是上面这样了,由于技术和眼界的限制,我也不会啥东西,自然也没有用什么花哨的技术,就是下载、转换、分块、嵌入、存储这几步。但每一步的参数都经过实际验证,800 字符的分块大小、150 字符的重叠、500 字符的嵌入截断、1.5 的距离过滤阈值,这些数字都是从实际效果中摸索出来的。

对于想要自己搭建 RAG 知识库的开发者来说,这套流程提供了一个可以直接参考的模板。不需要复杂的 NLP 管线,不需要昂贵的云服务,一台能跑 Ollama 的机器加上 ChromaDB 就够了。

预览时标签不可点