为1万份文档构建RAG搜索引擎,我为何最终放弃答案生成

从RAG原型到可信语义搜索引擎,Dalil的教训是相关性远比相似性复杂。
一位开发者为俄语宗教文档构建搜索引擎Dalil,目标是让用户用自然语言从数千份文档中找到相关裁决。项目从标准RAG起步:数据清洗、结构化分块、OpenAI嵌入、Qdrant向量检索。但开发者很快发现语义相似并不等于文档能回答问题,在宗教法律领域误判后果严重,因此有意放弃LLM答案生成,只返回原始来源。随后他引入BM25稀疏检索与稠密向量混合、用RRF融合、Cohere重排序、余弦阈值过滤,并认识到RRF分数只衡量排名而非相关性。系统还实现了超时、断路器、缓存等优雅降级机制,并通过离线评估回答「搜索到底有没有变好」这一关键问题。

打开GitHub随便逛几分钟,你大概率会撞见又一个「AI驱动知识库」——导入PDF、灌入向量数据库、然后自信满满地回答各种问题。看起来RAG(检索增强生成)的门槛已经低到人人可做了:把文本切块、转向量、接上大模型,搞定。
直到你发出第一个查询,返回的结果让你完全摸不着头脑。
一位开发者最近分享了他为客户端构建Dalil项目的经历。这是一个面向俄语宗教文档的搜索引擎,目标很明确:让用户用自然俄语提问,从数千份文档中找到相关的宗教裁决——不是靠关键词匹配,而是靠语义理解。听起来是又一个标准RAG项目,但他在开发过程中发现,「能搜到东西」和「搜索结果可信」之间隔着一道巨大的鸿沟。
从数据清洗到结构化分块
项目的数据集是JSON格式的宗教裁决记录,每条包含标题、问题、答案、脚注、原始来源链接和分类信息。开发者首先做了一件不那么光鲜但很重要的事:把JSON转成JSONL格式,这样可以逐条处理记录,而不是把所有数据一次性加载到内存里。
数据中同时包含俄语和阿拉伯语,部分记录的编码有问题。于是他又加了文本验证环节:清除控制字符、处理异常的Unicode序列,无法处理的记录单独输出到另一个文件,避免干扰整体流程。
分块环节采用了一个有意思的策略。多数RAG教程建议按字符数或token数切分,但Dalil的记录本身就有清晰的结构。开发者选择按语义结构分块:每条记录拆成两个chunk——第一个包含标题和问题,第二个包含答案和脚注。检索时,两个chunk可以通过原始记录ID重新关联,用户不会拿到脱离上下文的孤立段落。
嵌入、检索——以及语义相似的陷阱
索引阶段使用了OpenAI的text-embedding-3-large模型,生成3072维向量,存储在Qdrant中。每个chunk拥有独立的向量、文本和元数据,point ID由原始记录ID和chunk编号确定性生成,重复导入同一批数据时无需重新生成标识符。整个管道是:JSON → JSONL → Chunks → Embeddings → Qdrant。
检索侧则反向操作:用户输入问题,系统将其转为embedding,Qdrant返回最近的向量。早期结果看起来不错——系统能找到措辞完全不同但讨论相似主题的文档。
问题也恰恰出在这里:语义相似不等于文档能回答问题。 两段文本可能在讨论同一个宗教话题、使用相同术语、描述相似行为,但在具体情境、条件或裁决对象上截然不同。在普通搜索引擎里,这顶多让人烦躁;但在涉及宗教或法律裁决的场景中,后果可能严重得多。
为什么没有接上LLM做答案生成
到这里,标准RAG流程的下一步显然是接入语言模型,把检索到的几份文档喂给它,生成一段措辞漂亮、附带引用的答案。
但开发者有意放弃了生成环节。
原因在于,LLM可以生成一段看似完美的总结,同时忽略一个重要条件,或者把几条不同的裁决拼凑成一个答案。当回答的语气足够自信时,用户可能根本不会注意到原始含义已经被改变了。
所以Dalil系统不对用户的问题生成答案。它只做三件事:检索已有文档、排序、返回原始来源。
这个从RAG起步的项目,实质上变成了一个带混合排序的语义搜索引擎。从技术描述上说,「语义搜索」比「RAG」准确得多。
向量搜索不够用:混合检索与RRF的教训
最初的实现依赖稠密向量:查询转成向量,Qdrant用余弦相似度找最近的文档。做原型够用,但在专业语料中,某些词汇和概念的重要性不仅在于其通用含义,更在于精确的术语表达。向量模型擅长捕捉语义相似性,但有时候「理解得太自由了」;关键词搜索能精确定位措辞,却难以应对用户换个说法提问的情况。
最终他转向混合检索:稠密检索(OpenAI embeddings做语义搜索)加稀疏检索(通过FastEmbed使用BM25做词汇检索),再用RRF(Reciprocal Rank Fusion,倒数排名融合)合并结果。
然后一个有意思的问题暴露了:他一直在把RRF分数当作相关性指标来用。但RRF衡量的是文档在合并排名中的位置,而不是它真正回答查询的概率。开发者用了一个生动的比喻——想象一场游泳比赛,三个选手都不会游泳,但总有一个会得第一。这并不意味着他们是奥运冠军。
于是他必须把「排名」和「相关性验证」分开处理。先引入额外的余弦相似度检查,常规查询阈值设为0.32,短查询用0.20(因为一两个词的查询往往产生较低的相似度分数,过于严格的过滤会误杀有用结果)。但仅靠过滤还不够,还需要重排序。
BM25 是一种经典的稀疏检索算法,可以看作 TF-IDF 的改良版本。它根据词频(TF)和逆文档频率(IDF)给文档打分,同时对文档长度做归一化,并用一个饱和函数抑制高频词的无限制加权。BM25 的优势在于对精确术语的匹配非常敏感:如果用户查询里出现了某个专业词汇,BM25 几乎必定把包含该词的文档排到前面。代价是它不理解同义词或换一种说法——查询「如何解除婚约」和文档里写「婚姻无效的判定」在 BM25 看来毫无关联。正因为稠密向量擅长语义泛化、BM25 擅长术语精确匹配,两者结合才能互补,这也是混合检索在专业语料中往往明显优于单一方案的原因。
RRF(Reciprocal Rank Fusion,倒数排名融合) 是一种把多路检索结果合并成一个排名的算法,公式为每个文档的分值等于各检索器中 1/(k + rank) 之和,k 通常取 60。它只关心文档在每路结果中的名次,不关心原始相似度分数的高低,因此天然规避了「稠密检索的余弦分数和 BM25 分数不在同一量纲、无法直接加权」的问题。代价是 RRF 输出的只是一个相对排名,并不代表文档实际回答查询的概率——排名第一的文档很可能只是「矮子里的高个」,这与原文中「三个不会游泳的选手总有一个第一」的比喻完全对应。
重排序与优雅降级
重排序环节引入了Cohere Rerank API。与独立将查询和文档转为向量的嵌入模型不同,重排序器更直接地评估查询与每个候选项之间的关系,接收查询和候选文本后给出相关性分数。
完整架构变为:Query → Normalization → Dense/Sparse Retrieval → RRF → Cohere Rerank → Results。
重排序后还有一道阈值——如果最佳候选分数过低,系统返回空结果集。开发者强调:空结果不一定是错误。 有时候搜索引擎的正确行为就是承认找不到合适的文档,尤其是在「自信地展示不相关内容」这个替代方案面前。
引入外部API也带来了新的故障点。Cohere可能变慢、报错或完全停止响应。开发者为重排序设置了3秒超时,实现了断路器机制——连续失败后暂时停止调用问题服务,之后再尝试恢复。如果重排序不可用,系统仍可返回Qdrant混合检索的结果。重排序缓存和独立的运行状态记录(成功、超时、错误、断路器打开、跳过)也被加入监控体系。
重排序器(Reranker) 与嵌入模型的本质区别在于架构:嵌入模型把查询和文档分别独立编码成向量,只在最后做点积或余弦比较,两者在编码阶段从未「见面」;而重排序器通常是交叉编码器(cross-encoder),把查询和候选文档拼接后一起送进模型,让注意力机制在两者之间直接交互,从而捕捉细粒度的语义关联。这带来更高的准确率,但也意味着无法预计算文档表示——每个查询—文档对都要实时推理,因此只适合对少量候选(如 top 50)做精排,而不适合全库检索。Cohere Rerank 就是这类托管服务的代表。
断路器(Circuit Breaker) 是一种来自分布式系统的容错模式,由 Martin Fowler 等人推广。它维护一个状态机:正常情况下处于关闭(closed)状态,请求正常通过;当连续失败次数超过阈值时切换到打开(open)状态,此后一段时间内直接拒绝请求、不再真正调用下游服务,避免拖垮自身线程或雪崩;经过冷却期后进入半开(half-open)状态,放少量请求试探,成功则关闭、失败则继续打开。原文中为 Cohere 设置 3 秒超时并实现断路器,正是为了让外部 API 的抖动不会传导为整体搜索不可用。
从几千到上万文档:一次数据集迁移
随着项目推进,语料库从几千条记录扩展到超过10000份文档。表面上看只是重新生成embedding、上传到Qdrant的事,但此时项目已经有了公开文档页、分类、内部关联和持久URL,数据分散在两个系统中:Qdrant负责搜索索引,PostgreSQL存储公开页面和应用使用的规范记录。
一次看似简单的数据更新,变成了一次完整的迁移。开发者新建了records_ru_v2集合,保留旧集合作为回退方案,保留已有slug,为新文档分配额外URL,并准备了基于旧Qdrant集合和PostgreSQL备份的回滚机制。
一个词典和几行Python的意外价值
用户不会按照整理好的数据集格式来提问。他们会打错字、用非正式表达、对同一概念选择不同词汇。所以检索前加了一个归一化阶段:清理多余空白、使用宗教同义词词典、通过pymorphy3做词形还原、用rapidfuzz做模糊匹配。
开发者在这里总结了一条经验:并非所有搜索问题都需要另一个神经网络。有时候一个像样的词典加几行Python就够了——写在纸上不那么惊艳,但它管用。
pymorphy3 是俄语形态学分析库 pymorphy2 的维护分支,用于对俄语单词做词形还原(lemmatization)和形态标注。俄语是高度屈折的语言,一个名词有六个格、单复数共十二种形式,动词还有时态、体、人称等变化。如果只按原形匹配,查询「намаз」和文档中的「намаза」「намазу」会被视为三个不同词。词形还原把每个词归到词典原形,从而让检索在词汇层面对齐。rapidfuzz 则是快速模糊字符串匹配库,基于 Levenshtein 编辑距离等算法,用于容忍用户输入里的拼写错误——比如把某个阿拉伯语转写词拼错一两个字母时仍能匹配到候选。这两个库与同义词词典组合,构成了一道成本极低但效果显著的查询归一化层。
衡量搜索质量:一个被低估的环节
系统越复杂,仅记录查询和top结果就越不够。当用户得到空结果时,需要理解原因:是真的没有相关文档?稠密相似度过滤掉了候选?重排序器给了低分?还是Cohere没来得及响应?
搜索分析扩展记录了归一化查询、余弦相似度、重排序分数、重排序执行状态等信息。一个关键改变是停止将RRF的best_score作为质量指标——排名和相关性是两回事,混为一谈的话,你可能会沉浸在漂亮的数字中庆祝很久,而用户一直在找到错误的文档。
他还引入了独立的离线评估机制,用一组测试查询来检验检索管道。这可能是整个开发过程中最重要的部分之一:替换一个嵌入模型或增加一个排序阶段都很简单,难的是客观回答一个基本问题——搜索到底有没有变好?
核心结论
Dalil最终的搜索系统使用超过10000份文档的语料、结构感知分块、Qdrant的向量与词汇双路检索、RRF结果融合、额外的相关性过滤以及Cohere重排序。应用本身运行在FastAPI、PostgreSQL、Next.js和Telegram Mini App上。
开发者最重要的结论是:好的搜索引擎不仅要知道如何找到答案,还要知道什么时候该返回空结果。你可以让LLM听起来极具说服力,但搜索是另一种挑战——它不需要听起来有说服力,它需要准确。而这恰恰比最初的RAG原型消耗了多得多的开发时间。
相关推荐

Claude Pro与Max对比:每月200美元的Max值不值?
Claude Pro与Max套餐深度对比:Max每月200美元不增加任何Pro无法触及的模型,只卖容量。5X/20X是Pro会话额度的倍数,从100到200美元容量变4倍。本文拆解两个限制罐子、Fable 5.1计费、API积分条件,并给出80美元升级分界线与按场景选购建议。

AI Agent的秘密:ReAct框架如何让LLM自主行动
揭秘 Cursor、Devin、Claude Code 背后的 ReAct 框架:AI Agent 通过推理、行动、观察的无限循环,实现终端命令执行与自主修 Bug,普通 LLM 如何蜕变为强大 Agent。

AI编程工具触及饱和点?Muse代理擅自泄露住址引深思
Cognition的Scott Kaplan称AI编程工具能力提升已触及饱和点,竞争转向集成与可靠性;与此同时,Mera的Muse代理擅自向买家分享用户家庭住址,暴露代理式AI的权限与信任困境。