ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

从零搭建私有文档问答:向量检索与RAG实战指南

从零搭建私有文档问答:向量检索与RAG实战指南

1. 从零搭建私有文档问答:为什么向量检索是绕不开的那道坎

很多人第一次接触大模型应用,脑子里想的都是"我把文档丢给模型,它就能回答我的问题"。真动手才发现,模型根本读不到你的私有文档——它只认训练时见过的东西。于是就有了两条路:一条是把文档内容塞进提示词里,另一条是先检索再喂给模型。前者在文档量小的时候能凑合,一旦文档上百页、上千页,上下文窗口直接爆掉,成本也扛不住。后者就是现在大家都在用的检索增强生成,而检索这一步的核心,就是向量数据库。

我这次要分享的,是一套完整的私有文档检索增强应用实践。从向量数据库怎么选、文档怎么向量化、检索怎么调优,一直到和大模型串起来跑通整个问答链路。整套东西我在一台普通开发机上就跑起来了,没有用任何付费的向量数据库服务,全部本地化部署。适合谁看?如果你已经会一点 Python,听说过大模型但没真正搭过应用,或者搭过但检索效果一塌糊涂,那这篇内容应该能帮你少走不少弯路。

先说清楚一个概念,向量检索到底在干什么。传统的关键词搜索,是你输入"苹果",它去找包含"苹果"这两个字的文档。但如果你问"这种水果对健康有什么好处",文档里写的是"苹果富含膳食纤维",关键词匹配就抓瞎了,因为字面上没有重叠。向量检索不一样,它把每段文本转成一串数字(也就是向量),语义相近的文本,它们的向量在空间里的距离就近。你问的问题也转成向量,然后去找距离最近的文档向量。这样即使字面不一样,语义对得上就能被检索到。

这套逻辑听起来简单,但真做起来,坑一个接一个。选哪个向量数据库?文档怎么切分才合理?向量化用哪个模型?检索出来一堆不相关的内容怎么办?这些问题我在实际搭建过程中全都踩过,下面一个一个拆开讲。

2. 向量数据库选型:别一上来就上分布式集群

2.1 先搞清楚你的数据规模和部署条件

选型这件事,最怕的就是不看自己的实际情况,直接抄大厂的方案。我见过有人一共就几千条文档,上来就部署一套分布式向量数据库,光运维成本就够喝一壶的。所以第一步,先问自己三个问题:数据量多大?要不要持久化?部署环境有没有 GPU?

数据量这块,给你一个粗略的参考。如果你的文档切片之后在十万条以内,单机版的向量数据库完全够用,查询延迟基本在毫秒级。十万到百万级别,可以考虑单机但内存要够大,或者用支持磁盘索引的方案。再往上,才需要考虑分布式。我这次处理的是一批技术文档,切片后大概三万多条,属于单机轻松搞定的量级。

部署条件也很关键。有些向量数据库对内存要求很高,因为它要把所有向量加载到内存里做相似度计算。三万多条 768 维的向量,算下来大概是 30000 × 768 × 4 字节,差不多 90MB,内存毫无压力。但如果你有上千万条,那就是几十 GB 的内存需求,这时候就得考虑量化压缩或者磁盘索引了。

2.2 几款主流方案的横向对比

我把当时考虑过的几个方案列了个表,方便你对照自己的情况:

方案部署方式持久化适合规模上手难度特点
FAISS库,嵌入代码需自己实现百万级以内中等性能强,但只是个索引库,不是完整数据库
Chroma单机服务/嵌入式支持十万级低轻量,API 友好,适合快速原型
Milvus单机/分布式支持亿级较高功能全,但单机部署也不轻
Qdrant单机/分布式支持千万级中等Rust 写的,性能好,过滤功能强
PGVector依附 PostgreSQL支持百万级低如果你已经在用 PG,直接加个扩展就行

我最后选的是 Chroma,原因很直接:这次是私有文档检索,数据量不大,我要的是快速跑通并且方便调试。Chroma 支持嵌入式模式,不用单独起服务,几行代码就能建库、插入、查询,持久化也就是指定一个目录的事。对于原型验证阶段,这种低摩擦的方案能让我把精力放在检索效果调优上,而不是折腾部署。

但这里要提醒一句,Chroma 适合原型和小规模生产,如果你预期数据量会快速增长,或者需要复杂的元数据过滤、多租户隔离,那还是老老实实上 Milvus 或 Qdrant。选型没有绝对的好坏,只有合不合适。

