
1. 项目概述从“幻觉”困扰到“零幻觉”的追求做AI阅读器或者文档问答系统的朋友估计都经历过被“幻觉”折磨的阶段。你精心搭建了一个系统用户上传一份PDF问一个文档里明确写着答案的问题结果AI给你编造了一段看似合理、实则完全错误的信息。这种体验就像你问一个号称过目不忘的助手“我昨天穿什么颜色的衣服”它自信满满地告诉你“您穿的是蓝色的衬衫”而你昨天明明穿的是黑色的T恤。这种“一本正经地胡说八道”在技术圈我们称之为“幻觉”。我最近投入了大量精力就是为了解决这个痛点目标是实现阅读器的“零幻觉”问答。这里的“零幻觉”不是一个绝对的、数学上的100%无错而是一个工程上的高标准确保AI给出的每一个事实性答案都能在用户提供的源文档中找到确凿的依据并且答案的表述与原文意图高度一致杜绝无中生有和歪曲理解。这背后核心依赖的技术就是大家热议的RAG。但仅仅套用一个RAG框架是远远不够的。市面上很多教程和开源项目只是简单地把文档切片、向量化、然后检索、丢给大模型生成答案。这种“流水线”式的RAG在简单场景下或许能用但一旦遇到复杂文档、多步推理或者需要精确引用的场景幻觉问题就会频频出现。我的目标是构建一个更健壮、更可信的RAG系统它不仅仅是“检索后生成”更是“基于可靠证据的精确生成”。2. 核心思路超越基础RAG的工程化架构要实现“零幻觉”不能只盯着大模型本身而要把整个问答流程当作一个系统工程来设计。我的核心思路可以概括为“分而治之交叉验证证据前置”。这不仅仅是技术选型更是一种设计哲学。2.1 从“黑盒生成”到“白盒推理”传统RAG流程中大模型像一个黑盒我们喂给它检索到的文档片段和问题它内部进行复杂的处理然后输出答案。我们很难知道它到底用了哪些信息、是如何组合的。而“零幻觉”要求我们必须把这个黑盒尽可能打开。我的做法是引入“工具调用”的思维。我不再让大模型直接生成最终答案而是赋予它调用一系列“工具”的能力。这些工具包括精确检索器、文本匹配器、摘要验证器等。大模型的任务被拆解为首先分析用户问题决定需要调用哪些工具、按什么顺序调用然后收集工具返回的原始证据最后基于这些确凿的证据进行有限制的、可追溯的答案合成。这个过程让模型的“思考”过程变得可观测、可干预。2.2 构建多层防御的检索与验证体系单一的向量检索很容易因为语义相似但事实不符而“召回”错误片段。因此我构建了一个混合检索管道语义检索向量搜索负责处理用户问题的意图和深层语义召回相关但可能宽泛的片段。关键词检索稀疏检索负责精确匹配问题中的实体、术语、数字和日期确保关键事实点不被遗漏。元数据过滤如果文档结构清晰有章节、标题、作者、日期等利用这些元数据进行初步筛选大幅缩小搜索范围。这“三路召回”的结果不会直接合并丢给模型。它们会进入一个重排序模块。这个模块使用一个更轻量、更专注于相关性判断的模型或规则对召回的多个片段进行打分和排序把最相关、最可能包含答案的片段排到最前面。这步操作成本不高但能显著提升后续步骤的输入质量。2.3 证据的显式化与答案的约束生成这是实现“零幻觉”最关键的一环。在最终生成答案前系统会明确准备好一组“候选证据”。这些证据是经过检索和重排序后排名最高的几个文本片段。然后在给大模型的提示词中我会进行强约束指令约束“你的回答必须严格且仅基于以下提供的上下文信息。如果上下文信息不足以回答问题请直接说‘根据提供的信息无法回答此问题’切勿编造任何信息。”格式约束要求答案后必须附上引用标明答案出自哪个证据片段的哪一部分。例如“……参见[证据1]第三段”。过程约束在复杂问题上要求模型先分步推理列出从证据中推导出答案的每一步然后再总结成最终答案。这让我们有机会检查其推理链条是否牢固。通过这套组合拳大模型从“自由创作者”变成了“严谨的报告员”它的工作是基于给定材料撰写报告而不是凭空发挥。3. 关键技术点深度拆解与实操下面我深入聊聊几个关键组件的具体实现和选型思考这里面坑不少。3.1 文档处理的“第一公里”智能切片策略文档切片是RAG的基石切不好后面全白搭。很多人直接用固定长度的滑动窗口切分这对于结构复杂的文档如论文、手册是灾难性的很容易把一句话、一个表格或一个关键描述拦腰截断。我的策略是分层切片第一层基于语义的粗切。利用文档自身的结构标记如Markdown的标题# PDF解析出的章节标题HTML的h1等将文档切分成大的语义章节。这保持了主题的完整性。第二层基于长度与标点的细切。在每个语义章节内再按自然段落、句子边界进行切分同时严格保证每个切片有最大长度限制例如512个token。一个核心技巧是切分时优先在句号、问号、换行符处断开避免在实体名词或数字中间断开。第三层重叠与关联。相邻切片之间设置一个小的重叠区比如50个token。这确保了即使答案恰好落在边界也能被至少一个切片完整包含。同时为每个切片记录其元数据所属章节、页码、前后切片的ID等便于后续的上下文关联。实操心得对于PDF不要依赖最简单的文本提取工具。我使用像pymupdf或pdfplumber这类库它们能更好地保留文本的布局和顺序信息。对于扫描件OCR是必须的但OCR后的文本一定要进行后处理如拼写校正、段落合并否则噪声会严重影响向量表示的质量。3.2 向量化模型选型与“本地化”部署向量模型负责把文本切片变成计算机能理解的数学向量嵌入。选型上我放弃了追求顶级但庞大的通用模型如OpenAI的text-embedding-3转而选择在检索任务上专门优化过的、适合本地部署的中小型模型。为什么第一延迟和成本。每次检索都需要计算查询向量和百万级文档向量的相似度如果嵌入模型太大推理速度慢成本高。第二领域适配性。通用嵌入模型在百科知识上表现好但针对特定领域如法律、医疗、金融术语效果可能打折扣。我最终选用了像BAAI/bge-large-zh-v1.5或intfloat/e5-large-v2这类模型。它们在同尺寸模型中检索性能领先且对中文支持友好。部署上使用SentenceTransformers库加载并搭配FAISS或Chroma这类向量数据库。为了进一步提升速度我对向量进行了标量化处理这能在几乎不损失精度的情况下将相似度计算从浮点运算转换为更快的整数运算特别适合亿级规模文档的快速检索。注意事项嵌入模型需要和你的大语言模型“语感”一致吗不一定但最好在同一个语料分布上训练。一个重要的实践是将你的问答对问题 答案中的“答案”部分用与文档切片相同的嵌入模型进行向量化然后存入向量库。这样当你用类似的问题检索时有可能直接召回高质量的“答案片段”这比召回“问题相关段落”有时更直接有效。3.3 重排序低成本高收益的精度提升器重排序模块是提升答案相关性的“神器”。它的输入是混合检索召回的可能10-20个片段它的任务是对这些片段进行精排。我测试了两种方案交叉编码器模型如BAAI/bge-reranker-large。这类模型同时编码问题和候选片段直接输出一个相关度分数。精度最高但计算成本也相对较高因为每个问题候选对都需要单独计算一次。序列到序列的生成式重排让一个轻量级模型如Qwen2.5-7B对候选片段进行排序。提示词如“请根据问题‘[用户问题]’对以下文本片段的相关性进行排序只输出排序后的片段编号。”这种方法更灵活甚至可以理解“哪个片段最可能包含最终答案”而不仅仅是“语义相关”。在实际系统中我采用了一种级联策略先用一个非常快但较粗糙的模型或基于关键词匹配的规则从20个片段中筛选出Top 5再对这5个片段使用更强大的交叉编码器进行精排。这样在精度和速度间取得了很好的平衡。3.4 Tool Calling的实践让AI长出“可靠的手脚”这是将思路落地的核心编程范式。我不再使用简单的LLM(prompt)函数而是定义了一套清晰的工具集并使用像LangChain或LlamaIndex的Agent执行器或者直接利用支持function calling的模型API如OpenAI, DeepSeek, Qwen等。我定义的工具包括retrieve_related_passages(query: str, top_k: int) - List[Passage]: 执行混合检索返回文本片段列表及来源。find_exact_match(text: str, passage: Passage) - List[Span]: 在给定的片段中寻找与问题中关键词或短语的精确文本匹配位置。verify_answer_candidate(answer: str, passages: List[Passage]) - bool: 验证一个候选答案是否能从提供的片段中完全推导出来。大模型Agent的工作流就变成了用户提问“本文档中2023年的研发投入是多少”Agent分析后决定调用retrieve_related_passages(“2023年 研发 投入”, top_k5)。拿到5个片段后Agent可能发现里面既有2023年也有2022年的数据它需要进一步澄清。于是它调用find_exact_match(“2023”, passage)在每个片段中定位“2023”这个词。定位到包含“2023”的片段后Agent从中提取出数字和描述组合成一个候选答案“2023年研发投入为500万元”。最后Agent调用verify_answer_candidate将这个候选答案回传给验证工具检查“500万元”这个数字是否确实出现在那些提及“2023”和“研发投入”的上下文中。验证通过Agent生成最终答案“根据文档第X页第Y节2023年的研发投入为500万元。”这个过程虽然步骤多了但每一步都是可追溯、可验证的极大降低了幻觉概率。4. 系统实现与核心环节剖析我以构建一个本地化的PDF问答系统为例串联起上述所有环节。技术栈选择FastAPI作为后端Qwen2.5-7B-Instruct作为核心LLMBGE系列模型做嵌入和重排Chroma做向量库利用LangChain的Agent和Tools抽象来组织流程。4.1 环境搭建与依赖管理首先是一个清晰、可复现的环境。我强烈推荐使用conda或uv来管理Python环境避免包冲突。# 使用 conda 创建环境 conda create -n rag_zero_hallucination python3.10 conda activate rag_zero_hallucination # 核心依赖 pip install fastapi uvicorn pymupdf pdfplumber sentence-transformers chromadb langchain langchain-community # 如果需要特定的模型如Qwen pip install transformers torch对于向量数据库Chroma的轻量化和易用性很好它支持本地持久化模式无需额外安装数据库服务。如果你需要处理超大规模数据千万级以上FAISS或Weaviate可能是更好的选择但它们需要更多的运维知识。4.2 文档预处理管道的实现我实现了一个DocumentProcessor类它封装了从文件加载到切片存储的全过程。import hashlib from typing import List, Dict from pymupdf import open as fitz_open class DocumentProcessor: def __init__(self, chunk_size512, chunk_overlap50): self.chunk_size chunk_size self.chunk_overlap chunk_overlap def load_and_split_pdf(self, file_path: str) - List[Dict]: 加载PDF并执行智能分块 doc fitz_open(file_path) chunks [] chunk_id 0 for page_num in range(len(doc)): page doc.load_page(page_num) text page.get_text(text) # 获取文本 # 1. 基于段落进行初步分割假设双换行符是段落分隔 raw_paragraphs [p.strip() for p in text.split(\n\n) if p.strip()] for para in raw_paragraphs: # 2. 如果段落太长按句子和长度进一步切分 sentences para.replace(。, 。\n).split(\n) # 简单分句 current_chunk for sent in sentences: if len(current_chunk) len(sent) self.chunk_size: current_chunk sent else: if current_chunk: # 保存当前块 chunks.append({ id: f{hashlib.md5(current_chunk.encode()).hexdigest()[:8]}, text: current_chunk, source: file_path, page: page_num 1, metadata: {type: text} }) current_chunk sent # 新块以当前句子开始 # 处理最后一个块 if current_chunk: chunks.append({ id: f{hashlib.md5(current_chunk.encode()).hexdigest()[:8]}, text: current_chunk, source: file_path, page: page_num 1, metadata: {type: text} }) doc.close() return chunks这个处理器确保了切片尽可能在语义边界处断开。对于更复杂的文档可以集成LangChain的RecursiveCharacterTextSplitter并为其配置中文的分隔符优先级如\n\n,\n,。,,。4.3 混合检索与重排序的实现检索器HybridRetriever集成了语义和关键词搜索。from sentence_transformers import SentenceTransformer import chromadb from chromadb.utils import embedding_functions from typing import List, Tuple import jieba # 用于中文关键词提取 class HybridRetriever: def __init__(self, embedding_model_nameBAAI/bge-small-zh-v1.5): # 初始化向量模型和Chroma客户端 self.embed_model SentenceTransformer(embedding_model_name) self.ef embedding_functions.SentenceTransformerEmbeddingFunction(model_nameembedding_model_name) self.chroma_client chromadb.PersistentClient(path./chroma_db) self.collection self.chroma_client.get_or_create_collection( namedocs, embedding_functionself.ef ) # 初始化重排序模型可选按需加载 # self.reranker CrossEncoder(BAAI/bge-reranker-large) def _extract_keywords(self, query: str, top_k5) - List[str]: 简单的中文关键词提取 words jieba.lcut_for_search(query) # 过滤停用词和单字按词频排序 from collections import Counter filtered [w for w in words if len(w) 1] keyword_counts Counter(filtered) return [kw for kw, _ in keyword_counts.most_common(top_k)] def retrieve(self, query: str, top_k10) - List[Tuple[str, float, Dict]]: 混合检索入口 # 1. 语义检索向量搜索 semantic_results self.collection.query( query_texts[query], n_resultstop_k * 2, # 多取一些供重排序筛选 ) semantic_docs list(zip( semantic_results[documents][0], semantic_results[metadatas][0], semantic_results[distances][0] )) # 2. 关键词检索在内存中或二次查询 keywords self._extract_keywords(query) keyword_docs [] # 简化实现在语义检索结果中根据关键词匹配度进行加分 # 更复杂的实现可以维护一个倒排索引进行独立检索 for doc_text, metadata, distance in semantic_docs: score -distance # 将距离转换为相似度分数假设使用余弦相似度 for kw in keywords: if kw in doc_text: score 0.2 # 关键词匹配加分 keyword_docs.append((doc_text, metadata, score)) # 3. 融合与重排序 # 先按分数排序 all_docs sorted(keyword_docs, keylambda x: x[2], reverseTrue)[:top_k*2] # 4. 使用重排序模型进行精排如果加载了 # if self.reranker: # pairs [(query, doc[0]) for doc in all_docs] # scores self.reranker.predict(pairs) # reranked sorted(zip(all_docs, scores), keylambda x: x[1], reverseTrue) # final_docs [item[0] for item in reranked[:top_k]] # else: final_docs all_docs[:top_k] return final_docs这个retrieve方法返回的不仅是文本还有元数据和相关性分数为后续的Tool Calling提供了丰富的上下文。4.4 基于Tool Calling的问答Agent实现这是最精彩的部分。我们使用LangChain来定义工具和Agent。from langchain.agents import Tool, AgentExecutor, create_react_agent from langchain_core.prompts import PromptTemplate from langchain_qwen import ChatQwen # 假设使用Qwen模型 # 1. 定义工具 def retrieve_tool(query: str) - str: 检索相关文档片段 retriever HybridRetriever() # 这里应该是全局或注入的实例 results retriever.retrieve(query, top_k5) formatted [] for i, (text, meta, score) in enumerate(results): formatted.append(f[片段{i1}] (来源:{meta.get(source,未知)}, 页:{meta.get(page,N/A)}, 相关分:{score:.3f})\n{text[:200]}...) # 截断显示 return \n---\n.join(formatted) def exact_match_tool(query: str, passage_text: str) - str: 在指定片段中精确查找关键词 keywords jieba.lcut_for_search(query) found [] for kw in keywords: if len(kw) 1 and kw in passage_text: # 找到上下文 idx passage_text.find(kw) start max(0, idx - 20) end min(len(passage_text), idx len(kw) 20) context passage_text[start:end] found.append(f关键词“{kw}”出现在...{context}...) return \n.join(found) if found else 未找到精确匹配。 # 将函数包装成LangChain Tool tools [ Tool( name文档检索器, funcretrieve_tool, description当需要从知识库中查找与问题相关的信息时使用此工具。输入是一个查询字符串。 ), Tool( name文本精确匹配器, funcexact_match_tool, description当需要在给定的文本片段中精确查找某个词语、数字或短语时使用此工具。输入是查询词和文本片段。 ) ] # 2. 设计强约束的提示词模板 prompt_template PromptTemplate.from_template( 你是一个严谨的文档问答助手。你的所有回答必须严格基于用户提供的文档信息严禁编造幻觉。 你可以使用以下工具来获取信息 {tools} 请遵循以下流程思考 1. 分析用户问题理解其核心信息需求。 2. 使用“文档检索器”工具查找相关文档片段。 3. 如有必要使用“文本精确匹配器”工具在关键片段中定位具体信息。 4. **仅基于工具返回的证据**来组织你的答案。 5. 如果证据不足以回答问题请直接说“根据现有文档无法回答此问题。” 6. 在答案中尽可能引用证据来源例如“根据[片段1]...”。 问题{input} {agent_scratchpad} ) # 3. 初始化LLM和Agent llm ChatQwen(modelQwen/Qwen2.5-7B-Instruct, api_keyyour-key, base_urlhttp://localhost:8000/v1) # 假设本地部署 agent create_react_agent(llm, tools, prompt_template) agent_executor AgentExecutor(agentagent, toolstools, verboseTrue, handle_parsing_errorsTrue) # 4. 执行问答 def answer_question(question: str) - str: result agent_executor.invoke({input: question}) return result[output]当用户提问“公司2023年的战略目标是什么”时Agent会先调用“文档检索器”得到几个片段。如果片段中同时包含2022、2023、2024年的目标它可能会进一步调用“文本精确匹配器”在相关片段中精确查找“2023”这个词从而精准定位证据最终生成一个带有引用的、基于证据的答案。5. 效果评估与持续优化策略搭建完系统不是终点评估和优化才是保证“零幻觉”承诺的持续过程。5.1 如何量化“幻觉”程度我设计了一个简单的评估框架核心是构建一个测试集可回答问题从文档中直接提取事实构成问题-答案对如“总裁的名字是什么”“第三季度的营收是多少”。不可回答问题问题涉及文档中不存在的信息如“文档中提到了哪些AI模型”而文档根本没提AI。多步推理问题需要结合文档中多处信息进行简单推理如“A产品比B产品的销量高多少”需要先分别找到A和B的销量。评估指标包括答案准确性对于可回答问题模型答案与标准答案的事实一致性可以用模糊匹配或LLM评判。幻觉率模型在可回答问题中编造信息的比例在不可回答问题中“强行回答”的比例。引用精确度模型提供的引用是否真实支持其答案。拒答能力对于不可回答问题模型正确说“不知道”的比例。5.2 常见问题与针对性调优在实战中我遇到了以下典型问题及解决方案问题1检索结果看似相关但不包含答案。排查检查向量模型是否与文档领域匹配。尝试在领域数据上微调嵌入模型哪怕只是少量数据也能大幅提升效果。优化增强混合检索中的关键词权重。对于包含明确实体人名、产品名、代号、日期的问题提升稀疏检索的优先级。问题2答案正确但引用不准确或缺失。排查检查提示词中关于引用的指令是否足够强硬和明确。模型有时会“偷懒”。优化在生成答案的步骤中强制模型采用“先提取证据再生成答案”的两段式输出。甚至可以先让模型输出一个“证据摘要”再基于摘要生成最终答案。问题3对于长文档或多轮对话上下文混乱。排查检查是否在每次问答时都传入了过多的、无关的历史上下文。优化实现对话历史管理。不是把所有历史记录都塞进上下文而是维护一个“对话记忆向量库”将历史问答的关键信息向量化存储。当新问题到来时先从记忆库中检索最相关的历史信息再结合当前文档检索结果进行回答。这有效避免了上下文窗口被无关历史污染。问题4系统响应速度慢。排查瓶颈可能在嵌入模型推理、向量数据库搜索或大模型生成。优化嵌入缓存对常见问题或问题模板的查询向量进行缓存。异步处理将文档预处理、向量化等耗时操作改为异步任务。模型量化对嵌入模型和重排序模型使用INT8量化在精度损失极小的情况下提升推理速度。检索优化使用HNSW等更快的索引算法并设置合理的搜索参数。6. 从“零幻觉”到“高可信”未来的思考实现“零幻觉”问答是一个持续的过程而非一劳永逸的目标。通过上述的工程化架构、工具调用范式和多层验证我们已经能将幻觉控制在极低的水平。但还有一些更前沿的思路值得探索1. 知识图谱增强的RAG对于高度结构化、实体关系丰富的文档如技术手册、法律条文可以先将文档解析成知识图谱。检索时不仅检索文本片段还检索相关的实体和关系子图。这让模型在回答“A与B的关系是什么”这类问题时依据更加结构化推理链条更清晰。2. 自我验证与链式验证让模型在生成答案后自己扮演“验证者”角色。提示词可以是“请严格检查以下答案是否完全基于提供的上下文。如果答案中有任何信息无法在上下文中找到请将答案修改为‘无法验证’。”这种自我反思机制能进一步过滤掉残余的幻觉。3. 可解释性界面对于最终用户仅仅给出答案和引用还不够。一个高可信的系统应该能展示其“心路历程”展示了哪些检索片段、经过了怎样的重排序、最终依据了哪部分证据。这种透明性能极大增强用户信任。在我自己的实践中最大的体会是对抗幻觉三分靠模型七分靠工程。选择一个合适的基础大模型是重要的但如何为它设计流程、提供工具、设定规则、构建验证体系才是决定系统最终可靠性的关键。这就像培养一个研究员不仅要给他丰富的资料库向量知识库还要教会他严谨的研究方法工具调用和验证流程并规定他必须注明每一处引用的来源强约束提示。只有这样我们才能得到一个不仅聪明而且可信的AI助手。这条路没有终点但每解决一个细节问题系统的可靠性就向前迈进一小步这种积累带来的成就感正是技术工程迷人的地方。