ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于MongoDB与mongot实现RAG知识库存储与混合检索实战

基于MongoDB与mongot实现RAG知识库存储与混合检索实战 最近半年我一直在做 AI 知识库相关的项目最头疼的一件事就是文档原文、向量索引和全文检索到底应该放在哪里。身边不少团队的习惯做法是 MySQL 存业务数据、Elasticsearch 做全文检索、向量数据库管 embedding三个系统串起来看着挺专业维护起来真想摔键盘。后来我把整套检索链路迁到了 MongoDB 上底层正是 MongoDB 那个名为 mongot 的搜索引擎。这段时间踩了不少坑也顺着 mongot 开源源码把它的内部机制梳理了一遍今天把思路和实战过程完整写出来希望能给正在选型 RAG 存储方案的人一点参考。这套方案解决的核心问题很直接让同一个数据库同时承担文档存储、结构化过滤、全文检索和向量召回而 mongot 就是那个把搜索能力嵌进 MongoDB 体验里的关键进程。如果你正在搭 RAG 知识库或者被“三件套”架构的数据同步问题折磨过这篇文章应该能帮你少走很多弯路。1. RAG 项目里最容易被低估的一环是检索1.1 为什么传统“三件套”在 AI 场景里特别别扭RAG检索增强生成说白了就是把外部知识塞给大模型让它回答私域问题时少一点幻觉。完整流程不复杂文档切分、向量化、入库、检索召回、拼 Prompt、LLM 生成。听着简单可真落地的时候你会发现最大的瓶颈不是大模型部分而是“怎么把内容存下来、怎么把内容找回来”。最传统的路线是 MySQL Elasticsearch 向量数据库三个系统各干各的。这个架构的第一个问题就是数据同步。同一份文档业务字段要写进 MySQL正文要同步到 ES向量要推给向量库。任何一边写入失败数据就永久不一致。我在项目里遇到过最崩溃的场景用户修改了一条工单状态我同时要去 MySQL 改字段、去 ES 更新文档、去向量库重跑 embedding三套 SDK 来回调写到最后自己都怕改错地方。第二个问题是查询逻辑破碎。RAG 场景下的用户问题往往不是单纯的关键词匹配而是像“找最近三个月售后部门提交的跟退款相关工单”这种结构化过滤加全文加语义的组合条件。这种查询拆分到三个系统里你要写三套查询语句再在代码层做结果融合逻辑复杂不说性能还很难优化。第三个问题是运维负担。一个知识库没多大业务量先搭三个中间件对中小团队来说纯属过度设计。我当时就在想有没有可能用一个系统把所有这些事情扛下来。1.2 MongoDB mongot 提供了另一种思路MongoDB 在数据库领域不算新面孔但很多人不清楚它的搜索层。MongoDB Atlas Search以及企业版内置的搜索能力底层是由一个独立进程叫 mongot 承载的。mongot 内嵌了 Lucene 相关的全文检索能力也支持向量索引和 KNN 搜索。它不是简单包装而是跟 MongoDB 的查询引擎深度集成客户端的聚合查询里可以直接写 $search 和 $vectorSearch 阶段由 mongod 转发给 mongot 去执行。这意味着什么呢一份知识库文档它的原文、结构化元数据、向量表示可以全部存在同一个集合里然后通过同一条聚合管道同时完成布尔过滤、关键词检索和向量召回。业务数据查询和搜索查询在用户视角里融为一体。我在项目里最直观的体会就是删掉了两套同步代码原来那种“改一条数据要小心翼翼怕漏同步”的状态彻底消失了。这个方案适合谁如果你正在给 RAG 应用做存储选型或者你已经用了 MongoDB 但不知道它还能做搜索又或者你想理解大规模检索系统的设计思路这篇文章值得往下看。2. 从源码视角看 mongot 引擎的核心设计2.1 一条查询从 mongod 到 mongot 的完整旅程mongot 开源以后我就是冲着一个问题去看代码的一条搜索查询到底是怎么被 MongoDB 处理的。我觉得与其逐行读完全部源码不如沿着三条主线去梳理这样效率高得多。第一条主线是查询流转路径。当客户端向 mongod 发起一个带 $search 或 $vectorSearch 阶段的聚合查询时mongod 并没有自己去扫数据而是先解析出搜索意图把查询请求转换成内部协议转发给同机部署的 mongot 进程。mongot 在它管理的 Lucene 索引里执行检索返回一批文档的 _id 和相关性分数mongod 拿到这些 id 之后再从自己的存储引擎里回表取完整文档继续执行后续的聚合阶段。这个设计耐人寻味它把搜索索引和源数据在物理上放在同一个集群逻辑上又做了清晰分工。索引只负责快速筛选出候选集真正的数据读取还是 MongoDB 存储引擎的强项。对应用层来说mongot 完全透明你永远写 MongoDB 聚合管道不需要学 Lucene QueryParser 或者 Elasticsearch DSL这是 mongot 最核心的产品价值。2.2 全文索引和向量索引为什么能在一个引擎里共存第二条主线是索引管理。传统全文索引基于倒排表通过 BM25 这类公式来衡量关键词和文档的相关性向量索引则更像高维空间里的最近邻搜索问题常见实现是 HNSW 图。mongot 内部把这两类能力统一到 Lucene 生态里对外则暴露成不同的索引类型。对 RAG 应用来说这带来的直接好处是混合检索变得非常自然。你可以给同一个集合建一个全文索引和一个向量索引然后在一次聚合管道里先跑 $search 再跑 $vectorSearch把两边的结果按权重合并。以前在 ES 和向量库之间手动查询再合并的时代这个操作要写不少胶水代码。现在 mongot 帮你把两边能力收到一起我实际测试下来混合召回对回答质量的提升非常明显。纯向量检索容易把语义相近但完全无关的内容捞回来加入全文检索后实体名、型号、编号这类精确信息就稳了。第三条主线更关键就是相关性分数的处理。mongot 会把搜索内部的评分通过 $meta 语法暴露给聚合管道这意味着你可以在 MongoDB 查询层直接读取每条命中结果的分数再做二次归一化、排序或者过滤。这个能力听着简单却是生产环境必需的没有它混合检索的权重调优根本无从谈起。2.3 从源码设计里读出的三个经验读这套引擎代码我最大的感受是它的设计思路非常务实。第一索引与存储分离、但体验统一搜索系统的复杂细节被封装在独立进程里用户无感知地获得了搜索能力。第二评分在管道里可以流动让上层应用对检索结果有极强的控制力。第三所有控制延迟的手段都落在候选集和限制条件上全文搜索和向量搜索都不是全量扫描通过控制候选集大小和返回条数来调节资源消耗这是所有成熟搜索引擎的通用思路。3. 实战在 MongoDB 上搭一个可用的 RAG 知识库3.1 环境准备与文档切分理论讲再多不如直接跑起来。我用 MongoDB Atlas 的免费层 M0 集群不需要花钱就能跑通整条链路。如果你想在企业内部环境试用 MongoDB Enterprise 部署并开启搜索能力逻辑也是一样的。依赖库准备起来不复杂核心就这几个pymongo 做数据库操作sentence-transformers 做向量化langchain 做流程编排再配一个大模型接口。向量模型我推荐 BAAI/bge-m3中文效果好还支持本地部署不用把文本送到外部 API数据安全上更安心。切分是 RAG 里特别容易被忽视、却对召回质量影响最大的环节。切得太碎上下文信息不完整切得太粗无关内容混进来影响精度。我最终用了 chunk_size500、chunk_overlap50 的参数并把章节标题存成单独的字段。这样一个切块既保留语境也方便回答时溯源到具体章节。3.2 写入向量并建立搜索索引把文档处理成块之后写入过程很直接先连接 MongoDB然后把标题、正文、元数据和 embedding 一起插入集合。from pymongo import MongoClient from sentence_transformers import SentenceTransformer uri 你的MongoDB连接串 client MongoClient(uri) db client[rag_demo] col db[docs] model SentenceTransformer(BAAI/bge-m3) emb model.encode(这里是文档正文片段).astype(float32).tolist() col.insert_one({ title: 某某手册章节, content: 这里是文档正文片段, category: 售后, embedding: emb, })这里有个新手特别容易忽略的地方MongoDB 不会自动“理解”哪个字段是向量你必须显式建立索引。全文索引和向量索引的映射定义不一样。全文索引只映射需要的字段我建议关闭动态映射这样既能控制索引体积也能避免意外字段被加进来。{ mappings: { dynamic: false, fields: { title: { type: string }, content: { type: string, analyzer: lucene.standard }, category: { type: string } } } }向量索引的定义更关键路径、维度、相似度算法一个都不能错。{ mappings: { dynamic: false, fields: { embedding: { type: vector, path: embedding, numDimensions: 1024, similarity: cosine } } } }bge-m3 输出 1024 维配置维度必须跟模型一致否则索引建起来了查询结果却永远是空的。这个坑我后面还会细说。3.3 用 $search 和 $vectorSearch 做混合召回查询阶段是整个链路的核心。假设用户问“售后服务对退款有什么政策”第一步要用同一个嵌入模型把这个问题向量化然后用聚合管道查向量索引。query_embedding model.encode(user_question).astype(float32).tolist() pipeline [ { $vectorSearch: { index: vector_index, path: embedding, queryVector: query_embedding, numCandidates: 100, limit: 5 } }, { $project: { title: 1, content: 1, category: 1, score: { $meta: vectorSearchScore } } } ] docs list(col.aggregate(pipeline))全文检索对应另一种语法走的也是同一个聚合管道接口pipeline [ { $search: { index: fulltext_index, text: { query: user_question, path: [title, content] } } }, { $limit: 5 }, { $project: { title: 1, content: 1, score: { $meta: searchScore } } } ] docs list(col.aggregate(pipeline))如果你的需求比较轻两种检索方式二选一就能跑起来。但生产环境我更推荐混合检索把向量结果和全文结果分别取回然后对分数做归一化后再合并。全文分数和向量分数量纲不同直接相加没有意义。我习惯先各自做 min-max 归一化然后按权重合并全文给 0.3向量给 0.7。这个比例不是拍脑袋定的是用一批人工标注的相似问题反复验证才调出来的。max_v max([d[score] for d in vec_hits]) or 1 max_t max([d[score] for d in text_hits]) or 1 for d in vec_hits: d[final_score] 0.7 * d[score] / max_v for d in text_hits: d[final_score] 0.3 * d[score] / max_t merged sorted(vec_hits text_hits, keylambda x: x[final_score], reverseTrue)[:5]这段代码看起来普通但里面的权重调优才是 RAG 质量的关键。不同业务的数据分布差别很大没有一个通用的黄金比例必须用实际查询日志和人工标注去校准。3.4 上下文组装与大模型生成召回做完后把命中的片段拼接成上下文交给大模型生成回答。最终代码大概长这样from langchain_openai import ChatOpenAI context \n\n---\n\n.join( f来源{d[title]}\n{d[content]} for d in top_docs ) prompt f你是一名售后服务助手。请严格依据以下资料回答用户问题。 如果资料中没有答案请直接说明不知道不要编造。 资料 {context} 用户问题{user_question} llm ChatOpenAI(modelgpt-4o-mini, temperature0.2) answer llm.invoke(prompt).content print(answer)这一步看着平平无奇但 RAG 的最终答案质量绝大部分取决于这一步的输入而上下文拼得好不好完全取决于上一步的检索质量。4. 实战中踩过的坑与排查方法4.1 索引已经建好但搜索不到数据这是重复率最高的坑。索引状态明明显示 Active一跑查询结果集却是空的。第一排查思路就是看索引路径和文档字段名是否完全一致。我见过索引里写了 embeddings文档字段叫 embedding 的也见过向量存成 numpy 数组没转成 list 的这类问题建索引时不会报错查询时颗粒无收。另一个高频起因是换了嵌入模型。模型一变向量维度大概率跟着变旧向量索引不会自动重建必须删掉重跑一遍向量化、再重新建索引。向量索引本质上是图结构维度一变整个图就失效了别想着原地更新最务实的办法就是全量重建。4.2 numCandidates 设置不当导致召回率偏低$vectorSearch 里的 numCandidates 决定进入最终排名的候选数量limit 才是最终返回条数。很多新手把两个参数设成一样比如 limit 是 5numCandidates 也是 5这样向量索引实际只保留极少的邻居准确率会差得离谱。我的建议是 numCandidates 至少是 limit 的 10 到 20 倍。想返回 5 条候选设 100想返回 10 条候选设 150 到 200。候选集会带来更多计算但不会增加最终返回条数对召回率是实打实的帮助。RAG 场景里先保证找得到再谈排得好所以如果响应时间还有余量候选集宁可大一点。4.3 相关性分数看着不对劲向量搜索的相似度算法可以选 cosine、euclidean 或 dotProduct。我自己常用的是 cosine尤其在 bge-m3 这类模型上cosine 的区分度最直观。euclidean 在低维场景还行在高维向量上距离值会被稀释不太适合做 RAG 的召回排序。还有一点别迷信分数的绝对值。不同模型、不同索引类型产出的分数分布完全不同最靠谱的做法是用一批已经标注过“是不是正确答案”的测试数据去标定一个合理阈值再决定是直接取 top-k 还是按分数过滤。检索不是配一次就完事它需要跟着业务数据的变化持续调整。4.4 常见错误速查表| 现象 | 可能原因 | 处理方法 | | 索引 Active 但搜不到 | 索引路径与字段名不一致 | 核对 path删除并重建索引 | | 向量查询结果为空 | 维度与模型输出不一致 | 统一维度重建向量索引 | | 全文检索匹配不到中文词 | 没有配置合适 analyzer | 尝试 lucene.standard 或 smartcn | | 混合检索效果差 | 分数没有归一化就相加 | 先 min-max 归一化再加权 | | 查询延迟明显变高 | numCandidates 设置过大 | 在召回率和延迟之间折中 | | 文档更新后搜不到 | 索引刷新有延迟 | 等待刷新周期或调整刷新配置 |这些坑基本都出在配置和习惯问题上没有一个是搜索引擎原理层面的高深内容。做一次记录后续项目能省下大量定位问题的时间。5. 从这套方案里得到的架构思考5.1 数据存储收敛是一个明显的趋势过去我们习惯把数据结构和非结构化分成两个世界数据库管结构化搜索引擎管全文。RAG 工作负载把这两个世界的边界打穿了既要存原文又要算向量还要支持各种过滤查询。mongot 给了我们一个务实的方案用主数据库来承载数据资产把搜索能力当成可插拔的一部分而不是引入另一套独立体系。这个思路对选型很有启发。如果团队本身已经在用 MongoDB我会建议先在现有数据库上把搜索能力用起来小范围验证 RAG 效果而不是急着部署一套独立的向量数据库。对绝大多数中小数据集mongot 支撑的检索性能完全够用。等数据量真正到了千万级以上再考虑引入专有搜索引擎也不迟。5.2 对 agentic RAG 和 AI 工作负载的扩展价值现在 RAG 已经不完全满足于一问一答了更多场景在往 agentic RAG 演进也就是让模型自主决定先查什么、再查什么、调用什么工具。这种模式下检索接口的稳定性、过滤表达能力和元数据丰富度比单轮问答重要得多。MongoDB 聚合管道天然支持把各种条件组合进一个查询里mongot 的索引能力让这个组合查询既像数据库查询又像搜索引擎查询而且可以放心放进循环反复执行。我见过一个团队做了非常漂亮的例子在一个聚合管道里组合了布尔筛选、全文检索、向量相似度、地域过滤四层条件上层套了一个 ReAct Agent让模型把复杂问题拆成多个检索子任务。整个过程没有引入新中间件性能也稳定。以后做带自主规划能力的 AI 应用这种强组合表达能力会越来越吃香。6. 再聊聊开源源码这件事虽然这个引擎已经公开我还是想多说一句这类代码开源真正的意义不只是让你本地编译跑一次而是给技术社区提供了一个理解现代搜索引擎架构的活教材。我建议后来者别急着逐行读先按我前面说的三条主线把查询流转、索引管理、分数处理捋明白再回头看 Atlas Search 遇到的各种报错和调优建议你会瞬间明白背后的原因。检索是 RAG 的命门而 MongoDB 加 mongot 这套方案是我目前见过把检索和业务数据库距离拉得最近的选择。
返回列表