2.3 一个容易被忽略的点:元数据过滤能力

很多人选向量数据库只看它能不能做相似度搜索,忽略了元数据过滤。什么叫元数据过滤?举个例子,你的文档有多个来源,你想只在这个季度的文档里检索,或者只检索某个分类下的内容。如果向量数据库不支持在检索时按条件过滤,你就只能先全量检索再在代码里筛,效率低还容易出错。

Chroma 支持在查询时传 where 条件,比如where={"source": "manual"},这个在实际应用里非常有用。我在做文档问答时,会给每个切片打上来源文件、章节、页码这些元数据,检索时可以限定范围,避免跨文档串味。这一点在选型时一定要确认,不然后面想加过滤功能会很痛苦。

3. 文档向量化:切分策略比模型选择更影响效果

3.1 向量化模型怎么挑

向量化就是把文本转成向量,这一步用的模型叫嵌入模型。市面上有闭源的 API,也有开源的本地模型。闭源 API 的好处是省事、效果好,但你的文档内容要发到别人的服务器上,私有文档场景下很多人不接受。开源本地模型的好处是数据不出门,代价是效果可能略逊一筹,而且需要一点算力。

我这次用的是本地部署的开源嵌入模型,维度是 768。选它的理由:一是数据完全本地,符合私有文档的定位;二是 768 维在效果和存储之间比较平衡;三是这个模型对中文支持还不错,我的文档主要是中文技术资料。如果你主要处理英文,可以选英文表现更好的模型;如果中英混合,就要挑多语言能力强的。

这里有个经验:嵌入模型不是越大越好。有些模型维度上千,效果提升有限,但存储和计算成本翻倍。我实测下来,768 维对于文档问答这个场景已经够用,检索准确率的关键其实在切分策略上,模型的影响反而没那么大。

3.2 文档切分:最容易被低估的环节

文档切分,就是把长文档切成一段一段的小块,每块单独向量化。为什么不能整篇文档直接向量化?因为一篇文档可能讲了好几个主题,整篇转成一个向量,语义就被平均掉了,检索时定位不准。切成小块,每块聚焦一个点,检索精度会高很多。

但切多大合适?这是个玄学问题,我踩过坑。切太小,比如按句切,每块信息量不够,检索出来一堆碎片,拼不成完整答案。切太大,比如按整章切,又回到语义被平均的问题。我最后用的是按段落切,同时设置一个最大长度限制,比如 500 个字符,超过就再往下切。这样每块基本是一个完整的语义单元。

还有一个细节:切分时要保留重叠。什么意思?就是相邻两块之间留一部分重复内容。比如第一块是第 1 到 500 字,第二块从第 400 字开始。这样做是为了避免一个完整的句子或概念正好被切在边界上,导致两块都丢了关键信息。重叠长度我设的是 50 到 100 字,实测下来能明显减少"检索到了但答案不完整"的情况。

3.3 给每个切片打上元数据

前面提到元数据过滤,这里说怎么打。每个切片除了文本内容和向量,还要存一些附加信息。我存了这几样:来源文件名、在原文中的位置(第几段)、所属章节标题。这些信息在检索回来之后有用,一是可以展示给用户看答案出处,二是可以做过滤,三是如果发现某类文档检索效果差,可以针对性调整。

打元数据这件事,在切分的时候顺手就做了,成本很低,但后面用起来很香。我建议你从一开始就加上,别等需要了再回头补,那时候数据都入库了,重新处理一遍很麻烦。

4. 检索调优:为什么你搜出来的东西总是不对

4.1 相似度计算方式的选择

向量检索的核心是算相似度,常见的有余弦相似度和欧氏距离。余弦相似度看的是方向,欧氏距离看的是绝对距离。对于文本向量,通常用余弦相似度更合适,因为它对向量长度不敏感,只关注语义方向。Chroma 默认用的是欧氏距离,但可以配置成余弦。我实测下来,文本场景下余弦确实更稳,尤其是文档长度差异大的时候。

这个选择不是拍脑袋,背后有原因。文本向量经过归一化之后,方向就代表了语义,长度往往和文本长度相关。如果两段文本语义相同但长度差很多,欧氏距离会认为它们不相似,而余弦相似度能正确识别。所以做文本检索,优先选余弦。

4.2 Top-K 到底取多少

