构建零幻觉RAG系统:基于ModernBERT与SPLADE的逐字问答引擎
1. 项目概述一个能“逐字逐句”回答问题的RAG系统如果你用过传统的检索增强生成RAG系统大概率遇到过这样的烦恼你问它一个基于文档的问题它确实从文档里找到了相关信息但回答时却总爱“添油加醋”要么把几个不同地方的信息糅合成一个不存在的结论要么干脆自己编造一些看似合理但原文根本没有的细节。这种现象在AI领域被称为“幻觉”Hallucination对于需要精确引用、证据可追溯的场景比如法律咨询、医疗问答、学术研究来说简直是灾难。今天要聊的这个开源项目verbatim-rag就是为了彻底解决这个问题而生的。它的核心思想非常直接甚至有点“笨拙”生成的答案必须完全由源文档中“逐字逐句”提取的文本片段拼接而成并且每个片段都要有明确的出处引用。它不给你“自由发挥”的空间从根本上杜绝了幻觉的产生。更酷的是通过使用像SPLADE这样的稀疏嵌入模型和我们自己训练的ModernBERT分类器整个RAG流程可以完全在CPU上运行无需调用任何大语言模型LLM这让它变得极其轻量和高效。简单说它就是一个为“证据确凿”而设计的问答引擎。2. 核心设计思路为什么“逐字”比“生成”更可靠2.1 传统RAG的幻觉根源要理解verbatim-rag的价值得先看看传统RAG是怎么“翻车”的。典型的RAG流程是“检索-生成”两步走先用向量检索找到相关的文档片段上下文然后把问题和上下文一起喂给LLM让它生成答案。问题就出在第二步的“生成”上。LLM的本质是一个基于概率预测下一个词的语言模型。即使你给了它准确的上下文它在组织语言、归纳总结时依然会受到其庞大训练数据中固有模式的影响从而产生“创造性”的偏差。比如文档里明明写着“A疗法在60%的病例中有效”LLM可能会生成“A疗法对大多数患者有效”。虽然语义相近但“60%”和“大多数”在严谨性上有着天壤之别。这种偏差在需要精确数字、特定名称或复杂逻辑关系时尤为危险。2.2 Verbatim RAG的“保守主义”哲学verbatim-rag采取了一种截然不同的“保守主义”策略。它认为在证据驱动的问答中保真度远比流畅性重要。因此它将答案生成过程从“创造性写作”降级为“精准拼图”。它的工作流可以概括为精准检索利用向量数据库找到与问题最相关的文档块。片段提取使用专门的模型或方法从这些相关块中精确标出能直接回答问题的句子或文本跨度Span。模板化组装根据问题类型选择一个预定义的或动态生成的回答模板然后将上一步提取的原文片段像填空一样严丝合缝地填入模板的对应位置。引用标注为答案中的每一个原文片段自动附上其来源文档的标识如标题、页码、段落号。这样一来最终答案的每一个词都来自原文幻觉无处藏身。同时清晰的引用让用户能够一键回溯到原始证据极大地增强了系统的可信度和实用性。2.3 技术选型的权衡LLM vs. 专用模型项目提供了两种片段提取器这背后体现了重要的工程权衡LLMSpanExtractor利用如GPT-4等大型语言模型的强大理解能力通过精心设计的提示词Prompt来识别相关片段。优点是零样本Zero-shot能力强无需训练对于新领域适应快。缺点是依赖外部API有成本、延迟和隐私顾虑且依然存在极低概率的指令遵循偏差。ModelSpanExtractor使用专门为句子分类任务微调Fine-tune的编码器模型如项目提供的基于ModernBERT的模型。优点是本地化部署速度快、成本低、隐私性好且行为完全确定。缺点是需要训练数据并且在训练数据覆盖不到的领域外Out-of-domain样本上性能可能下降。verbatim-rag同时支持这两种方式并将基于ModernBERT的本地化方案作为亮点这为追求可控性、低延迟和私有化部署的用户提供了绝佳选择。项目团队甚至在RAGBench数据集上训练并开源了他们的ModernBERT模型 (KRLabsOrg/verbatim-rag-modern-bert-v1)展示了其完全脱离LLM运行整个流程的能力。注意选择哪种提取器取决于你的核心需求。如果追求最高准确率且不介意云API可选用LLM方案。如果要求本地部署、确定性高、运行成本低那么专用模型是更优解。对于大多数严肃的生产环境我倾向于后者。3. 从零开始搭建与实操理论说得再多不如亲手跑一遍。下面我将带你完整地部署和使用一个基于本地模型的verbatim-rag系统重点讲解每一步的意图和可能遇到的坑。3.1 环境准备与安装首先确保你的Python环境在3.9以上。创建一个干净的虚拟环境是个好习惯。# 创建并激活虚拟环境以conda为例 conda create -n verbatim-rag python3.10 conda activate verbatim-rag # 安装核心包 pip install verbatim-rag如果安装顺利你可以尝试导入验证。但这里有个关键点verbatim-rag的完整安装包含了Milvus向量数据库的本地客户端等依赖体积相对较大。如果你只需要其最核心的“逐字转换”逻辑例如想集成到已有系统中可以安装其轻量核心pip install verbatim-core这个verbatim-core只依赖openai,pydantic,rapidfuzz,jinja2四个包非常纯净。你可以用它来快速体验片段提取和模板填充的核心功能。3.2 文档处理与索引构建假设我们有一些学术PDF需要建立知识库。我们以项目自带的论文为例。from verbatim_rag import VerbatimIndex from verbatim_rag.ingestion import DocumentProcessor from verbatim_rag.vector_stores import LocalMilvusStore from verbatim_rag.embedding_providers import SpladeProvider # 1. 初始化文档处理器 # DocumentProcessor内部集成了docling格式解析和chonkie智能分块 processor DocumentProcessor() # 你可以调整chonkie的分块策略比如重叠overlap大小以适应不同长度的文档。 # 但项目默认配置对大多数英文文档已经足够友好。 # 2. 处理文档这里从URL处理一个PDF # 注意process_url会下载文件请确保网络通畅。 document processor.process_url( urlhttps://aclanthology.org/2025.bionlp-share.8.pdf, titleKR Labs at ArchEHR-QA 2025: A Verbatim Approach for Evidence-Based Question Answering, metadata{authors: [Adam Kovacs, Paul Schmitt, Gabor Recski], year: 2025} ) # process_url 返回一个Document对象列表。你也可以用 process_file(path) 处理本地文件。 # 关键务必为文档提供有意义的title和metadata这将是后续引用的重要依据。 # 3. 创建嵌入模型提供者和向量存储 # 使用SPLADE稀疏嵌入它可以在CPU上高效运行并且检索效果通常优于传统的BM25。 sparse_provider SpladeProvider( model_nameopensearch-project/opensearch-neural-sparse-encoding-doc-v2-distill, devicecpu # 明确指定使用CPU ) # 如果拥有GPU且追求极速可以设为 devicecuda:0 # 初始化本地Milvus存储 # LocalMilvusStore是基于Milvus Lite的封装无需启动独立的数据库服务。 vector_store LocalMilvusStore( db_path./my_rag_index.db, # 索引数据库保存路径 collection_nameresearch_papers, # 集合名类似数据库表名 enable_denseFalse, # 我们只用稀疏向量关闭稠密向量以节省资源 enable_sparseTrue, ) # 首次运行会创建数据库文件。db_path目录需要写权限。 # 4. 创建索引并添加文档 index VerbatimIndex( vector_storevector_store, sparse_providersparse_provider # 因为没有启用稠密向量所以不需要传入dense_provider ) index.add_documents([document]) print(f已成功索引 {len(document.chunks)} 个文本块。)实操心得分块是门艺术chonkie的分块效果直接影响检索精度。如果文档结构复杂如多级标题、图表可能需要根据文档类型微调分块参数如块大小、重叠区域。对于技术手册较小的块如256词可能更精准对于连贯的论述文较大的块如512词能保留更多上下文。元数据是黄金在metadata字段里尽可能丰富地添加作者、出版日期、章节、页码等信息。这些信息不仅能作为引用的一部分未来也可以通过向量存储的过滤Filter功能进行精细化查询。CPU友好性SpladeProvider在CPU上运行现代BERT模型进行推理对于没有GPU的机器非常友好。但首次加载模型和编码大量文档时仍会比较耗时请耐心等待。3.3 查询与基于ModernBERT的答案提取索引建好后我们就可以进行“灵魂”步骤——查询了。这里我们将使用项目开源的ModernBERT模型作为提取器。from verbatim_rag.core import VerbatimRAG from verbatim_rag.extractors import ModelSpanExtractor # 1. 加载我们训练好的ModernBERT片段提取器 # 模型会自动从HuggingFace Hub下载。确保网络能访问 https://huggingface.co extractor ModelSpanExtractor(KRLabsOrg/verbatim-rag-modern-bert-v1) # 这个模型的作用是二分类对于给定的问题文本片段对判断该片段是否与问题相关。 # 2. 创建VerbatimRAG查询引擎 rag_system VerbatimRAG( indexindex, # 传入我们刚才构建的索引 extractorextractor, # 使用本地模型提取器 k5 # 检索时返回最相关的5个文本块作为候选 # 其他参数如temperature对于LLM提取器等可以使用默认值 ) # 3. 提出问题 question What is the main contribution of the paper? response rag_system.query(question) # 4. 查看结果 print( 问题 ) print(question) print(\n 逐字答案 ) print(response.answer) print(\n 引用来源 ) for citation in response.citations: print(f- 来自文档 {citation.document_title}, 片段: {citation.text[:100]}...) print(\n 检索到的相关块前3个) for i, chunk in enumerate(response.retrieved_chunks[:3]): print(f[Chunk {i1}] {chunk.text[:150]}...)运行这段代码你会得到一个答案。这个答案的每一句话理论上都应该能在response.citations里找到完全一致的原文出处。关键参数解析k5这是检索阶段返回的顶层文档块数量。设置太小可能漏掉关键信息设置太大会增加片段提取器的负担并可能引入噪声。一般从5-10开始调整。extractor这是核心。ModelSpanExtractor在内部会对检索到的k个块进行句子级分割然后让模型对每一个句子进行相关性打分最后筛选出分数超过阈值模型内定的句子作为“证据片段”。response对象这是一个结构体通常包含answer最终组装好的答案、citations引用列表包含原文和来源、retrieved_chunks原始检索到的块等字段。仔细检查这些字段是调试系统的重要方式。4. 深入核心片段提取与模板组装的工作原理4.1 片段提取器内部探秘无论是LLM还是ModernBERT提取器它们的目标都是一致的从一堆文本中精准地“点亮”那些能回答问题的句子。对于ModelSpanExtractor其流程如下句子化将每个检索到的文本块Chunk分割成独立的句子。构建样本对将用户问题与每一个句子拼接形成[CLS] Question [SEP] Sentence [SEP]的格式输入给ModernBERT模型。分类打分模型输出一个0到1之间的分数代表该句子与问题的相关度。阈值过滤设定一个阈值例如0.8保留分数高于阈值的句子。这些句子就是“证据片段”。去重与排序可能对相邻或重叠的片段进行合并并按某种逻辑如原始文档顺序、相关性分数排序。对于LLMSpanExtractor则是通过设计如下的提示词让LLM直接返回原文中的跨度你是一个精确的信息提取助手。请严格根据以下上下文找出能直接回答问题的原文片段。 问题{question} 上下文{context} 请直接输出原文中的相关文本片段不要任何解释或改写。如果有多个片段请用“||”分隔。4.2 模板答案的骨架提取出片段后如何将它们组织成通顺的答案这就是模板的职责。verbatim-rag支持预定义模板和动态生成模板。预定义模板针对常见问题类型如“定义”、“比较”、“列举原因”可以事先写好模板。例如一个“定义”模板可能是“根据资料{concept} 被定义为{span1}。” 系统会自动将提取到的关于concept的第一个相关片段填入{span1}。动态生成模板在更复杂的场景下可以先用一个LLM或规则分析问题类型即时生成一个适合的模板结构然后再用提取的片段填充。项目论文中提到“an LLM drafts a question-specific template”指的就是这种高级模式。模板引擎使用的是Jinja2非常灵活。最终这些被填入原文片段的模板就生成了我们看到的、带有明确引用的最终答案。注意事项模板的设计质量直接影响答案的可读性。一个糟糕的模板可能会把答案组织得支离破碎。建议针对你的垂直领域设计一批高质量的预定义模板这能极大提升系统输出的专业性。5. 常见问题与排查实录在实际部署和测试中你可能会遇到以下典型问题。这里记录了我的排查经验和解决方案。5.1 检索不到相关内容症状答案空洞或者引用的片段明显不相关。排查步骤检查检索结果首先打印response.retrieved_chunks看前k个结果是否真的包含答案。如果没有问题出在检索阶段。审视嵌入模型SPLADE模型对英文优化较好对中文或其他语言可能效果打折。确保你的文档语言与模型匹配。可以考虑尝试其他嵌入模型如BAAI/bge-m3但需注意verbatim-rag当前版本对稠密向量的支持配置。调整分块策略如果答案跨越了两个块而分块时恰好被切断了就会检索失败。尝试增大分块大小chunk_size或增加块间重叠overlap。检查问题表述用户的问题可能太模糊或用了文档中不存在的术语。可以尝试用更接近文档词汇的方式重述问题。5.2 片段提取器漏掉了关键句子症状检索结果里明明有正确答案的句子但最终答案里没有包含。排查步骤查看提取器输入将retrieved_chunks中的文本和问题一起手动模拟输入给提取器看其打分情况。对于ModelSpanExtractor可以尝试调用其内部方法查看每个句子的分数。调整阈值如果使用的是自定义模型相关度阈值可能设得过高。可以尝试调低阈值但需警惕引入无关噪声。模型领域适配项目提供的ModernBERT模型是在RAGBench数据集上训练的。如果你的领域如生物医学、金融法律非常特殊该模型可能表现不佳。这时需要考虑用自己的数据对模型进行微调或者暂时切换回LLM提取器。句子边界错误如果关键信息处于一个长句的中间而分句工具如sent_tokenize错误地将其分割可能导致片段不完整。需要检查或调整文本预处理中的分句逻辑。5.3 答案组装不流畅或引用混乱症状答案读起来别扭多个片段之间缺乏连接或者引用标记错位。排查步骤检查模板确认使用的模板是否适合当前问题类型。一个“列举”问题用了“定义”模板结果肯定会很奇怪。需要完善模板库或优化动态模板生成逻辑。片段排序提取出的多个片段可能来自文档的不同位置直接拼接会导致逻辑混乱。需要在组装前根据它们在原文中的出现顺序或与问题的逻辑关联进行排序。引用映射确保每个填入模板的{span}变量都正确关联到了其来源的citation对象。这需要仔细调试模板引擎和片段映射代码。5.4 性能与资源问题症状查询速度慢内存占用高。优化建议索引优化LocalMilvusStore在数据量较大时如超过10万条检索速度会下降。考虑对向量索引类型如IVF_FLAT进行调优或者迁移到完整的Milvus集群。批量处理在构建索引时add_documents支持批量添加。一次性传入大量文档比循环传入单文档效率高得多。模型量化如果使用自定义的PyTorch模型如自己训练的提取器可以考虑使用动态量化或ONNX Runtime来加速推理特别是在CPU上。限制检索数在保证召回率的前提下尽量使用较小的k值。6. 进阶应用与扩展思路verbatim-rag提供了一个坚实的“逐字问答”基础框架。在此基础上我们可以针对特定场景进行增强多语言支持当前开源模型主要针对英文。要处理中文你需要替换嵌入模型为优秀的中文稀疏/稠密模型如BAAI/bge-m3或intfloat/e5-multilingual。使用中文分句工具如jieba或pkuseg替代默认的英文分句。使用中文语料微调或重新训练ModelSpanExtractor。混合检索策略除了稀疏向量检索可以结合关键词检索如BM25和稠密向量检索进行多路召回再融合提升检索的鲁棒性。LocalMilvusStore支持同时启用稀疏和稠密索引为这种混合检索提供了基础设施。答案后处理与润色在极端要求可读性的场景下可以在保持原意和引用不变的前提下使用一个非常小型的、针对性训练的文本润色模型对组装后的答案进行轻微的语法调整和连接词优化使其更通顺但这一步必须严格控制防止引入幻觉。流式输出与渐进式引用对于复杂问题可以设计流式回答。先给出核心答案和引用如果用户追问“为什么”或“具体指什么”再基于已有的引用片段进一步检索和展开相关上下文形成交互式、可追溯的深度问答。这个项目的价值在于它确立了一种高可靠性的RAG范式。它可能不会生成最华丽的答案但它给出的每一个字都有据可查。在事实准确性压倒一切的应用里这种“保守”恰恰是最大的“激进”。