RAG 管道返回正确答案但引用页码错误且偶发幻觉如何优化?
问题分析
RAG(检索增强生成)系统在实际应用中常出现一类棘手问题:模型生成的答案内容大体正确,但引用的页码与实际来源不符,或者模型"编造"了不存在的内容。这种现象被称为"幻觉"(hallucination),在 RAG 场景中尤其隐蔽。
引用页码错误的根源在于检索与生成的衔接层。向量相似度检索返回的是语义相关的文档片段,但这些片段可能分散在原文的不同位置。当 LLM 试图合成答案并标注来源时,可能出现位置混淆。例如,检索返回了第 5 页和第 15 页的内容,但模型错误地将第 5 页的内容标注为来自第 15 页。
幻觉问题在 RAG 中有特殊成因。当检索到的上下文不足以支撑完整答案时,模型倾向于用自己的预训练知识"填补空白"。这种填补可能正确,也可能错误,且用户难以分辨哪些是检索内容、哪些是模型推断。
Cross-Encoder 重排序可能加剧问题。虽然 Cross-Encoder 能提升检索精度,但如果重排序时没有保留原始元数据(如页码、段落号),排序后的结果丢失了位置信息。
LangGraph 的状态管理也可能引入问题。在多轮对话场景中,历史上下文的累积可能导致模型对来源记忆混乱,特别是当用户询问"刚才说的第三条来自哪里"时。
解决原理
优化引用准确性和减少幻觉需要多层面干预:
策略一:强化来源绑定
在构建索引时,将元数据(页码、段落号、文档标题)作为不可分割的部分注入到文本中。检索返回后,保留完整的元数据链路。在 Prompt 中明确要求模型逐句标注来源。
具体实现方式是在文档切分时添加特殊标记,如 [Page:5]...内容...[EndPage]。模型在生成时复制这些标记,确保内容与页码的绑定关系不会丢失。
策略二:分离检索与引用
采用两阶段生成:第一阶段生成答案内容,第二阶段为答案中的每个事实性陈述匹配引用来源。这种方式可以避免生成与引用的干扰。
实现时使用两个 LLM 调用:第一个生成答案,第二个输入答案和检索上下文,输出每个句子的来源标注。
策略三:引入验证机制
使用 NLI(自然语言推断)模型验证生成内容与检索上下文的一致性。如果模型生成的某句话在上下文中无法找到支撑证据,标记为"低置信度"或要求人工审核。
开源工具如 Ragas 可以自动化评估 RAG 输出的忠实度。
策略四:抑制幻觉的 Prompt 工程
明确告诉模型"如果上下文中没有答案,请回答'信息不足'而非推测"。使用系统提示词约束模型行为。
策略五:优化检索粒度
过大的切片会导致边界模糊,过小的切片丢失上下文。建议根据文档类型调整切片大小,技术文档通常 512-1024 tokens,法律合同可能需要更完整的段落。
程序实现与说明
"""
RAG 系统引用准确性优化实现
包含来源绑定、验证机制和幻觉抑制
"""
import re
from typing import List, Dict, Tuple, Optional
from dataclasses import dataclass
import numpy as np
from langchain_core.documents import Document
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
from langchain_text_splitters import RecursiveCharacterTextSplitter
# ================== 数据结构定义 ==================
@dataclass
class DocumentChunk:
"""带元数据的文档切片"""
content: str
page_number: int
paragraph_number: int
source_doc: str
start_char: int
end_char: int
@dataclass
class CitedAnswer:
"""带引用标注的答案"""
answer_text: str
citations: List[Dict]
confidence_scores: List[float]
# ================== 文档处理模块 ==================
class SourceAwareSplitter:
"""
来源感知的文档切分器
在切片中注入页码标记,确保来源可追溯
"""
def __init__(self, chunk_size: int = 512, chunk_overlap: int = 50):
self.base_splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
separators=["\n\n", "\n", "。", "!", "?", ".", "!", "?", " "]
)
def split_with_metadata(
self,
text: str,
page_mappings: Dict[int, Tuple[int, int]]
) -> List[Document]:
"""
切分文本并附加页码元数据
text: 原始文本
page_mappings: {页码: (起始字符位置, 结束字符位置)}
"""
# 基础切分
raw_chunks = self.base_splitter.split_text(text)
# 为每个切片查找所属页码
documents = []
current_pos = 0
for chunk in raw_chunks:
# 查找当前切片的字符范围
chunk_start = text.find(chunk, current_pos)
chunk_end = chunk_start + len(chunk)
current_pos = chunk_start + 1 # 更新搜索起点
# 确定页码(取主要位置)
page_number = self._find_page(chunk_start, chunk_end, page_mappings)
# 构建带标记的文本
# 关键:在内容中嵌入页码信息
marked_content = f"[来源:第{page_number}页]\n{chunk}"
# 创建 Document 对象
doc = Document(
page_content=marked_content,
metadata={
"page": page_number,
"char_range": (chunk_start, chunk_end)
}
)
documents.append(doc)
return documents
def _find_page(
self,
start: int,
end: int,
page_mappings: Dict[int, Tuple[int, int]]
) -> int:
"""根据字符位置确定页码"""
# 找到包含该切片大部分内容的页
chunk_mid = (start + end) // 2
for page, (page_start, page_end) in page_mappings.items():
if page_start <= chunk_mid < page_end:
return page
return 1 # 默认第一页
# ================== RAG 管道 ==================
class HallucinationResistantRAG:
"""
抗幻觉 RAG 系统
包含来源验证和置信度评估
"""
def __init__(self, model_name: str = "gpt-4o"):
self.llm = ChatOpenAI(model=model_name, temperature=0)
self.embeddings = OpenAIEmbeddings()
self.vectorstore = None
self.splitter = SourceAwareSplitter()
def index_documents(self, documents: List[Document]):
"""索引文档"""
self.vectorstore = Chroma.from_documents(
documents=documents,
embedding=self.embeddings
)
print(f"[索引完成] {len(documents)} 个文档片段")
def query_with_citations(
self,
question: str,
k: int = 5
) -> CitedAnswer:
"""
执行带引用标注的查询
两阶段生成:先答案,后标注
"""
# 第一阶段:检索相关文档
retrieved_docs = self.vectorstore.similarity_search(question, k=k)
# 提取上下文
context_text = self._format_context(retrieved_docs)
# 第二阶段:生成答案(带来源约束)
answer_prompt = ChatPromptTemplate.from_messages([
("system", """你是专业的问答助手。请根据提供的上下文回答问题。
重要规则:
1. 只使用上下文中的信息,不要使用外部知识
2. 每个事实性陈述后标注来源,格式为[第X页]
3. 如果上下文不足以回答,明确说明"信息不足"
4. 不要编造或推测任何内容
上下文:
{context}"""),
("human", "{question}")
])
answer_chain = answer_prompt | self.llm | StrOutputParser()
answer_text = answer_chain.invoke({
"context": context_text,
"question": question
})
# 第三阶段:验证并提取引用
citations = self._extract_citations(answer_text, retrieved_docs)
# 第四阶段:置信度评估
confidence_scores = self._evaluate_confidence(
answer_text,
retrieved_docs
)
return CitedAnswer(
answer_text=answer_text,
citations=citations,
confidence_scores=confidence_scores
)
def _format_context(self, docs: List[Document]) -> str:
"""格式化检索结果为上下文"""
formatted = []
for i, doc in enumerate(docs, 1):
# 保留原文中的页码标记
formatted.append(f"[文档{i}]\n{doc.page_content}\n")
return "\n".join(formatted)
def _extract_citations(
self,
answer: str,
docs: List[Document]
) -> List[Dict]:
"""从答案中提取引用信息"""
# 正则匹配页码引用
pattern = r'\[第(\d+)页\]'
matches = re.findall(pattern, answer)
citations = []
for page_str in matches:
page = int(page_str)
# 查找对应的文档片段
matching_docs = [
d for d in docs
if d.metadata.get('page') == page
]
if matching_docs:
citations.append({
"page": page,
"source_count": len(matching_docs),
"preview": matching_docs[0].page_content[:100] + "..."
})
return citations
def _evaluate_confidence(
self,
answer: str,
docs: List[Document]
) -> List[float]:
"""
评估答案各部分的置信度
使用句子级比对
"""
# 简化实现:基于引用密度的启发式评估
sentences = re.split(r'[。!?.!?]', answer)
scores = []
for sent in sentences:
if not sent.strip():
continue
# 检查是否有引用标记
has_citation = bool(re.search(r'\[第\d+页\]', sent))
# 检查是否声明信息不足
is_uncertain = "信息不足" in sent or "无法" in sent
if is_uncertain:
scores.append(1.0) # 诚实声明不确定性
elif has_citation:
scores.append(0.8) # 有引用,较高置信度
else:
scores.append(0.5) # 无引用,中等置信度
return scores
# ================== 幻觉检测器 ==================
class HallucinationDetector:
"""
基于 NLI 的幻觉检测
验证生成内容与检索上下文的一致性
"""
def __init__(self):
# 使用轻量级 NLI 模型
# 实际应用中可替换为更大模型
pass
def check_claim(
self,
claim: str,
context: str
) -> float:
"""
检查陈述与上下文的一致性
返回置信度分数(0-1)
"""
# 简化实现:基于关键词覆盖
# 实际应用应使用 NLI 模型
claim_words = set(claim.lower().split())
context_words = set(context.lower().split())
# 计算重叠率
overlap = claim_words & context_words
coverage = len(overlap) / max(len(claim_words), 1)
# 归一化到 0-1
return min(coverage * 2, 1.0)
def verify_answer(
self,
answer: str,
context: str
) -> Dict:
"""验证完整答案"""
sentences = re.split(r'[。!?.!?]', answer)
results = []
for sent in sentences:
if not sent.strip():
continue
score = self.check_claim(sent, context)
results.append({
"sentence": sent,
"confidence": score,
"status": "高置信" if score > 0.7 else "低置信"
})
return {
"sentence_results": results,
"overall_confidence": np.mean([r["confidence"] for r in results])
}
# ================== 使用示例 ==================
if __name__ == "__main__":
# 构建示例文档
sample_text = """
[第1页]
人工智能是计算机科学的一个分支,旨在创建能够模拟人类智能的系统。
机器学习是人工智能的核心技术之一。
[第2页]
深度学习是机器学习的子领域,使用多层神经网络处理数据。
卷积神经网络(CNN)是深度学习的重要架构。
[第3页]
自然语言处理(NLP)使计算机能够理解和生成人类语言。
Transformer 架构彻底改变了 NLP 领域。
"""
# 简化的页码映射
page_mappings = {
1: (0, 100),
2: (100, 200),
3: (200, 300)
}
# 实际应用时使用真正的文档和页码映射
# rag = HallucinationResistantRAG()
# rag.index_documents(documents)
# result = rag.query_with_citations("什么是深度学习?")
# print(result)
关键代码行解析:
marked_content = f"[来源:第{page_number}页]\n{chunk}":在文档切片中注入页码标记,这是确保引用准确的核心机制。LLM 在生成时会复制这些标记,形成内容与来源的绑定。
has_citation = bool(re.search(r'\[第\d+页\]', sent)):检测句子是否包含引用标记。无引用的句子被视为低置信度,可能需要人工审核。
if "信息不足" in sent: scores.append(1.0):模型诚实承认不确定性时给予高置信度。这鼓励模型"不知为不知",避免编造。
overlap = claim_words & context_words:计算陈述与上下文的词汇重叠。完全基于上下文的陈述重叠率高,编造内容的重叠率低。
优化建议总结:
- 在文档切分时注入页码标记,确保来源可追溯
- 使用两阶段生成:先生成答案,再匹配引用
- 引入 NLI 模型验证内容与上下文的一致性
- Prompt 中明确要求模型在信息不足时声明
- 为用户提供置信度指标,帮助判断可信度