检索时你要指定返回多少个最相似的结果,这个数量叫 Top-K。取太少,可能漏掉关键信息;取太多,会引入噪声,还会让大模型的输入变长、成本增加。我一开始取 3,发现有时候答案分散在多个切片里,3 个不够;后来取 10,又发现很多不相关的内容混进来,干扰模型判断。

最后我定的是 5,并且加了一个相似度阈值,低于阈值的直接丢掉。这样既保证召回,又控制噪声。阈值怎么定?我建议你先跑一批测试问题,看看正确切片的相似度分布,取一个能过滤掉大部分无关内容的值。我这边定的是 0.7 左右,你可以根据自己的数据调。

4.3 重排序:给检索结果再加一道筛子

即使做了 Top-K 和阈值过滤,检索结果里还是可能混入不相关的内容。这时候可以加一个重排序步骤。重排序就是用另一个模型,对初步检索出来的候选切片重新打分排序。它比向量检索更精细,但速度慢,所以只对少量候选做。

我用的是一个轻量的重排序模型,对 Top-10 的候选重新打分,然后取前 3 个喂给大模型。实测下来,加了重排序之后,答案的准确率有明显提升,尤其是那些语义相近但主题不同的干扰项,能被有效过滤掉。这一步不是必须的,但如果你对准确率要求高,值得加上。

5. 和大模型串起来:从检索到生成

5.1 提示词模板的设计

检索出来的切片,要和大模型的问题一起组织成提示词。这个模板设计得好不好,直接影响回答质量。我的模板大概是这样的:先给模型一个角色设定,告诉它你是一个文档问答助手,只能根据提供的资料回答;然后把检索到的切片按相关度排列贴进去;最后是用户的问题,并强调如果资料里没有答案,就直说不知道,不要编。

这个"不知道就说不知道"的约束非常重要。不加的话,模型很容易在资料不足时自己编答案,这在私有文档场景下是致命的。我踩过这个坑,问一个文档里根本没有的问题,模型煞有介事地编了一段,看起来还挺像那么回事。加上约束之后,它就会老老实实说资料里没有相关信息。

5.2 上下文长度和成本控制

检索回来的切片加上问题,一起构成模型的输入。这个输入不能太长,一是模型有上下文窗口限制,二是越长越贵。所以前面说的 Top-K 和阈值控制,在这里就体现出价值了。我一般控制在 3 到 5 个切片,每个切片几百字,总共两三千字的上下文,大部分模型都能轻松处理。

如果你用的是按 token 计费的 API,这个控制直接关系到钱包。我算过一笔账,不加控制的话,每次问答可能要消耗上万 token,控制之后降到两三千,成本差了好几倍。所以检索调优不只是为了效果,也是为了省钱。

5.3 完整链路的串接

把前面所有环节串起来,整个流程是这样的:用户提问 → 问题向量化 → 向量数据库检索 Top-K → 阈值过滤 → 重排序 → 取前几个切片 → 组装提示词 → 调用大模型 → 返回答案和出处。

这个链路里,每一步都可以单独调试和优化。我建议你先把链路跑通,哪怕效果一般,然后再逐个环节调。一开始就追求完美,很容易卡在某个细节上出不来。先跑通,再调优,这是我做这类项目的一贯节奏。

6. 实测中遇到的几个坑和应对

6.1 中文切分的标点问题

中文和英文不一样,英文按空格和句号切很自然,中文没有空格,标点也复杂。我一开始用简单的按字符数切,结果经常把一句话切成两半,语义断裂。后来改成优先按中文标点(句号、问号、感叹号、分号)切,再控制最大长度,效果好很多。

还有一个细节:中文的省略号、破折号这些,处理不好也会出问题。我的做法是先把这些特殊标点统一替换成句号,再做切分。这样虽然损失了一点原文格式,但切分质量提升明显,值得。

6.2 向量维度不匹配的报错

这个坑很典型。你先用模型 A 建了库,后来想换模型 B,结果查询时报维度不匹配。因为不同模型的向量维度可能不一样,A 是 768 维,B 可能是 1024 维,数据库里存的向量和查询向量对不上,直接报错。

解决办法只有一个:换模型就要重新向量化所有文档,重建库。所以选模型的时候要慎重,别建完库又换。如果非要换,预留好重新处理的时间。我建议在项目初期就把模型定下来,后面尽量不动。

6.3 检索结果重复的问题

有时候检索出来的几个切片内容高度重复,因为原文里相似的内容被切到了不同的块里。这会导致喂给大模型的上下文里有一堆重复信息,浪费空间还干扰判断。我的应对是在检索后做一次去重,如果两个切片的文本相似度超过某个阈值,就只保留一个。

去重可以用简单的文本相似度算法,也可以用向量相似度。我用的是向量相似度,因为向量已经算好了,直接比较就行,不用额外计算。这个步骤加在重排序之后,效果不错。

6.4 大模型"幻觉"的抑制

前面提过,模型会编答案。除了在提示词里加约束,还可以在返回结果时附上出处,让用户自己判断。我的做法是每个切片都带来源信息,答案里标注引用了哪几个切片。这样即使模型说错了,用户也能顺着出处去核实。这不能完全消除幻觉,但能降低误导风险。

另外,如果检索结果的相似度普遍偏低,说明资料里可能真没有相关内容,这时候可以主动告诉用户"未找到相关资料",而不是硬让模型回答。这个判断逻辑加在检索之后、生成之前,能挡掉不少无效问答。

7. 一些让效果更稳的实操心得

7.1 建立一个小型评测集

调优最怕凭感觉。你觉得这个参数好,那个参数差,但没有数据支撑,调来调去都是瞎猜。我的做法是建一个小型评测集:准备二三十个问题,每个问题标注好正确答案应该来自哪个文档的哪个部分。每次调整参数,就跑一遍评测集,看准确率的变化。

这个评测集不用很大,二三十个问题就够。关键是覆盖不同类型的提问:有明确答案的、需要跨文档综合的、文档里没有的。这样调出来的参数才靠谱。我靠这个评测集,把检索准确率从最初的六成多提到了八成以上。

7.2 日志记录不能省

每次问答,我都把中间结果记下来:用户问题、检索到的切片、相似度分数、最终答案。这些日志在排查问题时非常有用。比如用户反馈某个问题答错了,我可以翻日志看是检索没召回,还是召回了但模型没用好。定位到具体环节,才能针对性修复。

日志不用搞得很复杂,写个文件就行。但一定要记,而且要记全。我见过有人只记最终答案,出了问题完全不知道是哪一步的锅,只能从头复现,效率极低。

7.3 增量更新文档的处理

文档不是一成不变的,今天加一篇,明天改一篇。如果每次都全量重新向量化,费时费力。我的做法是给每个文档算一个指纹(比如文件内容的哈希值),更新时只处理指纹变化的文档。删除的文档,从向量库里按元数据过滤删掉对应的切片。

这个增量更新的逻辑,在文档量大之后能省很多时间。一开始文档少可能感觉不到,但养成习惯,后面会感谢自己。Chroma 支持按元数据删除,实现起来不难。

7.4 关于硬件的一点实话

很多人担心本地跑向量化模型需要好显卡。我实测下来,对于文档问答这种离线批处理的场景,CPU 也能跑,就是慢一点。三万多条切片,用 CPU 向量化大概几十分钟,用 GPU 几分钟。如果你只是偶尔更新文档,CPU 完全够用,没必要为了这个专门买显卡。检索阶段更是几乎不吃算力,主要吃内存。

真正吃算力的是大模型本身。如果你本地跑大模型,那显卡是刚需;如果调用 API,那本地只需要处理向量化,压力小很多。这个账要算清楚,别为了向量化去配高端显卡,结果大模型还是调 API,显卡就浪费了。

8. 后续可以继续深挖的方向

整套东西跑通之后,我发现还有不少可以继续优化的地方。比如混合检索,就是把向量检索和关键词检索结合起来,各取所长。向量检索擅长语义,关键词检索擅长精确匹配,两者结合往往比单用向量更稳。这个我还在试,初步效果不错。

再比如查询改写,用户的问题有时候表述不好,直接拿去检索效果差。可以先用大模型把问题改写成更适合检索的形式,再去做向量检索。这个思路在复杂问题上提升明显,但会增加一次模型调用,成本和延迟都要考虑。

还有就是多路召回,用不同的切分策略或不同的嵌入模型分别检索,然后合并结果。这个能提高召回率,但实现复杂度也上去了。我目前还没上,等有明确需求再说。

做这类应用,我的体会是:没有一步到位的完美方案,都是先跑通再迭代。每个环节都有优化空间,但要分清主次,先把影响最大的环节调好,别在细枝末节上耗太久。检索质量是这套系统的命门,把检索调好了,后面大模型那一步反而简单。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进