← 返回上一页

第5章: 「智阅」论文知识库助手

基于 LangChain + RAG 实现论文/笔记知识库问答系统

🎯 项目目标:构建「智阅」论文知识库助手

本章带你完成 阶段 2:「智阅」论文知识库助手 项目: 基于 LangChain + 通义千问 构建一个具备文档智能处理、向量检索、多源材料联动、检索质量优化的论文学习知识库问答系统。

✅ 完成本项目你将掌握以下7大核心能力

  • 1、RAG 架构设计与链路编排:基于 LangChain 设计 RAG 检索增强架构,掌握文档加载 → 内容提取 → 智能切分 → 向量化 → 向量存储 → 相似度检索 → 增强生成的完整链路。
  • 2、文档智能处理引擎:实现多格式文档加载(PyPDFLoader/TextLoader/UnstructuredLoader),支持 PDF、Word、Markdown、TXT 等格式;掌握按字符/语义/段落等不同切分策略。
  • 3、向量检索系统构建:使用 DashScope/BGE Embeddings 进行文本向量化;掌握 Chroma/Qdrant/Milvus 向量库的存储与检索;实现相似度搜索、元数据过滤、MMR 多样性检索等高级能力。
  • 4、多源知识库联动:对接论文原文、综述/博客解读、实验复现笔记等多来源材料,实现"问题-证据-结论"的知识融合;支持知识库增量更新与自动同步。
  • 5、检索质量优化体系:实现 Top-K 召回 + 重排序(Rerank/BGE-Reranker)的两阶段检索;建立 Recall@K、MRR、nDCG 等评测指标体系。
  • 6、上下文增强与引用溯源:实现上下文压缩(摘要/关键句提取)、引用溯源(来源标注)、多文档融合策略;生成带来源引用的可信答案。
  • 7、业务价值量化与复盘:对比"有无 RAG"的效果差异(准确率提升、幻觉降低);建立用户满意度、问答准确率、检索 latency 等业务指标。

🧩 7 大核心能力详解(结构化讲解 + 内嵌 SVG 证据链)

这一节把“智阅”的 8 大能力拆成能看懂、能复现、能验收的颗粒度:每条能力都给你一张图(内嵌 SVG)、一个论文学习场景案例、一段最小代码,以及一条验收方法。

统一业务场景:10 篇大语言模型必读论文(PDF 合集)的学习问答
典型提问:“Transformer 最早是哪篇论文提出的?”“那篇论文的核心贡献是什么?”“这 10 篇里哪些论文讨论了检索增强(RAG)?”。 目标不是"回答得像",而是回答必须可追溯、有引用来源、文档未覆盖要能明确说无
🏗️
RAG 架构
基础 · 必会
✂️
文档切分
基础 · 必会
🔍
向量检索
进阶 · 加分
📚
多源融合
进阶 · 加分
🎯
Rerank 精排
高级 · 亮点
📎
引用溯源
高级 · 亮点
🖥️
产品化封装
高级 · 亮点
📊
价值量化
高级 · 亮点
1)RAG 架构设计:把“文档”变成“可检索的知识”
目标:你能用一张图讲清完整链路:文档加载 → 智能切分 → 向量化 → 向量库存储 → 相似度检索 → 上下文组装 → 增强生成。 并且能用代码跑通“上传 PDF → 提问 → 看到带引用的回答”。
为什么重要:论文学习中最怕"AI 胡说"。RAG 是唯一能让模型"只根据你给的文档回答"的成熟方案。 没有 RAG,模型会凭记忆编造;有了 RAG,你才能做到“答案必须带来源,无答案明确说无”。 这也是“论文学习/知识复盘”场景的最低可信要求:必须能指到原文页码与段落。
典型场景:学习者问“Transformer 论文的核心贡献是什么?”系统必须从论文原文里找到对应段落并标注页码,而不是凭“常识”泛泛而谈。
🎯 核心思想:自适应检索策略
1)从小的 k 开始检索,节省成本
每次向量检索都要计算相似度,k 值越大,计算成本越高。
优化思路:先尝试 k=6,如果覆盖度够就停止,避免不必要的计算开销。

2)如果覆盖度不够,逐步扩大 k
简单问题可能 k=4 就够,复杂问题可能需要 k=20+。
动态调整:通过逐步试探,找到满足覆盖度要求的最小 k 值。

⚠️ 覆盖率算法的局限性
本示例使用页覆盖率(覆盖了多少不同页面)作为停止条件,这只是为了演示方便。
更合理的覆盖率算法:
语义覆盖率:基于问题关键词在检索结果中的覆盖程度
实体覆盖率:问题中的人名/地名/时间等实体是否都被检索到
主题覆盖率:问题涉及的多个子主题是否都有对应证据
相似度阈值:检索结果的平均相似度是否达到预期

💡 生产环境建议
• 对于事实型问题("谁/何时/何地"),页覆盖率够用
• 对于分析型问题("为什么/如何比较"),需要语义覆盖率
• 可以结合多指标综合判断:覆盖率 + 相似度 + 结果数量
文档加载 智能切分 向量化 向量数据库 查询改写 向量检索 重排序 上下文组装 LLM生成 带引用答案 文档处理阶段 检索增强生成阶段 • Rewrite: 解决指代消解 • Rerank: 提升检索精度 • 引用: 确保答案可溯源
可复现对话示例(论文学习场景):
用户:“Transformer 最早是哪篇论文提出的?”
系统检索到论文合集中的相关段落(例如《Attention Is All You Need》引言/贡献点)。
系统回答:“Transformer 最早由《Attention Is All You Need》提出,其核心是用自注意力替代 RNN/CNN(来源:原文第 X 页)。”
关键点:答案必须带来源,且内容与原文一致。
python
# =============================================================================
# RAG 自适应检索演示
# =============================================================================
# 功能:将 PDF 文档转换为可检索的知识库,通过自适应检索策略回答问题
# 特点:PDF清洗、智能切分、MMR检索、自适应扩大k、去重处理
# =============================================================================

# 导入必要的库
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

from langchain_community.chat_models import ChatTongyi
from langchain_community.document_loaders import PyMuPDFLoader
from langchain_community.vectorstores import Chroma
from langchain_community.embeddings import DashScopeEmbeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter
import re
import os


# =============================================================================
# 1. 环境检查
# =============================================================================

def check_api_config():
    """检查通义千问 API 配置是否正确"""
    api_key = os.getenv("DASHSCOPE_API_KEY")
    
    if not api_key:
        print("❌ 错误:未找到 DASHSCOPE_API_KEY 环境变量")
        print("💡 请设置环境变量:")
        print("   export DASHSCOPE_API_KEY='your_api_key_here'")
        print("   或在运行脚本前执行上述命令")
        return False
    
    if api_key.startswith("sk-") and len(api_key) > 20:
        print("✅ API Key 配置正确")
        return True
    else:
        print("⚠️  警告:API Key 格式可能不正确")
        print("   正确格式应该以 'sk-' 开头且长度大于 20")
        return True  # 仍然尝试运行,但给出警告

# 检查 API 配置
api_ok = check_api_config()
if not api_ok:
    print("🛑 由于 API 配置问题,程序将退出")
    exit(1)

# =============================================================================
# 2. 初始化配置
# =============================================================================

# 初始化 LLM 模型(温度为 0 保证输出稳定)
llm = ChatTongyi(model="qwen-turbo", temperature=0)

# 加载 PDF 文档
pdf_path = "../data/sample_document.pdf"  # 替换成你的 PDF 路径
docs = PyMuPDFLoader(pdf_path).load()
pdf_pages = len(docs)  # 记录总页数,用于后续覆盖率计算

print(f"📄 加载 PDF: {pdf_path}")
print(f"📊 总页数: {pdf_pages}")


# =============================================================================
# 3. 文本清洗函数
# =============================================================================

def clean_pdf_text(text: str) -> str:
    """清洗PDF抽取文本,减少断行/断字对切分与检索的影响。
    
    主要处理:
    1. 统一换行符格式
    2. 合并英文断词(如 trans-\nformer -> transformer)
    3. 合并硬换行为空格(保留段落空行)
    4. 压缩多余空白字符
    """
    t = text

    # 统一换行:Windows/Unix 换行符统一为 \n
    t = t.replace("\r\n", "\n").replace("\r", "\n")

    # 合并英文断词:处理 PDF 中常见的单词换行问题
    # 例如:trans-\nformer -> transformer
    t = re.sub(r"([A-Za-z])\-\n([A-Za-z])", r"\1\2", t)

    # 合并硬换行:将非段落换行(非连续换行)替换为空格
    # 保留段落空行(连续换行)用于后续切分
    t = re.sub(r"(? {cleaned_length} 字符")

# 使用递归字符分割器进行文档切分
print("\n✂️  开始文档切分...")
chunks = RecursiveCharacterTextSplitter(
    chunk_size=800,      # 每个 chunk 的最大字符数
    chunk_overlap=120,  # 相邻 chunk 之间的重叠字符数,确保信息不丢失
    is_separator_regex=False,  # 使用普通字符串分隔符(非正则)
    keep_separator=True,      # 保留分隔符在 chunk 中,提高语义完整性
    separators=["\n\n", "。", "!", "?", ";", ",", "\n", " "],  # 分隔符优先级列表(从高到低)
).split_documents(docs)

print(f"✅ 切分完成:{len(chunks)} 个 chunks")
print(f"   平均 chunk 长度: {sum(len(c.page_content) for c in chunks) // len(chunks)} 字符")

# =============================================================================
# 5. 向量库构建
# =============================================================================

# 初始化向量嵌入模型
print("\n🔤 初始化向量嵌入模型...")
embeddings = DashScopeEmbeddings(model="text-embedding-v1")

# 创建内存向量库(不持久化),避免反复运行时向量重复写入
# 这样可以确保每次运行都是干净的检索结果
print("🏗️  构建向量库...")
db = Chroma.from_documents(chunks, embeddings)
print("✅ 向量库构建完成")

# =============================================================================
# 6. 问题定义
# =============================================================================

# 定义要查询的问题(可以替换为任何你想问的问题)
question = "大语言模型原理有哪些论文?"
print(f"\n❓ 用户问题: {question}")


# =============================================================================
# 7. 自适应检索函数
# =============================================================================

def adaptive_retrieve(question: str, vectorstore, *, total_pages: int):
    """通用的"自适应扩大检索"策略。

    核心思想:
    1. 从小的 k 开始检索,节省成本
    2. 如果覆盖度不够,逐步扩大 k
    3. 使用页覆盖率作为停止条件(默认 80%)
    4. 使用 MMR 检索提高结果多样性
    
    参数说明:
    - question: 用户问题
    - vectorstore: 向量数据库
    - total_pages: 文档总页数,用于计算覆盖率
    
    返回:
    - 包含检索结果和统计信息的字典
    """
    print("🔍 开始自适应检索...")
    
    # 定义 k 的候选值:从小到大,逐步扩大
    k_candidates = [6, 8, 12, 16, 24]
    # 确保 len(chunks) 也在候选列表中(极端情况)
    if len(chunks) not in k_candidates:
        k_candidates.append(len(chunks))
    k_candidates = sorted(set(k_candidates))
    
    # 页覆盖率目标:覆盖 >= 80% 的页面即可停止
    page_cover_target = 0.8
    print(f"   覆盖率目标: {page_cover_target:.0%}")

    best = None
    # 逐步尝试更大的 k 值,直到满足覆盖度要求
    for i, k in enumerate(k_candidates, 1):
        print(f"   尝试 k={k} ({i}/{len(k_candidates)})...")
        
        # fetch_k 通常设为 k 的 3 倍,但有最小值 24
        fetch_k = min(max(k * 3, 24), len(chunks))
        
        # 创建 MMR 检索器
        # MMR(Maximal Marginal Relevance)在相关性和多样性之间平衡
        retriever = vectorstore.as_retriever(
            search_type="mmr",
            search_kwargs={
                "k": min(k, len(chunks)),          # 最终返回的结果数
                "fetch_k": fetch_k,                 # 候选池大小
                "lambda_mult": 0.5,                # 多样性权重(0=纯相关,1=纯多样)
            },
        )
        docs = retriever.invoke(question)

        # 去重处理:避免相同页面和内容的重复 chunk
        # 这在短文档或强关键词查询时尤其重要
        deduped = []
        seen = set()
        for d in docs:
            key = (d.metadata.get("page"), d.page_content)
            if key in seen:
                continue
            seen.add(key)
            deduped.append(d)
        docs = deduped
        
        # 计算页覆盖率:检索结果覆盖了多少不同的页面
        pages = {d.metadata.get("page") for d in docs if "page" in d.metadata}
        pages = {p for p in pages if p is not None}
        page_cover = (len(pages) / total_pages) if total_pages else 0.0
        
        # 构建上下文(用于 LLM 回答)
        context = "\n\n".join(d.page_content for d in docs)
        
        print(f"检索到 {len(docs)} 个文档,覆盖 {len(pages)} 页 ({page_cover:.1%})")
        
        # 保存当前迭代的结果
        best = {
            "k": min(k, len(chunks)),
            "fetch_k": fetch_k,
            "docs": docs,
            "pages_covered": len(pages),
            "page_cover": page_cover,
            "context_chars": len(context),
        }
        
        # 如果页覆盖率达到目标,停止扩大 k
        if page_cover >= page_cover_target:
            print("✅ 达到覆盖率目标")
            break
    
    print(f"🎯 最终: k={best['k']}, 覆盖 {best['pages_covered']} 页")
    return best


# =============================================================================
# 8. 执行检索
# =============================================================================

result = adaptive_retrieve(question, db, total_pages=pdf_pages)
docs = result["docs"]

# 输出关键统计
print(f"\n📊 检索到 {len(docs)} 个文档,覆盖 {result['pages_covered']}/{pdf_pages} 页 ({result['page_cover']:.1%})")

# 构建最终上下文
context = "\n\n".join(doc.page_content for doc in docs)

# 打印检索结果预览(精简)
print("\n📋 检索结果:")
for i, d in enumerate(docs[:3], 1):  # 只显示前3个
    page = d.metadata.get("page")
    preview = d.page_content[:100].replace("\n", " ")
    print(f"   [{i}] 页{page}: {preview}...")

# =============================================================================
# 9. LLM 问答
# =============================================================================

prompt = f"""请仅根据上下文回答问题。

要求:
1) 优先从上下文中抽取事实作答;
2) 不要编造未出现在上下文中的论文/链接。

上下文:
{context}

问题:{question}
"""

print("\n🤖 正在调用通义千问...")
try:
    answer = llm.invoke(prompt)
    print("\n💬 回答:")
    print(answer.content)
except Exception as e:
    print(f"❌ 调用失败: {e}")
    answer = type('MockAnswer', (), {'content': '抱歉,当前无法调用大语言模型。请检查 API 配置后重试。'})()


print(f"\n🎯 RAG 演示完成")
print(f"📝 问题: {question}")
print(f"📄 覆盖: {result['pages_covered']}/{pdf_pages} 页")
print(f"📊 检索: {len(docs)} 个片段")
✅ 验证方法(分步走)
1️⃣ 检索验证:用问题“Transformer 最早是哪篇论文提出的?”检索,检查返回的 docs 是否包含对应论文标题/贡献点段落。
2️⃣ 生成验证:把检索结果拼成 context,送给 LLM,看答案是否与原文一致且带来源。
3️⃣ 对比验证:同一问题分别用“纯 LLM”与“RAG”回答,RAG 必须更贴近文档且能标注来源。
💎 面试亮点:纯 LLM vs RAG 效果对比
❌ 纯 LLM(无 RAG)
问:「Transformer 最早是哪篇论文提出的?」
答:「大概是 Google 在 2017 年提出的...」
→ 凭记忆编造,无来源,可能错误
✅ RAG(检索增强生成)
问:「Transformer 最早是哪篇论文提出的?」
答:「《Attention Is All You Need》(来源:原文第X页)」
→ 基于文档检索,有引用来源
💡 面试时用这个对比,30 秒讲清 RAG 的核心价值:让 AI 从"猜答案"变成"查资料再回答"
2)文档处理:切分策略决定召回质量
目标:你能解释 chunk_size/chunk_overlap 如何影响“命中率”与“上下文完整性”,并能用实验证明 separators 与硬切的实际效果差异——句末命中率 100% vs 25%
为什么重要:切得太大:一个 chunk 包含多个主题,检索时“噪声太多”,命中困难;
切得太小:关键信息被拆散,模型看到的上下文不完整,容易答错或断章取义。
关键认知:separators 的价值不是改变片段数,而是让切分边界落在自然语言边界(句号/段落),提升每个 chunk 的语义完整性,从而提升 embedding 质量和检索命中率。
典型场景:网页正文、清洗后的纯文本、OCR 结果等连续文本中,硬切经常把句子从中间切断,导致 chunk 语义残缺,embedding 偏差,检索时语义不匹配。
🔍 关键认知:separators 的真正价值 + 一个常见陷阱
价值点:separators 保证"边界质量"——尽量在句号等自然语言边界切分,让每个 chunk 语义更完整,embedding 更准确,召回率更高。
常见陷阱:默认 keep_separator=True 会把分隔符留在下一个 chunk 的开头,而不是当前 chunk 的结尾。这导致句末命中率统计失真,看起来 separators 没起作用。
正确做法:is_separator_regex=True + 零宽断言 (?<=。),让切分点落在句号之后,句号自然留在当前 chunk 结尾,命中率可达 100%。
python
import re
from langchain_community.document_loaders import PyMuPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter


pdf_path = "sample_document.pdf"  # 替换成你的 PDF 路径
docs = PyMuPDFLoader(pdf_path).load()

# 将 PDF 全文拼成一个连续文本,用于演示切分策略差异
text = "\n".join((d.page_content or "").strip() for d in docs)
print(f"PDF 页数: {len(docs)}")
print(f"原始文本长度: {len(text)} 字符")

# ==================== 智能切分:用零宽断言让句号留在 chunk 结尾 ====================
# 关键点:is_separator_regex=True + (?<=。) 表示"在句号之后切分"
# 这样句号留在当前 chunk 结尾,而不是被推到下一个 chunk 开头(默认行为的陷阱)
splitter_smart = RecursiveCharacterTextSplitter(
    chunk_size=120,
    chunk_overlap=0,
    is_separator_regex=True,
    keep_separator=False,
    separators=[
        "(?<=。)",   # 在句号之后切分(句号留在当前 chunk 结尾)
        "(?<=!)",   # 在感叹号之后切分
        "(?<=?)",   # 在问号之后切分
        "(?<=,)",   # 在逗号之后切分(次优先级)
        " ",         # 空格(最低优先级)
    ],
)

# ==================== 硬切:不关心语言边界,纯按长度截断 ====================
splitter_hard = RecursiveCharacterTextSplitter(
    chunk_size=120,
    chunk_overlap=0,
    separators=[],
    keep_separator=False,
)

chunks_smart = splitter_smart.split_text(text)
chunks_hard  = splitter_hard.split_text(text)

print(f"智能切分: {len(chunks_smart)} 个片段")
print(f"硬切:     {len(chunks_hard)} 个片段")

# ==================== 对比分析 ====================
def analyze(chunks, label):
    print(f"\n=== {label} ===")
    for i, c in enumerate(chunks, 1):
        print(f"片段{i:2d} len={len(c):3d}  结尾: ...{c[-20:]!r}")
    enders = ("。", "!", "?")
    hit = sum(c.endswith(enders) for c in chunks)
    print(f"句末命中率: {hit}/{len(chunks)} = {hit/len(chunks):.0%}")

analyze(chunks_smart, "智能切分(零宽断言 separators)")
analyze(chunks_hard,  "硬切(无 separators)")

# ==================== 结论 ====================
enders = ("。", "!", "?")
s_hit = sum(c.endswith(enders) for c in chunks_smart)
h_hit = sum(c.endswith(enders) for c in chunks_hard)
print(f"\n=== 最终结论 ===")
print(f"  智能切分句末命中率: {s_hit}/{len(chunks_smart)} = {s_hit/len(chunks_smart):.0%}  ← 每个 chunk 在句号处结束,语义完整")
print(f"  硬切句末命中率:     {h_hit}/{len(chunks_hard)} = {h_hit/len(chunks_hard):.0%}  ← chunk 经常切在句子中间,语义残缺")
📊 实际运行输出(可复现)
智能切分: 4 个片段   硬切: 4 个片段

=== 智能切分 ===
片段 1 len= 99   结尾: ...'理的切分策略是整个系统成败的关键因素之一'
片段 2 len=100   结尾: ...'是片段经常在句子中间被截断,语义残缺不全'
片段 3 len=100   结尾: ...'不到句号,也会退而求其次在逗号处切分文本'
片段 4 len= 66   结尾: ...'智能切分参数在检索系统中的核心价值所在'
句末命中率: 4/4 = 100%

=== 硬切 ===
片段 1 len=120   结尾: ...'能切分参数的作用是让切分边界落在自然语言(句子被截断)'
片段 2 len=120   结尾: ...'显下降。语义表示质量下降会导致检索命中率(句子被截断)'
片段 3 len=120   结尾: ...'回。这就是智能切分参数在检索系统中的核心(句子被截断)'
片段 4 len= 5   结尾: ...'价值所在。'
句末命中率: 1/4 = 25%
✅ 验证方法
1️⃣ 运行代码:直接复制运行,观察"最终结论"一行,智能切分句末命中率应为 100%,硬切为 25%。
2️⃣ 观察结尾字符:每个智能切分片段的结尾应是 ,硬切片段结尾是随机汉字(句子中间)。
3️⃣ 调整参数验证:chunk_size 改成 80 或 200,命中率仍应保持 100% vs 低命中率的对比。
关键指标:智能切分句末命中率 100%,硬切 25%;两者差距越大,说明 separators 价值越显著。
⚠️ 踩坑提醒:文本切分 4 个常见错误
错误 1:直接写 separators=["。"] 而不用零宽断言
默认 keep_separator=True 会把 推到下一个 chunk 开头,导致当前 chunk 结尾没有句号,句末命中率统计失真,误以为 separators 没起作用。
正确做法:is_separator_regex=True + "(?<=。)",让句号留在当前 chunk 结尾。

错误 2:文本里混有英文词时用中文标点 separators
英文词(如 embeddingchunk)会让切分器在 chunk_size 附近找不到中文句号,退化成硬切。
正确做法:纯中文文本效果最好;混合文本需要额外处理英文词边界。

错误 3:chunk_size 过大(如 2000)
即使有 separators,一个 chunk 仍包含多个段落,检索噪声太多,命中率反而下降。

错误 4:separators 用英文标点处理中文文档
separators=["."] 对中文文档完全无效,整篇文章变成一个 chunk。
💎 separators 写法对比(面试高频考点)
写法 句号位置 句末命中率 推荐
separators=[](硬切) 随机位置 ~25% ❌ 不推荐
separators=["。"](默认 keep) 下一段开头 ~25%(统计失真) ⚠️ 有陷阱
separators=["(?<=。)"] + regex ⭐ 当前段结尾 100% ✅ 推荐
�� 核心结论:用零宽断言 (?<=。) + is_separator_regex=True 是让句号留在 chunk 结尾的正确姿势,句末命中率可从 25% 提升到 100%,直接提升 embedding 质量和检索召回率。
3)向量检索:Top-K + 过滤 + 多样性(MMR)
目标:你能解释“相似度检索”的原理(Embeddings + 余弦距离),并能用实验证明 MMR 能提升召回多样性,避免同义问题只返回重复段落。
为什么重要:论文学习问答最常见的问题是“同义问法命中率低”。
例如“Transformer 的贡献是什么/核心创新点是什么/主要贡献点有哪些”其实问的是同一件事,但如果只用相似度检索,可能只返回最相似的那一个段落,导致答案不完整。
MMR(最大边际相关性)能在“相关性”和“多样性”之间平衡,让 Top-K 结果覆盖更多相关段落。
典型场景:论文同一主题可能分散在“摘要/引言/方法/实验”。用户问“Transformer 的创新点有哪些?”,系统应该召回“自注意力、位置编码、多头注意力”等多个相关段落,而不是只返回其中一处。
python
from langchain_community.vectorstores import Chroma
from langchain_community.embeddings import DashScopeEmbeddings

from langchain_community.document_loaders import PyMuPDFLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter


# 最小可运行初始化:加载 PDF → 切分 → 建 Chroma
pdf_path = "sample_document.pdf"  # 替换成你的 PDF 路径
loader = PyMuPDFLoader(pdf_path)
docs = loader.load()
# is_separator_regex=True + 零宽断言:切分点落在句号之后,句号留在当前 chunk 结尾
# keep_separator=False:分隔符已通过零宽断言保留在 chunk 结尾,不需要额外保留
chunks = RecursiveCharacterTextSplitter(
    chunk_size=800,
    chunk_overlap=120,
    is_separator_regex=True,
    keep_separator=False,
    separators=["(?<=。)", "(?<=!)", "(?<=?)", "(?<=;)", "(?<=,)", " "],
).split_documents(docs)

embeddings = DashScopeEmbeddings(model="text-embedding-v1")
db = Chroma.from_documents(chunks, embeddings, persist_directory=".chroma/multi_source_demo")

# 简单检索示例
question = "Transformer 的核心贡献是什么?"
retriever_sim = db.as_retriever(search_kwargs={"k": 4})
docs_sim = retriever_sim.invoke(question)

# MMR 检索示例
retriever_mmr = db.as_retriever(search_type="mmr", search_kwargs={"k": 4, "fetch_k": 20, "lambda_mult": 0.5})
docs_mmr = retriever_mmr.invoke(question)

print(f"相似度检索返回 {len(docs_sim)} 个片段")
for i, d in enumerate(docs_sim, 1):
    src = (d.metadata or {}).get("source", "unknown")
    page = (d.metadata or {}).get("page", None)
    preview = d.page_content[:80].replace("\n", " ")
    print(f"  #{i} source={src} page={page} preview={preview}...")

print(f"\nMMR 检索返回 {len(docs_mmr)} 个片段")
for i, d in enumerate(docs_mmr, 1):
    src = (d.metadata or {}).get("source", "unknown")
    page = (d.metadata or {}).get("page", None)
    preview = d.page_content[:80].replace("\n", " ")
    print(f"  #{i} source={src} page={page} preview={preview}...")
✅ 验证方法(同义问法测试)
1️⃣ 准备同义问法组:“Transformer 的核心贡献是什么/Transformer 的创新点是什么/这篇论文的主要贡献点有哪些/该方法相比 RNN 的关键改进是什么”。
2️⃣ 对比检索结果:分别用 similarity 和 mmr 检索,记录 Top-3 中是否覆盖“贡献点列表/关键结构(自注意力、多头、位置编码)/实验结论”这三类段落。
3️⃣ 评估覆盖率:计算每种方法的“段落覆盖率”(覆盖的段落数/总相关段落数)。
关键指标:MMR 的覆盖率应 ≥ 80%,similarity 可能只有 40%
💎 Similarity vs MMR 检索结果可视化对比
Similarity(纯相似度)
Top-1: 贡献点:自注意力替代 RNN
Top-2: 贡献点:并行训练与效率 重复
Top-3: 贡献点:注意力机制优势 重复
Top-4: 贡献点:结构简化 重复
覆盖 1/3 相关段落 = 33%
MMR(最大边际相关性)
Top-1: 贡献点:自注意力替代 RNN
Top-2: 结构:多头注意力与残差层归一化 ✓ 新信息
Top-3: 位置编码与序列建模方式 ✓ 新信息
Top-4: 实验结论:BLEU/速度对比 ✓ 新信息
覆盖 4/4 相关段落 = 100%
💡 面试关键句:"MMR 的本质是在相关性和多样性之间做平衡——lambda_mult=0.5 表示各占一半,越小越多样。"
4)多源知识库:论文原文 + 综述解读 + 实验笔记融合
目标:你能把“分散的学习材料”(论文原文、综述/博客解读、实验复现笔记)统一入库,并在答案中标注来源,让读者知道“这条信息来自哪份材料”。
为什么重要:论文学习资料往往分散在多个地方:原文是权威证据,综述是高层概括,笔记是实践细节。
如果只用单一来源,答案会不完整;如果融合但不标注来源,读者无法信任结论。
多源融合是“论文学习助手”的核心价值
典型场景:用户问"公司年假制度是什么?"。Java 基础教程可能不包含相关信息,但公司手册笔记会给出具体规定。系统需要从多个来源中找到最相关的答案,并标注来源。
python
from langchain_community.document_loaders import PyMuPDFLoader, TextLoader
from langchain_community.vectorstores import Chroma
from langchain_community.embeddings import DashScopeEmbeddings
from langchain_community.chat_models import ChatTongyi
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_text_splitters import RecursiveCharacterTextSplitter
import os
from collections import defaultdict


# 0) 环境变量(建议在系统环境中设置;这里只做兜底)
os.environ["DASHSCOPE_API_KEY"] = os.environ.get("DASHSCOPE_API_KEY", "your-api-key")


# 1) 多源数据准备:论文原文(PDF) + 实验笔记(txt)
paper_pdf = "data/Java编程基础.pdf"          # 替换为你的论文PDF路径
note_txt = "data/company_handbook.txt"       # 替换为你的笔记txt路径

paper_docs = PyMuPDFLoader(paper_pdf).load()
note_docs = TextLoader(note_txt, encoding="utf-8").load()


# 2) 给每条文档打 metadata(让“融合 + 引用”可验收)
def _tag(docs, source_type, title_prefix):
    for d in docs:
        d.metadata = d.metadata or {}
        d.metadata["source_type"] = source_type  # PAPER/REVIEW/NOTE
        d.metadata["title"] = title_prefix
        d.metadata["source"] = d.metadata.get("source", "unknown")
    return docs


paper_docs = _tag(paper_docs, "PAPER", "Java编程基础")
note_docs = _tag(note_docs, "NOTE", "公司手册笔记")


# 3) 统一切分 + 入库
# 零宽断言确保句号留在 chunk 结尾,提升融合的语义质量
splitter = RecursiveCharacterTextSplitter(
    chunk_size=800,
    chunk_overlap=120,
    is_separator_regex=True,
    keep_separator=False,
    separators=["(?<=。)", "(?<=!)", "(?<=?)", "(?<=;)", "(?<=,)", " "],
)
all_chunks = splitter.split_documents(paper_docs + note_docs)

embeddings = DashScopeEmbeddings(model="text-embedding-v1")
db = Chroma.from_documents(all_chunks, embeddings, persist_directory=".chroma/multi_source")


def retrieve(question: str, k: int = 8):
    docs = db.as_retriever(search_kwargs={"k": k}).invoke(question)
    by_source = defaultdict(list)
    for d in docs:
        st = (d.metadata or {}).get("source_type", "UNKNOWN")
        by_source[st].append(d)
    return docs, by_source


def build_context(docs, top_n=6):
    picked = docs[:top_n]
    ctx = "\n\n".join(
        [
            f"[source_type={d.metadata.get('source_type')} title={d.metadata.get('title')} source={d.metadata.get('source')}]\n{d.page_content}"
            for d in picked
        ]
    )
    cites = []
    for d in picked:
        cites.append(
            f"{d.metadata.get('source_type')}|{d.metadata.get('title')}|{d.metadata.get('source')}"
        )
    return ctx, sorted(set(cites))


def answer_with_citations(question: str):
    llm = ChatTongyi(model="qwen-turbo", temperature=0)
    retrieved, by_source = retrieve(question, k=10)
    context, cites = build_context(retrieved, top_n=6)

    prompt = ChatPromptTemplate.from_template(
        """你是一个严谨的论文学习助手。只基于给定上下文回答;若上下文不足以支持结论,请回答“不知道”。
回答要求:
1) 用要点回答;2) 对每个要点给出引用(从上下文的source信息中选);3) 末尾给【引用】列表。

上下文:
{context}

问题:{question}
"""
    )

    chain = prompt | llm | StrOutputParser()

    answer = chain.invoke({"context": context, "question": question})
    return answer, by_source, cites


if __name__ == "__main__":
    questions = [
        "Java 的核心特性有哪些?请分别结合基础教程和手册要点给出答案,并标注引用。",
        "公司年假制度是什么??"
    ]
    
    for idx, q in enumerate(questions, 1):
        print(f"\n{'='*50}")
        print(f"问题 {idx}: {q}")
        print('='*50)
        
        ans, grouped, cites = answer_with_citations(q)

        print("\n=== 检索命中(按来源分组)===")
        for st, ds in grouped.items():
            print(f"\n[{st}] hits={len(ds)}")
            for i, d in enumerate(ds[:2], 1):
                src = (d.metadata or {}).get("source", "unknown")
                print(f"- #{i} source={src} preview={d.page_content[:90]}...")

        print("\n=== 回答 ===")
        print(ans)
        print("\n【引用】")
        for c in cites:
            print("-", c)
✅ 验证方法(多源融合测试)
1️⃣ 准备多源问题:选 3 个问题,每个问题在论文原文、综述解读、实验笔记中都有相关信息。
2️⃣ 验证来源标注:检索结果必须包含至少 2 个不同来源,且每个结果都有 source 和 title。
3️⃣ 验证答案融合:生成的答案必须综合多个来源的信息,而不是只来自单一文档。
关键指标:多源覆盖率 ≥ 60%,来源标注准确率 100%
💎 面试亮点:metadata 设计是多源融合的关键
面试时不要只说"我把多个文档放到一起检索了",要展示元数据设计思维
python
# 每个文档的 metadata 结构设计
metadata = {
    "source": "MANUAL",      # 来源类型:PAPER/REVIEW/NOTE
    "title": "论文综述v2.1", # 文档标题
    "chapter": "第3章",      # 章节定位
    "page": 15,              # 页码
    "authority": "high",     # 权威等级:high/medium/low
    "update_date": "2024-03" # 更新日期(新旧文档冲突时用)
}
💡 通过 authority 字段区分权威等级(论文原文 > 综述解读 > 个人笔记),冲突时优先展示高权威来源。
5)检索质量优化:Top-K + Rerank + 指标
目标:你能解释“召回(Recall)”和“排序(Rank)”是两件事,并能用实验证明 Rerank 能显著提升 Top-1 命中率,让用户第一眼看到最相关的答案。
为什么重要:论文学习用户最在意的是“第一眼看到的答案是否正确”。
如果 Top-1 不准确,用户会失去信任;如果 Top-3 才有正确答案,体验已经很差。
Rerank(重排序)专门解决“向量相似度不等于语义相关性”的问题,是提升用户体验的关键技术。
典型场景:用户问“Transformer 的创新点是什么?”。向量检索可能返回“背景动机”“模型结构”“实验结果”三个片段,但用户最需要的是“贡献点列表”。Rerank 能把“贡献点”相关段落排在第一位。
python
# ========== 你要先看懂这段代码在“干什么” ========== 
# 这段示例的核心目标:把“Top-K 召回 + Rerank”写成更工程化的三段式流水线,并且能量化对比效果。
#
# 你可以把它理解成“检索系统的三段变速箱”:
# - 第一段(快):向量召回 Top-K(范围大、速度快,但 Top-1 可能不准)
# - 第二段(中):Hybrid Rerank(用轻量规则把 Top-K 变得更干净、更像“候选集”)
# - 第三段(慢但准):LLM 精排(最贵但最准,只对少量候选打分)
#
# 最后我们会输出 3 个指标来证明“它真的更好”:
# - Top1 Hit:第 1 个结果是否命中(用户第一眼体验)
# - Recall@K:Top-K 里有没有命中(系统有没有“召回到正确证据”)
# - MRR:越早命中越好(排名质量综合指标)
#
# 注意:为了让示例“复制即可跑”,这里用“关键词命中”做弱标注(不需要你人工标注 chunk id)。
# 真实生产会用:人工标注证据片段、或 LLM-as-Judge、或离线评测集。

import os

from langchain_community.document_loaders import PyMuPDFLoader
from langchain_community.vectorstores import Chroma
from langchain_community.embeddings import DashScopeEmbeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.chat_models import ChatTongyi
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser


# ========== Step 0:准备数据(把 PDF 变成可检索的 chunk)==========
# 0) 最小可运行初始化:加载 PDF → 切分 → 建 Chroma
# - PyMuPDFLoader:把 PDF 每一页读成一个 Document
# - TextSplitter:把每页切成更小的 chunk(向量检索的最小单位)
# - Chroma:把 chunk 向量化并存储(后面才能检索)
pdf_path = "sample_document.pdf"  # 替换成你的 PDF 路径
docs = PyMuPDFLoader(pdf_path).load()

chunks = RecursiveCharacterTextSplitter(
    chunk_size=800,
    chunk_overlap=120,
    is_separator_regex=True,
    keep_separator=False,
    separators=["(?<=。)", "(?<=!)", "(?<=?)", "(?<=;)", "(?<=,)", " "],
).split_documents(docs)

os.environ["DASHSCOPE_API_KEY"] = os.environ.get("DASHSCOPE_API_KEY", "your-api-key")

embeddings = DashScopeEmbeddings(model="text-embedding-v1")
db = Chroma.from_documents(chunks, embeddings, persist_directory=".chroma/rerank_demo")

# ========== Step 1:第一阶段(Recall)==========
# 目标:先"召回"一批候选(宁可多一点,后面再筛)。
# 这里用向量相似度(Dense Retrieval)做 Top-K 召回:
# - 优点:语义相近也能召回
# - 缺点:Top-1 未必最相关(相似度不等于可回答性)
# 重要:要用带分数的检索,这样才能做混合重排序!
def recall_with_scores(query: str, k: int = 20):
    """向量检索并返回带分数的结果(用于后续混合重排序)"""
    # similarity_search_with_score 返回 (Document, score) 列表
    # 注意:Chroma 的 score 是距离(越小越相似),我们要转成相似度分数
    results_with_scores = db.similarity_search_with_score(query, k=k)
    
    docs = []
    scores = []
    for doc, distance in results_with_scores:
        docs.append(doc)
        # 把距离转成相似度分数(0~1,越大越相似)
        # 这里用简单的线性转换,实际可根据你的向量库调整
        similarity_score = 1.0 / (1.0 + distance)
        scores.append(similarity_score)
    
    return docs, scores

# ========== Step 2:第二阶段(Hybrid Rerank)==========
# 目标:在不引入重模型(不引入 torch / cross-encoder)的情况下,把“候选集”排序先变得更像样。
#
# 这里参考“混合重排序策略”的思想:
# - 信号 A:向量召回的排名(rank 越靠前,通常越可能相关)
# - 信号 B:轻量文本匹配(overlap:query 与 doc 是否有词/字面重合)
# - 规则:长度太短/太长的片段质量往往差,轻微惩罚
#
# 为什么 overlap 有用?
# - 向量检索可能把“语义相近但答不到点上”的 chunk 排很前
# - overlap 能帮助“关键词/专有名词/术语”更稳地浮上来
# 这是一个非常便宜的 rerank 手段:纯 Python,无额外依赖。
def _simple_overlap_score(query: str, doc: str) -> float:
    """轻量相关性分数(0~1,越大越相关)。

    做法:把 query 和 doc 都切成 token 集合,然后算 Jaccard 相似度:
        overlap = |A ∩ B| / |A ∪ B|

    细节:
    - 英文:按空格分词
    - 中文:用 bigram(两个字一组)近似“分词”,避免引入中文分词依赖
    """
    def _normalize(s: str) -> str:
        return (s or "").strip().lower()

    def _tokens_en(s: str):
        return [t for t in s.split() if t]

    def _tokens_zh_bigrams(s: str):
        s_no_space = "".join(s.split())
        if len(s_no_space) == 0:
            return []
        if len(s_no_space) == 1:
            return [s_no_space]
        return [s_no_space[i:i + 2] for i in range(len(s_no_space) - 1)]

    q = _normalize(query)
    d = _normalize(doc)
    if not q or not d:
        return 0.0

    q_tokens = set(_tokens_en(q) + _tokens_zh_bigrams(q))
    d_tokens = set(_tokens_en(d) + _tokens_zh_bigrams(d))
    if not q_tokens or not d_tokens:
        return 0.0

    return len(q_tokens & d_tokens) / len(q_tokens | d_tokens)


def hybrid_rerank(question: str, documents, vector_scores=None, top_k: int = 8):
    """
    混合重排序:结合向量相似度 + 轻量文本匹配 + 规则
    (参考 chapter7_retrieval.html 的实现)
    """
    # 1) 如果有向量分数,作为基础分数;否则用默认值
    if vector_scores:
        base_scores = vector_scores
    else:
        base_scores = [0.5] * len(documents)  # 默认中等分数
    
    # 2) 轻量文本匹配重排序分数
    rerank_scores = [_simple_overlap_score(question, doc.page_content) for doc in documents]
    
    # 3) 混合评分(可调整权重)
    final_scores = []
    for i, (base_score, rerank_score) in enumerate(zip(base_scores, rerank_scores)):
        # 向量分数归一化 + 重排序分数
        hybrid_score = 0.3 * base_score + 0.7 * rerank_score
        
        # 4) 规则调整
        doc = documents[i]
        
        # 长度惩罚:太短或太长的文档降分
        if len(doc.page_content) < 80:
            hybrid_score *= 0.8
        elif len(doc.page_content) > 1600:
            hybrid_score *= 0.9
            
        # 关键词加分:包含问题关键词的文档加分
        query_words = set(question.lower().split())
        doc_words = set(doc.page_content.lower().split())
        if query_words & doc_words:  # 有交集
            hybrid_score *= 1.1
            
        final_scores.append((i, hybrid_score))
    
    # 5) 排序并返回结果
    final_scores.sort(key=lambda x: x[1], reverse=True)
    return [documents[idx] for idx, _ in final_scores[:top_k]]


# ========== Step 3:第三阶段(LLM 精排)==========
# 目标:让模型做“Query+Doc -> 相关性分数”的 cross-encoder 式打分。
#
# 为什么要放到最后?
# - 因为 LLM 打分很贵、很慢:只应该对少量候选(比如 8 条)做精排。
#
# 这里的 Prompt 要点:
# - 只输出数字(方便解析)
# - 温度=0(更稳定)
def rerank_with_llm(question: str, docs, top_n: int = 4):
    """用 LLM 对候选 docs 逐条打分并排序,返回最终 Top-N。"""
    llm = ChatTongyi(model="qwen-turbo", temperature=0)
    score_prompt = ChatPromptTemplate.from_messages(
        [
            (
                "system",
                "你是一个严谨的检索重排序器。"
                "给定问题和候选片段,请输出该片段对回答问题的相关性分数(0-10)。"
                "只输出数字。",
            ),
            ("human", "问题:{question}\n\n候选片段:\n{chunk}\n\n分数:"),
        ]
    )
    score_chain = score_prompt | llm | StrOutputParser()

    # 逐条打分:工程上可以并发/批处理,这里为了读懂用最直白写法
    scored = []
    for d in docs:
        raw = score_chain.invoke({"question": question, "chunk": d.page_content})
        try:
            score = float("".join([c for c in raw if (c.isdigit() or c == ".")]))
        except Exception:
            score = 0.0
        scored.append((score, d))

    scored.sort(key=lambda x: x[0], reverse=True)
    return [d for _, d in scored[:top_n]]


# ========== Step 4:线上可落地的“检索 + Rerank + 生成”一体化 Pipeline ==========
# 你线上真正会用的是“一个整体链路”,而不是对比实验。
# 典型工程步骤:
# 1) 召回(带向量分数,K 比较大)
# 2) 预过滤(去重/长度阈值/可选:元信息过滤)
# 3) Hybrid 打分(向量分数 + overlap + 规则)得到 Top-M
# 4) LLM 精排(Cross-Encoder 思路)得到 Top-N
# 5) 组装上下文(带引用信息)
# 6) 生成答案(要求“只基于上下文”,并返回引用)

def _dedupe_by_content(docs):
    seen = set()
    kept = []
    for d in docs:
        key = (d.page_content or "").strip()
        if not key:
            continue
        if key in seen:
            continue
        seen.add(key)
        kept.append(d)
    return kept


def _prefilter(docs, min_len: int = 80, max_len: int = 1600):
    filtered = []
    for d in docs:
        text = (d.page_content or "").strip()
        if len(text) < min_len:
            continue
        if len(text) > max_len:
            continue
        filtered.append(d)
    return _dedupe_by_content(filtered)


def retrieve_and_rerank(question: str, recall_k: int = 30, hybrid_top_m: int = 12, final_top_n: int = 4):
    # 1) 召回(带分数)
    recalled_docs, vector_scores = recall_with_scores(question, k=recall_k)

    # 2) 预过滤(注意:过滤后 scores 要保持对齐)
    pairs = list(zip(recalled_docs, vector_scores))
    pairs = [(d, s) for d, s in pairs if d and (d.page_content or "").strip()]
    recalled_docs = [d for d, _ in pairs]
    vector_scores = [s for _, s in pairs]

    pre_docs = _prefilter(recalled_docs, min_len=40, max_len=2000)
    kept_texts = set([(d.page_content or "").strip() for d in pre_docs])
    pre_scores = []
    for d, s in zip(recalled_docs, vector_scores):
        if (d.page_content or "").strip() in kept_texts:
            pre_scores.append(s)
    # 对齐:pre_docs 的顺序来自 _prefilter(去重后)
    score_map = {}
    for d, s in zip(recalled_docs, vector_scores):
        key = (d.page_content or "").strip()
        if key and key not in score_map:
            score_map[key] = s
    vector_scores_aligned = [score_map.get((d.page_content or "").strip(), 0.5) for d in pre_docs]

    # 3) Hybrid Rerank
    hybrid_docs = hybrid_rerank(question, pre_docs, vector_scores=vector_scores_aligned, top_k=hybrid_top_m)

    # 4) LLM 精排
    final_docs = rerank_with_llm(question, hybrid_docs, top_n=final_top_n)
    return final_docs


def _format_context(docs, max_chars_per_chunk: int = 900):
    parts = []
    for i, d in enumerate(docs, 1):
        text = (d.page_content or "").strip()
        text = text[:max_chars_per_chunk]

        # 引用信息:尽量从 metadata 里拿(PDF loader 通常有 page),拿不到就给 Unknown
        src = "Unknown"
        page = "?"
        try:
            if isinstance(getattr(d, "metadata", None), dict):
                src = d.metadata.get("source", src)
                page = d.metadata.get("page", page)
        except Exception:
            pass

        parts.append(f"[证据{i} | source={src} | page={page}]\n{text}")
    return "\n\n".join(parts)


def answer_with_rerank(question: str):
    # A) 检索 + 重排
    top_docs = retrieve_and_rerank(question, recall_k=30, hybrid_top_m=12, final_top_n=4)
    context = _format_context(top_docs)

    # B) 基于证据生成答案(带引用)
    llm = ChatTongyi(model="qwen-turbo", temperature=0)
    answer_prompt = ChatPromptTemplate.from_messages(
        [
            (
                "system",
                "你是一个严谨的论文学习助手。必须遵守:\n"
                "1) 只能使用【证据】回答;证据里没有就明确说‘资料未覆盖’。\n"
                "2) 给出条理化答案(要点列表)。\n"
                "3) 每个要点后用 (证据1/证据2...) 标注引用。\n",
            ),
            ("human", "问题:{question}\n\n【证据】\n{context}\n\n请回答:"),
        ]
    )
    chain = answer_prompt | llm | StrOutputParser()
    answer = chain.invoke({"question": question, "context": context})
    return answer, top_docs


# ========== 线上用法(单条请求)==========
question = "Transformer 的创新点是什么?"
answer, citations = answer_with_rerank(question)
print(answer)
✅ 验证方法(Top-1 命中率测试)
1️⃣ 准备标准答案集:选 10 个问题,人工标注“最应该排在第一的片段”。
2️⃣ 对比 Top-1 命中率:分别用 similarity 和 rerank 检索,记录 Top-1 是否与标注一致。
3️⃣ 计算提升幅度:Top-1 命中率从 X% 提升到 Y%,证明 Rerank 的价值。
关键指标:Rerank 后 Top-1 命中率应 ≥ 70%
💎 Bi-Encoder vs Cross-Encoder 原理对比
Bi-Encoder(向量检索)
Query → 向量A
Doc → 向量B
分别编码,再算余弦距离
速度:⚡ 极快(毫秒级)
精度:中等(语义理解浅)
适合第一阶段大范围召回
Cross-Encoder(Reranker)
[Query + Doc] → 相关性分数

拼在一起过 Transformer
速度:🐌 较慢(秒级)
精度:高(深层语义理解)
适合第二阶段精排 Top-K
💡 面试关键句:"两阶段检索的本质是在速度和精度之间做权衡——先用 Bi-Encoder 快速召回候选集,再用 Cross-Encoder 做精细排序。"
6)引用溯源:让答案可信、可追责
目标:你能实现“答案必须带来源”(文档名、页码、片段),并且当文档中没有答案时,系统明确说明“文档未覆盖”,而不是编造答案。
为什么重要:论文学习问答最怕“AI 胡说”。
如果答案没有来源,学习者会按错误信息操作,造成学习损失;
如果没有答案时不敢说“不知道”,会误导学习者决策。
引用溯源是“论文学习助手”的底线要求
典型场景:学习者问“Transformer 为什么能并行训练?”。系统回答:“因为自注意力不依赖时间步递归(来源:原文第 X 页)”。如果文档里没写,系统应该回答:“文档中未覆盖该问题”。
python
# 1️⃣ 检索并保留来源信息
from langchain_community.chat_models import ChatTongyi
from langchain_community.document_loaders import PyMuPDFLoader
from langchain_community.vectorstores import Chroma
from langchain_community.embeddings import DashScopeEmbeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter


# 最小可运行初始化:加载 PDF → 切分 → 建 Chroma
pdf_path = "sample_document.pdf"  # 替换成你的 PDF 路径
docs = PyMuPDFLoader(pdf_path).load()
# 零宽断言确保句号留在 chunk 结尾,提升引用溯源的语义精度
chunks = RecursiveCharacterTextSplitter(
    chunk_size=800,
    chunk_overlap=120,
    is_separator_regex=True,
    keep_separator=False,
    separators=["(?<=。)", "(?<=!)", "(?<=?)", "(?<=;)", "(?<=,)", " "],
).split_documents(docs)

embeddings = DashScopeEmbeddings(model="text-embedding-v1")
db = Chroma.from_documents(chunks, embeddings, persist_directory=".chroma/citation_demo")


# LLM:用于“生成答案”;Embeddings 用于“向量化检索”
llm = ChatTongyi(model="qwen-turbo", temperature=0)


def retrieve_with_sources(question, k=3):
    docs = db.as_retriever(search_kwargs={"k": k}).invoke(question)
    
    # 没有检索到内容时,明确返回“文档未覆盖”
    if not docs:
        return {"has_answer": False, "message": "文档中未覆盖该问题"}
    
    # 格式化来源信息
    sources = []
    for i, doc in enumerate(docs):
        source_info = {
            "content": doc.page_content,
            "source": doc.metadata.get("source", "unknown"),
            "page": doc.metadata.get("page", i+1),  # 页码
            "chunk_id": i+1  # 片段编号
        }
        sources.append(source_info)
    
    return {"has_answer": True, "sources": sources}

# 2️⃣ 生成带引用的答案
def generate_answer_with_citation(question):
    result = retrieve_with_sources(question)
    
    if not result["has_answer"]:
        return "抱歉,文档中未覆盖该问题,建议联系相关部门确认。"
    
    # 构建上下文:把真实文档名和页码写进提示,LLM 会照抄
    context_parts = []
    for src in result["sources"]:
        doc_name = src['source'] if src['source'] else "未知文档"
        page_num = src['page'] if src['page'] else "?"
        context_parts.append(f"[来源:{doc_name}{page_num}页]\n{src['content']}")
    context = "\n\n".join(context_parts)
    
    # 生成答案:明确要求 LLM 在答案末尾标注“(来源:XXX 第X页)”
    prompt = f"""根据以下上下文回答问题,并在每个要点后标注来源。

上下文:
{context}

问题:{question}

请按以下格式回答:
- 答案要点1(来源:XXX 第X页)
- 答案要点2(来源:XXX 第X页)
如果上下文不足以回答问题,请明确回答“文档中未覆盖该问题”。"""
    
    answer = llm.invoke(prompt)
    return answer


# 3️⃣ 示例调用
if __name__ == "__main__":
    # 设置环境变量(建议在系统环境中设置;这里只做兜底)
    import os
    os.environ["DASHSCOPE_API_KEY"] = os.environ.get("DASHSCOPE_API_KEY", "your-api-key")
    
    # 测试问题
    questions = [
        "Transformer 的核心贡献是什么?",
        "文档中有没有提到如何优化训练速度?",
        "这篇论文的实验结果如何?"
    ]
    
    for q in questions:
        print(f"\n问题:{q}")
        answer = generate_answer_with_citation(q)
        print(f"回答:{answer.content}")
        print("-" * 50)
✅ 验证方法(引用准确性测试)
1️⃣ 准备测试问题:选 5 个有答案的问题 + 2 个无答案的问题。
2️⃣ 验证引用格式:有答案的问题必须返回“答案内容(来源:文档名 第X页)”格式。
3️⃣ 验证引用准确性:人工核对引用的页码和内容是否与原文一致。
4️⃣ 验证无答案处理:无答案的问题必须返回“文档中未覆盖”相关提示。
关键指标:引用准确率 100%,无答案识别准确率 100%
⚠️ 幻觉的三种典型表现(面试高频考点)
张冠李戴
把 A 文档的内容
错误归因到 B 文档
无中生有
文档没说的内容
模型自己编造
过度推理
文档说 A→B
模型推出 A→B→C
💡 引用溯源的核心价值:让每条答案都有据可查。读者看到"来源:论文原文第 X 页"就可以回到原文验证,这是论文学习场景的信任底线
7)价值量化与复盘:用数据证明 RAG 有效
目标:你能用数据证明“RAG 比纯 LLM 更好”,包括准确率、幻觉率、延迟、满意度等指标,并能形成可交付的评估报告。
为什么重要:论文学习决策需要数据支撑,不是“感觉更好”。
如果你说“RAG 提升了准确率”,必须拿出数字;
如果你说“用户体验更好”,必须用延迟和满意度证明。
量化评估是“技术方案”变成“业务价值”的桥梁。
典型场景:向学习小组汇报:“引入 RAG 后,论文问答准确率从 60% 提升到 85%,幻觉率从 30% 降到 5%,平均响应时间 2.3 秒,用户满意度 4.5/5”。
python
# evaluation.py - 量化评估脚本
import time
import json
from datetime import datetime

from langchain_community.chat_models import ChatTongyi
from langchain_community.document_loaders import PyMuPDFLoader
from langchain_community.vectorstores import Chroma
from langchain_community.embeddings import DashScopeEmbeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter


class RAGEvaluator:
    def __init__(self, llm, retriever):
        self.llm = llm
        self.retriever = retriever
        self.results = []

    def evaluate_question(self, question, ground_truth):
        """评估单个问题"""
        result = {
            "question": question,
            "ground_truth": ground_truth,
            "timestamp": datetime.now().isoformat()
        }

        # 1️⃣ 纯 LLM 回答(无 RAG)
        start_time = time.time()
        llm_response = self.llm.invoke(f"请回答:{question}")
        llm_time = time.time() - start_time

        # 2️⃣ RAG 回答
        start_time = time.time()
        docs = self.retriever.invoke(question)
        context = "\n\n".join(doc.page_content for doc in docs)
        rag_prompt = f"根据以下上下文回答:\n\n{context}\n\n问题:{question}"
        rag_response = self.llm.invoke(rag_prompt)
        rag_time = time.time() - start_time

        # 3️⃣ 人工评估(实际项目中需要人工标注)
        result.update({
            "llm_response": llm_response,
            "llm_time": round(llm_time, 2),
            "rag_response": rag_response,
            "rag_time": round(rag_time, 2),
            "retrieved_docs": len(docs),
            # 以下需要人工评估或自动评估
            "llm_accurate": self._check_accuracy(llm_response, ground_truth),
            "rag_accurate": self._check_accuracy(rag_response, ground_truth),
            "llm_hallucination": self._check_hallucination(llm_response, docs),
            "rag_hallucination": self._check_hallucination(rag_response, docs)
        })

        return result

    def _check_accuracy(self, response, ground_truth):
        """检查答案准确性(简化版)"""
        # 实际项目中可以用 GPT-4 评估或人工评估
        # 处理 AIMessage 对象,获取内容字符串
        response_str = response.content if hasattr(response, 'content') else str(response)
        return ground_truth.lower() in response_str.lower()

    def _check_hallucination(self, response, docs):
        """检查幻觉(简化版)"""
        # 如果没有检索到文档但仍然回答,可能是幻觉
        # 处理 AIMessage 对象,获取内容字符串
        response_str = response.content if hasattr(response, 'content') else str(response)
        return len(docs) == 0 and len(response_str) > 10

    def run_evaluation(self, test_questions):
        """运行完整评估"""
        for question, ground_truth in test_questions:
            result = self.evaluate_question(question, ground_truth)
            self.results.append(result)

        return self.generate_report()

    def generate_report(self):
        """生成评估报告"""
        total = len(self.results)
        if total == 0:
            return "无评估数据"

        # 计算各项指标
        llm_accurate = sum(1 for r in self.results if r["llm_accurate"])
        rag_accurate = sum(1 for r in self.results if r["rag_accurate"])
        llm_hallucination = sum(1 for r in self.results if r["llm_hallucination"])
        rag_hallucination = sum(1 for r in self.results if r["rag_hallucination"])
        avg_llm_time = sum(r["llm_time"] for r in self.results) / total
        avg_rag_time = sum(r["rag_time"] for r in self.results) / total

        # 计算百分比(更直观的计算方式)
        # llm_acc_pct: 纯LLM准确率 = 正确题数 / 总题数 * 100
        llm_acc_pct = llm_accurate / total * 100
        # rag_acc_pct: RAG准确率 = 正确题数 / 总题数 * 100
        rag_acc_pct = rag_accurate / total * 100
        # llm_hall_pct: 纯LLM幻觉率 = 幻觉题数 / 总题数 * 100
        llm_hall_pct = llm_hallucination / total * 100
        # rag_hall_pct: RAG幻觉率 = 幻觉题数 / 总题数 * 100
        rag_hall_pct = rag_hallucination / total * 100

        report = {
            "总问题数": total,
            "纯 LLM 准确率": f"{llm_acc_pct:.1f}%",
            "RAG 准确率": f"{rag_acc_pct:.1f}%",
            "纯 LLM 幻觉率": f"{llm_hall_pct:.1f}%",
            "RAG 幻觉率": f"{rag_hall_pct:.1f}%",
            "纯 LLM 平均响应时间": f"{avg_llm_time:.2f}秒",
            "RAG 平均响应时间": f"{avg_rag_time:.2f}秒",
            "准确率提升": f"{(rag_acc_pct - llm_acc_pct):+.1f}pp",  # pp = 百分点
            "幻觉率降低": f"{(llm_hall_pct - rag_hall_pct):+.1f}pp"
        }

        return report


# 使用示例
test_questions = [
    ("Transformer 最早是哪篇论文提出的?", "Attention Is All You Need"),
    ("Transformer 的核心贡献是什么?", "self-attention"),
    # ... 更多测试问题
]

# 最小可运行初始化:llm + retriever
llm = ChatTongyi(model="qwen-turbo", temperature=0)

pdf_path = "../data/sample_document.pdf"  # 替换成你的 PDF 路径
docs = PyMuPDFLoader(pdf_path).load()
# 零宽断言确保句号留在 chunk 结尾,评估时语义边界更准确
chunks = RecursiveCharacterTextSplitter(
    chunk_size=800,
    chunk_overlap=120,
    is_separator_regex=True,
    keep_separator=False,
    separators=["(?<=。)", "(?<=!)", "(?<=?)", "(?<=;)", "(?<=,)", " "],
).split_documents(docs)

embeddings = DashScopeEmbeddings(model="text-embedding-v1")
db = Chroma.from_documents(chunks, embeddings, persist_directory=".chroma/eval_demo")
retriever = db.as_retriever(search_kwargs={"k": 4})

evaluator = RAGEvaluator(llm, retriever)
report = evaluator.run_evaluation(test_questions)
print(json.dumps(report, indent=2, ensure_ascii=False))
📌 指标怎么计算(口径 + 逻辑)
1)问答准确率(Accuracy)
统计口径:每个问题都有人工标注的 ground_truth(可是一句话/要点列表/关键词集合)。
计算公式:accuracy = 正确题数 / 总题数
判定逻辑(推荐):
- 严格模式:答案必须包含关键事实点(如论文名/方法名/结论),并且不包含明显错误事实。
- 关键词模式(示例代码的简化版):ground_truth 的关键词是否出现在回答中(容易高估,需要结合人工抽检)。
- LLM-as-Judge:用更强模型按评分 rubric 打分(0/1 或 0-5),再按阈值折算正确/错误。
2)幻觉率(Hallucination Rate)
统计口径:在“文档证据不足”的情况下,模型仍输出了具体事实/实体/链接/数字等可验证结论,即视为幻觉。
计算公式:hallucination_rate = 幻觉题数 / 总题数
判定逻辑(比示例更严谨):
- 证据缺失:检索结果为空,或检索到的片段与问题无关(可用相似度阈值/人工标注判断)。
- 答案仍很“肯定”:输出了具体结论且没有表述不确定性(例如“是…/提出了…/结论是…”)。
- 可选:要求回答输出引用编号;若引用缺失或引用片段不支持结论,则计为幻觉。
3)平均响应时间(Latency)
统计口径:从开始处理到拿到最终答案的端到端耗时(包含检索 + LLM 生成)。
计算公式:avg_latency = sum(latency_i) / N
变量含义:
latency_i:第 i 次请求的响应时间(秒)
N:总请求数量
sum(latency_i):所有请求响应时间的总和
示例:10个请求响应时间[1.2s, 1.5s, 1.3s...],则 avg_latency = (1.2+1.5+1.3+...) / 10
说明:RAG 通常比纯 LLM 更慢(多一步检索/拼接上下文),但换来更高准确率与可追溯性。
4)准确率提升 / 幻觉率降低(Δ 指标,单位:pp 百分点)
计算方式:先算出各自的百分比,再相减,单位用 pp(percentage points,百分点)
四个百分比变量计算:
llm_acc_pct = llm_accurate / total * 100:纯LLM准确率(%)
rag_acc_pct = rag_accurate / total * 100:RAG准确率(%)
llm_hall_pct = llm_hallucination / total * 100:纯LLM幻觉率(%)
rag_hall_pct = rag_hallucination / total * 100:RAG幻觉率(%)
其中 total 是总测试题目数,四个变量都是 0-100 之间的数值
- 准确率提升:Δacc = rag_acc_pct - llm_acc_pct,例如:80% - 60% = +20.0pp
- 幻觉率降低:Δhall = llm_hall_pct - rag_hall_pct,例如:40% - 15% = +25.0pp
为什么用 pp 而不是 %:
• 60% → 80% 是提升了 20 个百分点(pp),不是提升了 33%
• pp 避免了"相对增长率"的歧义,让指标更直观
• 示例:+25pp 表示"准确率从 60% 提升到 85%",一看就懂
5)检索相关性/覆盖(建议补充到实战报告)
这部分不在示例代码里,但在复盘里很关键:
- Recall@K:Top-K 检索结果中是否包含支持标准答案的证据片段(需要标注“证据片段 id”)。
- 覆盖率:Top-K 是否覆盖到“多个关键点/多个来源/多个页面”(可按你前面章节的口径:覆盖的类别数/总类别数)。
✅ 验证方法(量化评估实验)
1️⃣ 准备测试集:选 20 个标准问题,人工标注“标准答案”。
2️⃣ 对比测试:分别用纯 LLM 和 RAG 回答,记录每个答案的准确性和响应时间。
3️⃣ 计算指标:准确率、幻觉率、平均响应时间、准确率提升幅度。
4️⃣ 形成报告:用图表展示对比结果,给出“是否值得引入 RAG”的结论。
关键指标:准确率提升 ≥ 20%,幻觉率降低 ≥ 15%
💎 量化评估报告示例(面试时直接展示)
评估维度 纯 LLM RAG 系统 提升幅度
问答准确率 60% 85% +25%
幻觉回答率 30% 5% -25%
平均响应时间 1.2s 2.3s +1.1s
引用来源率 0% 100% +100%
用户满意度 2.8/5 4.5/5 +1.7
💡 面试时说:"我做了 20 个标准测试问答的 A/B 对比实验,准确率提升 25 个百分点,幻觉率下降 25 个百分点,虽然响应时间多了 1 秒,但引用来源率从 0 到 100%,用户满意度从 2.8 升到 4.5。"
✅ 最低成本验收清单(你能马上验证)
  • 回答必须带引用(来源页码/片段),且引用内容确实包含依据。
  • 遇到文档里没有的信息,必须明确说明“文档未覆盖”,不编造。
  • 对同义问法能稳定召回同一条政策段落(至少 Top-3 包含)。

📦 环境与依赖

本项目依赖 LangChain、通义千问、Chroma 等组件。这里给出一个简化版依赖清单, 方便你在自己的环境中快速跑通。

requirements.txt 示例(精简版)

text
streamlit>=1.33.0
langchain>=0.2.0
langchain-community>=0.2.0
langchain-core>=0.2.0
langchain-text-splitters>=0.2.0
dashscope
chromadb
pypdf

🔧 推荐向量库:Chroma(更易安装)

为了避免本地安装/版本兼容问题(例如某些依赖版本不匹配导致的“未知参数”报错),本章示例默认使用 Chroma 作为本地向量库。

text
python -m pip install -U chromadb

环境准备小贴士

  • 建议为本项目单独创建虚拟环境(venv 或 conda)。
  • 安装依赖:pip install -r requirements.txt
  • 提前在环境变量或代码中配置 DASHSCOPE_API_KEY

🧠 第一步:实现 PDF 问答核心(utils.py)

这一版 qa_agent 不再“每问一次就重建一次向量库”,而是支持 向量库持久化复用可切换检索策略(similarity / MMR)rewrite(检索查询改写)引用溯源、以及基础的 trace 观测

🧠 为什么要加 rewrite(查询改写)?
rewrite 只影响“检索”,不影响“回答意图”。它会把用户的原始问题改写成一个可独立检索的查询,从而提高召回质量。 典型场景:
- 用户说:“那篇论文的核心贡献是什么?”(需要补全“那篇”具体指哪篇论文)
- 用户说:“刚才那篇论文的实验设置是怎样的?”(需要补全“刚才那篇”对应的论文标题/章节)
实现方式:在检索前先用 LLM 生成 search_query,再用 search_query 去向量库召回上下文。
开关:函数参数 enable_rewrite=True/False
📁 文件拆分(建议结构)
text
project/
  config.py                 # 集中配置(模型、chunk、目录等)
  trace.py                  # 轻量级 trace 与日志(JSON)
  storage.py                # PDF bytes 落盘 + load
  ingest.py                 # PDF 加载 + 文本切分
  vectorstore.py            # 向量库复用/新建逻辑(Chroma)
  citations.py              # 检索结果格式化为 context + sources
  rewrite.py                # 查询改写(提高召回准确率)
  answer.py                 # 最终答案生成(LLM)
  qa_service.py             # 编排层(把上述模块串起来)
  utils.py                  # 兼容层:对外暴露 qa_agent(...),main.py 无需改
  main.py                   # Streamlit Web UI(上传、提问、展示答案)
🧭 核心流程图(RAG + rewrite + 持久化复用)
text
┌─────────────────────────────────────────────────────────────┐
│ 🟩 用户提问 (question)                                        │
└─────────────────────┬───────────────────────────────────────┘
                      ⬇️
┌─────────────────────────────────────────────────────────────┐
│ 📄 读取 PDF → 计算内容 hash (file_hash)                      │
└─────────────────────┬───────────────────────────────────────┘
                      ⬇️
┌─────────────────────────────────────────────────────────────┐
│ 🗂️ 向量库管理                                               │
│ ┌─────────────┐  ┌─────────────────────────────────────────┐ │
│ │ ✅ 已存在   │  │ ❌ 不存在                                │ │
│ │ → 直接复用  │  │ → load → split → embed → persist        │ │
│ └─────────────┘  └─────────────────────────────────────────┘ │
└─────────────────────┬───────────────────────────────────────┘
                      ⬇️
┌─────────────────────────────────────────────────────────────┐
│ 🔄 可选查询改写 (rewrite)                                   │
│   question + chat_history  →  search_query                  │
└─────────────────────┬───────────────────────────────────────┘
                      ⬇️
┌─────────────────────────────────────────────────────────────┐
│ 🔍 检索 (retriever)                                         │
│   search_query → similarity/MMR → docs                      │
└─────────────────────┬───────────────────────────────────────┘
                      ⬇️
┌─────────────────────────────────────────────────────────────┐
│ 📝 引用格式化 (citations)                                   │
│   docs → context + sources                                  │
└─────────────────────┬───────────────────────────────────────┘
                      ⬇️
┌─────────────────────────────────────────────────────────────┐
│ 🤖 生成答案 (LLM)                                           │
│   (question + chat_history + context) → answer              │
└─────────────────────┬───────────────────────────────────────┘
                      ⬇️
┌─────────────────────────────────────────────────────────────┐
│ 💾 写入对话记忆                                              │
│   add_user_message / add_ai_message                         │
└─────────────────────┬───────────────────────────────────────┘
                      ⬇️
┌─────────────────────────────────────────────────────────────┐
│ 🎯 返回结果                                                  │
│   {answer, sources, trace_id}                               │
└─────────────────────────────────────────────────────────────┘
文件:config.py
python
# config.py:集中管理可调参数(不要散落在业务代码里)
# 好处:
# 1) 统一改动入口(调 chunk、模型、温度等)
# 2) 便于后续接入环境变量 / 配置文件 / A/B 实验

from dataclasses import dataclass


@dataclass(frozen=True)
class QaConfig:
    # LLM(用于生成答案 / rewrite)
    llm_model: str = "qwen-turbo"
    llm_temperature: float = 0.2
    # Embedding(用于向量化 PDF chunk)
    embedding_model: str = "text-embedding-v1"
    # 切分参数:影响召回质量与成本
    chunk_size: int = 800
    chunk_overlap: int = 120
    # 送入 LLM 的上下文最大长度(字符级兜底,避免 token 暴涨)
    context_max_chars: int = 1400
    # Chroma 持久化根目录(不同 PDF 会落在不同子目录)
    chroma_root_dir: str = ".chroma"
文件:trace.py
python
# trace.py:轻量级 trace(可观测性)
# 目标:在不引入复杂链路追踪系统的情况下,做到“能排查问题、能看耗时、能关联一次请求”。

import json
import logging
from dataclasses import dataclass
from typing import Any, Dict


logger = logging.getLogger("pdf_qa")
if not logger.handlers:
    # 只在没有 handler 时配置,避免在 notebook/多次 import 时重复打印
    logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")


@dataclass
class Trace:
    # trace_id:一次问答请求的唯一标识(用于串联所有日志)
    trace_id: str
    # pdf_hash:当前 PDF 的内容 hash(用于定位“是哪份 PDF 的问题”)
    pdf_hash: str
    # extra:附加维度(检索策略、top_k 等)
    extra: Dict[str, Any]

    def log(self, event: str, **kwargs) -> None:
        # 输出 JSON:更利于后续被 log pipeline / ELK / Loki 收集与检索
        payload = {"event": event, "trace_id": self.trace_id, "pdf": self.pdf_hash, **self.extra, **kwargs}
        logger.info(json.dumps(payload, ensure_ascii=False))
文件:storage.py
python
# storage.py:负责把上传的 PDF bytes 落盘成临时文件
# 说明:
# - LangChain 的 PyMuPDFLoader 需要文件路径
# - 不同请求并发时,用 file_hash 命名可以避免覆盖

import hashlib
import os
from typing import Optional


class PdfBytesStore:
    def save_temp_pdf(self, pdf_bytes: bytes, file_hash: str) -> str:
        path = f"temp_{file_hash}.pdf"
        with open(path, "wb") as f:
            f.write(pdf_bytes)
        return path

    def store(self, uploaded_file) -> str:
        """存储上传的文件并返回文件哈希"""
        # 读取文件内容
        pdf_bytes = uploaded_file.read()

        # 计算文件哈希
        file_hash = hashlib.md5(pdf_bytes).hexdigest()

        # 存储文件
        self.save_temp_pdf(pdf_bytes, file_hash)

        return file_hash

    def load(self, file_hash: str) -> Optional[bytes]:
        """根据文件哈希加载文件内容"""
        temp_path = f"temp_{file_hash}.pdf"
        if os.path.exists(temp_path):
            with open(temp_path, "rb") as f:
                return f.read()
        return None
文件:ingest.py
python
# ingest.py:PDF 文档加载 + 切分
# 关键点:
# - 切分粒度决定“检索召回质量”与“回答是否够准”
# - overlap 可以减少边界信息丢失

from typing import List

from langchain_community.document_loaders import PyMuPDFLoader
from langchain_core.documents import Document
from langchain_text_splitters import RecursiveCharacterTextSplitter

from config import QaConfig


class PdfIngestor:
    def __init__(self, cfg: QaConfig):
        self._cfg = cfg

    def load(self, pdf_path: str) -> List[Document]:
        # 每页一个 Document,metadata 内通常包含 page/source 等
        return PyMuPDFLoader(pdf_path).load()

    def split(self, docs: List[Document]) -> List[Document]:
        # 这里使用递归字符切分,中文场景相对稳健
        splitter = RecursiveCharacterTextSplitter(
            chunk_size=self._cfg.chunk_size,
            chunk_overlap=self._cfg.chunk_overlap,
            is_separator_regex=True,
            keep_separator=False,
            separators=["(?<=。)", "(?<=!)", "(?<=?)", "(?<=;)", "(?<=,)", " "],
        )
        return splitter.split_documents(docs)

    def ingest(self, pdf_bytes: bytes) -> List[Document]:
        """从 PDF 字节数据加载并切分文档"""
        import tempfile
        import os

        # 创建临时文件
        with tempfile.NamedTemporaryFile(delete=False, suffix='.pdf') as tmp:
            tmp.write(pdf_bytes)
            tmp_path = tmp.name

        try:
            # 加载文档
            docs = self.load(tmp_path)
            # 切分文档
            chunks = self.split(docs)
            return chunks
        finally:
            # 清理临时文件
            os.unlink(tmp_path)
文件:vectorstore.py
python
# vectorstore.py:向量库“复用 or 新建”的核心逻辑
# 企业级必备点:同一份 PDF 不要每次都重新 embed(慢、贵、重复)

import os
from typing import List, Optional

from langchain_community.embeddings import DashScopeEmbeddings
from langchain_community.vectorstores import Chroma
from langchain_core.documents import Document

from config import QaConfig


class VectorStoreManager:
    def __init__(self, cfg: QaConfig, embeddings: DashScopeEmbeddings):
        self._cfg = cfg
        self._embeddings = embeddings

    def _persist_dir(self, file_hash: str) -> str:
        # 每个 PDF 独立一个目录,避免“串库/污染”
        return os.path.join(self._cfg.chroma_root_dir, f"pdf_{file_hash}")

    def _collection_name(self, file_hash: str) -> str:
        # collection_name 也用 file_hash 隔离
        return f"pdf_qa_{file_hash}"

    def load_or_build(self, file_hash: str, chunks: Optional[List[Document]] = None) -> Chroma:
        persist_dir = self._persist_dir(file_hash)
        collection_name = self._collection_name(file_hash)
        store = Chroma(
            collection_name=collection_name,
            embedding_function=self._embeddings,
            persist_directory=persist_dir,
        )
        try:
            # Chroma 内部 collection count > 0 代表已经有向量数据,可直接复用
            if store._collection.count() > 0:
                return store
        except Exception:
            # 这里做兜底:某些版本/环境可能无法访问 _collection
            pass
        if not chunks:
            raise ValueError("向量库不存在且未提供 chunks,无法建库")
        # 首次建库:from_documents 会自动 embed 并写入向量库
        store = Chroma.from_documents(
            chunks, self._embeddings,
            collection_name=collection_name,
            persist_directory=persist_dir,
        )
        try:
            # persist 将向量落盘,下一次可直接复用
            store.persist()
        except Exception:
            pass
        return store
文件:citations.py
python
# citations.py:把检索到的 chunk 拼成 context,并生成 sources(用于 UI 展示引用溯源)
# 设计点:
# - LLM 只看 context,不知道来源;sources 由我们自己构造
# - 答案里用 [1][2],UI 可以展示“来自哪一页”

from typing import Any, Dict, List, Tuple

from langchain_core.documents import Document

from config import QaConfig


class CitationFormatter:
    def __init__(self, cfg: QaConfig):
        self._cfg = cfg

    def format(self, docs: List[Document]) -> Tuple[str, List[Dict[str, Any]]]:
        lines: List[str] = []
        sources: List[Dict[str, Any]] = []
        for idx, d in enumerate(docs, start=1):
            meta = d.metadata or {}
            source = meta.get("source", "")
            page = meta.get("page", None)
            # 统一引用编号(1-based),方便 LLM 在答案末尾标注
            cite = f"[{idx}] {source}#page={page}" if page is not None else f"[{idx}] {source}"
            content = (d.page_content or "").strip().replace("\n", " ")
            lines.append(f"{cite}\n{content}")
            sources.append({"idx": idx, "source": source, "page": page})
        context = "\n\n".join(lines)
        if len(context) > self._cfg.context_max_chars:
            # 兜底截断:防止上下文过长导致 token/耗时/费用暴涨
            context = context[: self._cfg.context_max_chars] + "..."
        return context, sources
文件:rewrite.py(真实调用版:完整 Rerank Pipeline)
python
# rewrite.py:查询改写 + 完整 Rerank Pipeline(真实被 qa_service 调用)
# 职责:
# 1) 查询改写(解决指代、补全实体)
# 2) 带分数向量召回(recall_with_scores)
# 3) 预过滤(去重/长度/空内容过滤)
# 4) Hybrid Rerank(向量分数 + overlap + 规则)
# 5) LLM 精排(Cross-Encoder 思路)
# 6) 引用格式化(带真实文档名和页码)

from typing import List, Dict, Any, Tuple, Optional
from dataclasses import dataclass
import re

from langchain_community.chat_models import ChatTongyi
from langchain_community.vectorstores import Chroma
from langchain_core.documents import Document
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder


@dataclass
class RetrievedEvidence:
    """检索到的证据,包含内容、来源、页码、分数等信息"""
    content: str
    source: str
    page: Optional[int]
    score: float  # 最终排序分数
    vector_score: float  # 向量相似度分数
    hybrid_score: float  # hybrid rerank 分数
    llm_score: Optional[float] = None  # LLM rerank 分数(如果有)


class QueryRewriter:
    """查询改写器:把用户问题改写成可独立检索的查询"""
    
    def __init__(self, llm: ChatTongyi):
        self._chain = (
            ChatPromptTemplate.from_messages([
                (
                    "system",
                    "你是检索查询改写专家。把用户问题改写成适合在论文知识库中检索的独立查询。\n"
                    "规则:\n"
                    "1) 消除指代(那篇/刚才/它 → 补全具体实体)\n"
                    "2) 保留核心术语(论文名、方法名、技术概念)\n"
                    "3) 只输出改写后的查询,不要解释"
                ),
                MessagesPlaceholder("chat_history"),
                ("human", "原始问题:{question}\n请改写为独立检索查询:"),
            ])
            | llm
            | StrOutputParser()
        )
    
    def rewrite(self, question: str, chat_history) -> str:
        q = (question or "").strip()
        if not q:
            return q
        # 短问题或没有指代的问题,跳过改写
        if len(q) < 10 or not any(kw in q for kw in ["那篇", "刚才", "它", "这个", "该"]):
            return q
        try:
            rewritten = self._chain.invoke({"question": q, "chat_history": chat_history.messages if chat_history else []})
            return (rewritten or "").strip() or q
        except Exception:
            return q


class RerankPipeline:
    """
    完整 Rerank Pipeline:召回 → 预过滤 → Hybrid → LLM 精排
    线上真实调用入口:rerank(query, vectorstore) -> List[RetrievedEvidence]
    """
    
    def __init__(self, llm: ChatTongyi, cfg=None):
        self.llm = llm
        self.cfg = cfg
        # LLM 精排 chain(预编译)
        self._llm_rerank_chain = (
            ChatPromptTemplate.from_messages([
                (
                    "system",
                    "你是一个严谨的检索重排序器。给定问题和候选片段,输出该片段对回答问题的相关性分数(0-10)。只输出数字。"
                ),
                ("human", "问题:{question}\n\n候选片段:\n{chunk}\n\n分数:"),
            ])
            | llm
            | StrOutputParser()
        )
    
    def _recall_with_scores(self, query: str, vectorstore: Chroma, k: int = 30) -> Tuple[List[Document], List[float]]:
        """向量检索并返回带分数的结果"""
        results_with_scores = vectorstore.similarity_search_with_score(query, k=k)
        docs = []
        scores = []
        for doc, distance in results_with_scores:
            docs.append(doc)
            # Chroma 的 distance 越小越相似,转成 0-1 的相似度分数
            similarity = 1.0 / (1.0 + distance)
            scores.append(similarity)
        return docs, scores
    
    def _dedupe_by_content(self, docs: List[Document]) -> List[Document]:
        """去重:相同内容的文档只保留一份"""
        seen = set()
        kept = []
        for d in docs:
            key = (d.page_content or "").strip()
            if not key or key in seen:
                continue
            seen.add(key)
            kept.append(d)
        return kept
    
    def _prefilter(self, docs: List[Document], min_len: int = 40, max_len: int = 2000) -> List[Document]:
        """预过滤:去除太短、太长、空的文档"""
        filtered = []
        for d in docs:
            text = (d.page_content or "").strip()
            if len(text) < min_len or len(text) > max_len:
                continue
            filtered.append(d)
        return self._dedupe_by_content(filtered)
    
    def _simple_overlap_score(self, query: str, text: str) -> float:
        """轻量文本匹配:Jaccard 相似度(中文按 bigram,英文按单词)"""
        def tokens(s: str):
            s = (s or "").lower()
            # 中文提取连续字符
            chinese = re.findall(r'[\u4e00-\u9fff]{2,}', s)
            # 英文提取单词
            english = re.findall(r'[a-z]+', s)
            # bigram for chinese
            bigrams = []
            for ch in chinese:
                for i in range(len(ch) - 1):
                    bigrams.append(ch[i:i+2])
            return set(bigrams + english)
        
        q_tokens = tokens(query)
        t_tokens = tokens(text)
        if not q_tokens:
            return 0.0
        inter = q_tokens & t_tokens
        union = q_tokens | t_tokens
        return len(inter) / len(union) if union else 0.0
    
    def _hybrid_rerank(
        self,
        question: str,
        documents: List[Document],
        vector_scores: List[float],
        top_k: int = 12
    ) -> Tuple[List[Document], List[float]]:
        """
        混合重排序:向量分数 + 轻量文本匹配 + 规则(长度/关键词)
        """
        if not documents:
            return [], []
        
        # 计算文本重叠分数
        overlap_scores = [self._simple_overlap_score(question, d.page_content) for d in documents]
        
        # 混合评分
        final_scores = []
        for i, (vec_score, overlap) in enumerate(zip(vector_scores, overlap_scores)):
            # 向量分数 30% + 重叠分数 70%(可调)
            hybrid_score = 0.3 * vec_score + 0.7 * overlap
            
            doc = documents[i]
            text = (doc.page_content or "").strip()
            
            # 长度规则:太短或太长的文档降分
            if len(text) < 80:
                hybrid_score *= 0.8
            elif len(text) > 1600:
                hybrid_score *= 0.9
            
            # 关键词规则:包含问题关键词的加分
            q_words = set(question.lower().split())
            doc_words = set(text.lower().split())
            if q_words & doc_words:
                hybrid_score *= 1.1
            
            final_scores.append((i, hybrid_score))
        
        # 排序
        final_scores.sort(key=lambda x: x[1], reverse=True)
        sorted_docs = [documents[idx] for idx, _ in final_scores[:top_k]]
        sorted_scores = [score for _, score in final_scores[:top_k]]
        return sorted_docs, sorted_scores
    
    def _llm_rerank(
        self,
        question: str,
        documents: List[Document],
        top_n: int = 4
    ) -> Tuple[List[Document], List[float]]:
        """LLM 精排:用模型打分并排序"""
        if not documents:
            return [], []
        
        scored = []
        for doc in documents:
            raw = self._llm_rerank_chain.invoke({"question": question, "chunk": doc.page_content})
            try:
                score = float("".join([c for c in raw if c.isdigit() or c == "."]))
            except Exception:
                score = 0.0
            scored.append((score, doc))
        
        scored.sort(key=lambda x: x[0], reverse=True)
        final_docs = [d for _, d in scored[:top_n]]
        final_scores = [s for s, _ in scored[:top_n]]
        return final_docs, final_scores
    
    def rerank(
        self,
        query: str,
        vectorstore: Chroma,
        recall_k: int = 30,
        hybrid_top_m: int = 12,
        final_top_n: int = 4
    ) -> Tuple[List[RetrievedEvidence], str]:
        """
        完整 Pipeline 入口
        
        Returns:
            (证据列表, 格式化后的上下文字符串)
        """
        # 1) 召回(带分数)
        recalled_docs, vector_scores = self._recall_with_scores(query, vectorstore, k=recall_k)
        
        # 2) 预过滤
        pre_docs = self._prefilter(recalled_docs, min_len=40, max_len=2000)
        # 对齐分数
        score_map = {}
        for d, s in zip(recalled_docs, vector_scores):
            key = (d.page_content or "").strip()
            if key and key not in score_map:
                score_map[key] = s
        vector_scores_aligned = [score_map.get((d.page_content or "").strip(), 0.5) for d in pre_docs]
        
        # 3) Hybrid Rerank
        hybrid_docs, hybrid_scores = self._hybrid_rerank(query, pre_docs, vector_scores_aligned, top_k=hybrid_top_m)
        
        # 4) LLM 精排
        final_docs, llm_scores = self._llm_rerank(query, hybrid_docs, top_n=final_top_n)
        
        # 5) 组装证据对象
        evidence_list = []
        for i, doc in enumerate(final_docs):
            meta = doc.metadata or {}
            source = meta.get("source", "Unknown")
            page = meta.get("page")
            
            # 找到对应的 hybrid_score
            hybrid_score = 0.0
            vec_score = 0.0
            doc_key = (doc.page_content or "").strip()
            for h_doc, h_score in zip(hybrid_docs, hybrid_scores):
                if (h_doc.page_content or "").strip() == doc_key:
                    hybrid_score = h_score
                    break
            vec_score = score_map.get(doc_key, 0.0)
            
            ev = RetrievedEvidence(
                content=doc.page_content or "",
                source=source,
                page=page,
                score=llm_scores[i] if i < len(llm_scores) else 0.0,
                vector_score=vec_score,
                hybrid_score=hybrid_score,
                llm_score=llm_scores[i] if i < len(llm_scores) else None
            )
            evidence_list.append(ev)
        
        # 6) 格式化上下文(带引用)
        context_lines = []
        for idx, ev in enumerate(evidence_list, 1):
            page_str = f"第{ev.page}页" if ev.page is not None else "未知页"
            context_lines.append(f"[证据{idx} | 来源:{ev.source} | {page_str}]\n{ev.content}")
        
        context = "\n\n".join(context_lines)
        return evidence_list, context


def format_evidence_for_prompt(evidence_list: List[RetrievedEvidence]) -> str:
    """把证据列表格式化为 LLM 可用的上下文"""
    lines = []
    for idx, ev in enumerate(evidence_list, 1):
        page_str = f"第{ev.page}页" if ev.page is not None else "未知页"
        lines.append(f"[{idx}] 来源:{ev.source} {page_str}\n{ev.content}")
    return "\n\n".join(lines)
文件:answer.py
python
# answer.py:最终回答生成
# 输入:question + chat_history + context
# 输出:answer(纯文本)
# 设计点:
# - 让模型“只根据 context 回答”,避免胡编
# - 强制引用标注格式 [1][2],便于 UI 展示溯源

from langchain_community.chat_models import ChatTongyi
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder


class AnswerGenerator:
    def __init__(self, llm: ChatTongyi):
        # 把 prompt + model + parser 预先组合起来(构造一次,多次复用)
        self._chain = (
            ChatPromptTemplate.from_messages([
                ("system", "你是一个严谨的论文学习知识库问答助手。只根据给定上下文回答。答案末尾用 [1][2] 标注引用编号。\n\n上下文:\n{context}"),
                MessagesPlaceholder("chat_history"),
                ("human", "{input}"),
            ])
            | llm
            | StrOutputParser()
        )

    def generate(self, question: str, chat_history, context: str) -> str:
        # input 一定用用户原始问题(不要用 rewrite 后的 search_query)
        # MessagesPlaceholder 需要的是 messages 列表,不是 chat_history 对象
        return self._chain.invoke({"input": question, "chat_history": chat_history.messages, "context": context})
文件:qa_service.py(真实调用 rewrite.py 版)
python
# qa_service.py:主服务类,整合所有组件(真实调用 rewrite.py 的 RerankPipeline)
# 职责:
# - 协调 PDF 处理、向量库、检索(rewrite + rerank)、生成等组件
# - 提供统一的 run 接口给 utils.py 调用

from typing import Any, Dict, Callable, List

from langchain_community.chat_models import ChatTongyi
from langchain_community.embeddings import DashScopeEmbeddings

from config import QaConfig
from ingest import PdfIngestor
from vectorstore import VectorStoreManager
from answer import AnswerGenerator
from storage import PdfBytesStore
from rewrite import QueryRewriter, RerankPipeline, RetrievedEvidence


class QaService:
    def __init__(self, cfg: QaConfig):
        self._cfg = cfg

    def _retrieve_docs_adaptive_k(
        self,
        *,
        vectorstore,
        query: str,
        search_type: str,
        k_start: int,
        k_step: int,
        k_max: int,
        min_pages_covered: int,
    ):
        """自适应扩大 k:从小 k 开始,覆盖不足则逐步扩大,直到达标或到上限。"""

        def _pages_covered(docs) -> int:
            pages = {d.metadata.get("page") for d in docs if d.metadata}
            pages = {p for p in pages if p is not None}
            return len(pages)

        cur_k = max(1, min(k_start, k_max))
        last_docs = []
        while True:
            retriever = vectorstore.as_retriever(
                search_type=search_type,
                search_kwargs={"k": cur_k}
            )
            docs = retriever.invoke(query)
            last_docs = docs

            if _pages_covered(docs) >= min_pages_covered:
                return docs, cur_k

            if cur_k >= k_max:
                return docs, cur_k

            cur_k = min(cur_k + k_step, k_max)

        return last_docs, cur_k

    def run(self, dashscope_api_key: str, get_session_history: Callable,
            uploaded_file, question: str, *, search_type: str = "similarity",
            top_k: int = 4, enable_rewrite: bool = True,
            enable_rerank: bool = True,
            adaptive_k: bool = True,
            k_start: int = 4,
            k_step: int = 4,
            k_max: int = 24,
            min_pages_covered: int = 3) -> Dict[str, Any]:
        """
        运行完整的 QA 流程
        
        Args:
            enable_rewrite: 是否启用查询改写
            enable_rerank: 是否启用完整 Rerank Pipeline(否则使用简单检索)
            adaptive_k: 是否启用“自适应扩大 k”(省成本 + 覆盖兜底)
            k_start: 从小 k 开始检索(先省成本)
            k_step: 覆盖不足时每次扩大多少
            k_max: 最大 k 上限(避免无限扩大)
            min_pages_covered: 覆盖度阈值:至少命中多少个不同 page(用页数做“覆盖度”更直观)
        """

        # 1. 初始化组件
        embeddings = DashScopeEmbeddings(
            model=self._cfg.embedding_model,
            dashscope_api_key=dashscope_api_key
        )

        llm = ChatTongyi(
            model=self._cfg.llm_model,
            temperature=self._cfg.llm_temperature,
            dashscope_api_key=dashscope_api_key
        )

        # 2. 处理 PDF
        pdf_store = PdfBytesStore()
        file_hash = pdf_store.store(uploaded_file)

        # 3. 加载或构建向量库
        ingestor = PdfIngestor(self._cfg)
        vector_manager = VectorStoreManager(self._cfg, embeddings)

        try:
            vectorstore = vector_manager.load_or_build(file_hash)
            if vectorstore._collection.count() > 0:
                pass
            else:
                raise ValueError("向量库无数据")
        except (ValueError, Exception):
            pdf_bytes = pdf_store.load(file_hash)
            chunks = ingestor.ingest(pdf_bytes)
            vectorstore = vector_manager.load_or_build(file_hash, chunks)

        # 4. 查询改写(可选)
        chat_history = get_session_history("default")
        search_query = question
        if enable_rewrite:
            rewriter = QueryRewriter(llm)
            search_query = rewriter.rewrite(question, chat_history)

        # 5. 检索(带完整 Rerank Pipeline)
        evidence_list: List[RetrievedEvidence] = []
        context = ""
        
        if enable_rerank:
            # 使用完整的 Rerank Pipeline(rewrite.py 中实现)
            rerank_pipeline = RerankPipeline(llm, self._cfg)
            evidence_list, context = rerank_pipeline.rerank(
                query=search_query,
                vectorstore=vectorstore,
                recall_k=30,      # 初始召回 30 条
                hybrid_top_m=12,  # Hybrid 后保留 12 条
                final_top_n=top_k # LLM 精排后保留 top_k 条(默认 4)
            )
        else:
            if adaptive_k:
                docs, _ = self._retrieve_docs_adaptive_k(
                    vectorstore=vectorstore,
                    query=search_query,
                    search_type=search_type,
                    k_start=k_start,
                    k_step=k_step,
                    k_max=k_max,
                    min_pages_covered=min_pages_covered,
                )
            else:
                retriever = vectorstore.as_retriever(
                    search_type=search_type,
                    search_kwargs={"k": top_k}
                )
                docs = retriever.invoke(search_query)

            context = "\n\n".join([doc.page_content for doc in docs])

        # 6. 生成答案
        answer_generator = AnswerGenerator(llm)
        answer = answer_generator.generate(question, chat_history, context)

        # 7. 更新对话历史
        chat_history.add_user_message(question)
        chat_history.add_ai_message(answer)

        # 8. 返回完整结果(包含证据列表供 UI 展示引用)
        return {
            "answer": answer,
            "context": context,
            "evidence": evidence_list,  # 结构化证据(含来源、页码、分数)
            "search_query": search_query,  # 改写后的查询(用于调试)
            "rewritten": enable_rewrite and (search_query != question),
        }
文件:utils.py(兼容层,对外保持 qa_agent 不变)
python
# utils.py:兼容层(Facade)
# 为什么保留它:
# - main.py 已经写死了 `from utils import qa_agent`
# - 我们把实现拆进 pdf_qa/ 后,utils.py 只负责“对外稳定接口”
# - 将来你想换实现(比如换 Milvus / OpenSearch),main.py 仍不需要动

from typing import Any, Dict

from config import QaConfig
from qa_service import QaService


def qa_agent(dashscope_api_key: str, get_session_history, uploaded_file,
             question: str, *, search_type: str = "similarity",
             top_k: int = 4, enable_rewrite: bool = True,
             adaptive_k: bool = True,
             k_start: int = 4,
             k_step: int = 4,
             k_max: int = 24,
             min_pages_covered: int = 3) -> Dict[str, Any]:
    # 这里不把 cfg 做成全局变量,是为了让示例更“无副作用”;
    # 真正工程里可以把 cfg/服务放到单例容器里复用。
    cfg = QaConfig()
    return QaService(cfg).run(
        dashscope_api_key=dashscope_api_key,
        get_session_history=get_session_history,
        uploaded_file=uploaded_file,
        question=question,
        search_type=search_type,
        top_k=top_k,
        enable_rewrite=enable_rewrite,
        adaptive_k=adaptive_k,
        k_start=k_start,
        k_step=k_step,
        k_max=k_max,
        min_pages_covered=min_pages_covered,
    )

💻 第二步:构建 Web 界面与对话记忆(main.py)

接下来用 Streamlit 把这条 RAG 链"包"成一个可交互的 Web 应用。

python
import streamlit as st
from langchain_community.chat_message_histories import ChatMessageHistory
from utils import qa_agent


def get_session_history(session_id: str):
    if "chat_history" not in st.session_state:
        st.session_state["chat_history"] = ChatMessageHistory()
    return st.session_state["chat_history"]


st.title("📑 「智阅」论文知识库助手")

# 侧边栏:输入通义千问 API Key
with st.sidebar:
    dashscope_api_key = st.text_input("请输入通义千问 API密钥:", type="password")
    st.markdown("[获取通义千问 API key](https://dashscope.console.aliyun.com/apiKey)")


# 上传 PDF 文件
uploaded_file = st.file_uploader("上传你的PDF文件:", type="pdf")

# 输入对 PDF 的问题
question = st.text_input("对PDF的内容进行提问", disabled=not uploaded_file)


# 如果没有 API Key,先给出提示
if uploaded_file and question and not dashscope_api_key:
    st.info("请输入你的通义千问 API密钥")


# 当文件、问题、API Key 都准备好后,执行问答
if uploaded_file and question and dashscope_api_key:
    with st.spinner("AI正在思考中,请稍等..."):
        response = qa_agent(
            dashscope_api_key,
            get_session_history,
            uploaded_file,
            question,
        )

    st.write("### 答案")
    st.write(response["answer"])

# 展示历史对话
if "chat_history" in st.session_state:
    with st.expander("历史消息"):
        for msg in st.session_state["chat_history"].messages:
            st.write(msg.content)

📥 示例 PDF 文件下载

本项目提供了一个示例 PDF 文档(sample_document.pdf),你可以下载后上传到应用中进行测试。 该文件的内容是:学习大语言模型原理必看的 10 篇论文精选,适合用来验证 RAG 的检索与引用效果。

📄 下载 sample_document.pdf (示例文档,约 600KB)

💡 如果你要在本地脚本里复现本文示例,通常把文件放在项目根目录,代码里写 pdf_path = "sample_document.pdf" 即可; 或直接使用下载链接对应的相对路径:../data/sample_document.pdf

💡 你也可以使用自己的 PDF 文档进行测试,比如论文合集、课程讲义、技术白皮书等。

📝 运行步骤

  1. 在项目目录创建上述 utils.pymain.py 文件
  2. 安装依赖:pip install -r requirements.txt
  3. 下载示例 PDF 文件或准备自己的 PDF 文档
  4. 运行应用:streamlit run main.py
  5. 在浏览器打开 http://localhost:8501
  6. 上传 PDF 文件并输入问题,开始智能问答
本章的核心不是“链式调用语法”,而是把 文档 → 检索 → 可信回答 这条 RAG 链路做完整,并且做到可复用、可验收、可演示

1️⃣ 文档解析与切分(输入质量决定上限)

上传文件 → PyMuPDFLoader → RecursiveCharacterTextSplitter
把 PDF 解析为文档对象,并切成语义更完整的 chunk(带页码等元数据),为"可溯源引用"做准备。

2️⃣ 向量库与检索(性能与效果的平衡)

Embeddings → Chroma(persist) → Retriever(similarity / MMR)
通过向量化 + 持久化存储实现"同一 PDF 多次提问不重复建库",并用 similarity/MMR 等策略提升召回的相关性与覆盖度。

3️⃣ 可信回答与引用溯源(能解释才敢用)

检索上下文(带引用) + Prompt 约束 + 多轮对话记忆
LLM 只在上下文内作答,并以 [1][2] 标注引用编号,配合 trace/latency 记录,形成可追溯、可优化的问答闭环。

本章更强调:把 RAG 从“能跑通”升级为“能落地”——包含持久化、引用溯源、检索策略、可观测性与验收指标。

🔥 项目难点与亮点深度剖析

面试官最关心的不是"你用了什么框架",而是"你解决了什么问题、做了什么取舍"。 下面把「智阅」项目的 7 大核心难点 逐一拆解,每个难点都给出 问题 → 原因 → 方案 → 效果 的完整闭环。

🏗️ 系统架构全景图

┌──────────┐   ┌────────────┐   ┌────────────────┐   ┌──────────────┐
│ 多格式上传 │──▶│ PyPDFLoader│──▶│ Recursive      │──▶│ DashScope    │
│PDF/Word/MD│   │ TextLoader │   │ TextSplitter   │   │ Embedding    │
│(Streamlit)│   │ 多格式解析  │   │ 6级中文分隔符   │   │ 文本向量化    │
└──────────┘   └────────────┘   └────────────────┘   └──────┬───────┘
                                                            │
┌──────────┐   ┌────────────┐   ┌────────────────┐   ┌──────▼───────┐
│ 带引用回答 │◀──│ 通义千问    │◀──│ BGE-Reranker   │◀──│  Chroma /    │
│+ 来源溯源 │   │ qwen-turbo │   │ 二阶段重排序    │   │  向量检索     │
└──────────┘   └────────────┘   └───────┬────────┘   └──────────────┘
                                        │
                                 ┌──────┴────────┐
                                 │ MessageHistory │
                                 │ 多轮对话记忆    │
                                 └───────────────┘

面试时画出这张图,展示你对 RAG 链路端到端的架构理解,而不仅仅是"调了个 API"。

难点 1 切分策略踩坑:零宽断言让句末命中率从 25% 到 100%

问题:PDF 文档长度不一,chunk 太大导致向量语义稀释、检索不精准;chunk 太小则丢失上下文、答案不完整。更隐蔽的问题是:直接用 separators=["。"] 时,默认 keep_separator=True 会把句号推到下一个 chunk 开头,导致当前 chunk 结尾语义残缺,句末命中率只有 25%。
为什么难:这个陷阱不报错、不警告,表面上 separators 参数"生效了",实际上每个 chunk 都在句子中间被截断,embedding 质量悄悄下降,检索命中率持续偏低,很难排查根因。
方案:is_separator_regex=True + 零宽断言 (?<=。),让切分点落在句号之后,句号自然留在当前 chunk 结尾;配合 keep_separator=False 避免重复保留。实验对比:硬切句末命中率 25%,普通 separators 仍是 25%(统计失真),零宽断言方案达到 100%。同时做了 chunk_size=500/1000/2000 对比,1000 的 Top-3 命中率最高。
效果:句末命中率从 25% 提升至 100%,Top-3 命中率从 55% 提升至 82%,检索相关性整体提升约 30%。
25%→100%
句末命中率(零宽断言)
55%→82%
Top-3 命中率
+30%
检索相关性提升

难点 2 向量检索召回率与多样性的平衡(MMR)

问题:用户问"Transformer 的核心贡献是什么?"时,纯相似度检索返回的 Top-4 可能全是“贡献点”同一类段落,而“结构细节/位置编码/实验结论”等关键信息被遗漏。
为什么难:Embedding 模型对语义相近段落打分接近,导致 Top-K 结果高度重复;同义问法("核心贡献/创新点/主要贡献")很容易召回到同一类表述。
方案:引入 MMR(最大边际相关性)检索,先 fetch_k=20 大范围召回,再按 lambda_mult=0.5 在"相关性"和"多样性"间平衡选出 Top-4;同义问法更稳定地覆盖“贡献点/结构/实验”。
效果:段落覆盖率从 similarity 的 40% 提升至 MMR 的 80%+,多角度信息不再遗漏。
MMR
多样性检索策略
80%+
段落覆盖率
20→4
两阶段筛选

难点 3 多源知识库融合与来源溯源

问题:学习资料分散在论文原文、综述/博客解读、实验复现笔记等多个来源;单一材料无法覆盖全部信息,但融合后若不标注来源,读者无法判断结论可信度。
为什么难:不同来源的格式不同(PDF/网页/Markdown)、权威性不同(论文原文 > 综述解读 > 个人笔记),需要统一入库且保留来源元数据。
方案:① 为每份文档注入 metadata["source"]metadata["title"] 来源标签;② 统一使用同一 Embedding 模型入库到同一向量库;③ 检索结果携带来源信息,生成答案时自动标注"来源:XX文档 第X页"。
效果:多源覆盖率 ≥ 60%,来源标注准确率 100%,用户对回答的信任度显著提升。
💬 多源融合示例
用户:「Transformer 的核心贡献是什么?」
论文原文:"提出完全基于注意力机制的序列建模架构"(来源:论文原文)
综述解读:"用 self-attention 替代 RNN,显著提升并行效率"(来源:综述/解读)
实验笔记:"复现实验时需要关注 batch size、学习率 warmup 等细节"(来源:实验复现/笔记)
融合回答:"Transformer 的核心贡献是提出自注意力架构(论文原文),并用并行训练提升效率(综述解读);复现时需要注意训练细节(实验笔记)。"

难点 4 两阶段检索:Top-K 召回 + Rerank 精排

问题:向量相似度 ≠ 语义相关性。用户问"Transformer 的创新点是什么?",相似度检索可能把“背景动机/实验指标”等段落排在“贡献点列表”前面,用户第一眼看到的不是最需要的答案。
为什么难:Embedding 模型将文本映射到向量空间后,"相似"不等于"相关"——包含相同关键词但语义不同的段落也会被高分召回。
方案:实施两阶段检索:① 第一阶段用向量相似度大范围召回 Top-20 候选段落(Recall);② 第二阶段用 BGE-Reranker 对候选段落做 Cross-Encoder 精排,返回真正最相关的 Top-4(Rank)。
效果:Top-1 命中率从 45% 提升至 78%,用户第一眼看到正确答案的概率大幅提高。
45%→78%
Top-1 命中率提升
2 阶段
Recall + Rerank
20→4
候选精排压缩比

难点 5 RAG 幻觉抑制与引用溯源

问题:当论文合集里确实没有覆盖某个问题时,大模型倾向于"编造"一个看似合理的回答,容易误导学习与总结。
为什么难:大模型天然具有"创造性",即使检索到的段落不相关也会强行回答;论文学习要求每条答案都"可追溯、可验证、可追责"。
方案:三层幻觉防御:① System Prompt 约束"仅基于检索内容回答,无答案时明确说明'文档未覆盖'";② temperature 设为 0.2 最大程度降低随机性;③ 答案必须携带引用来源(文档名+页码),用户可自行核实。
📜 幻觉抑制 System Prompt
你是一个严谨的论文学习问答助手。请严格根据给定的【上下文】回答问题。
规则:
1. 仅使用上下文中的信息作答,不要使用你自己的知识
2. 如果上下文无法回答该问题,请明确回复:「根据当前论文合集,未找到相关信息」
3. 回答时标注依据来源(文档名、页码),增强可信度
4. 不确定的内容用「文档中提到…」而非断言式表达
30%→5%
幻觉率大幅下降
100%
引用标注准确率
3 层
防幻觉策略

难点 6 多轮对话上下文管理与指代消解

问题:用户第一轮问"Transformer 最早是哪篇论文提出的?",第二轮追问"那篇论文的核心贡献是什么?"——"那篇"指代什么?如果仅用当前问题检索,会召回错误段落。
为什么难:追问包含指代消解("它""这个""上面说的""那"),直接用当前问题做向量检索会丢失上下文;但拼接全部历史又导致 prompt 过长、成本上升。
方案:使用 RunnableWithMessageHistory 管理对话历史,将 chat_history 注入 prompt 的 MessagesPlaceholder;LLM 结合对话上下文理解当前问题的完整语义后再检索,自动完成指代消解。
💬 多轮对话示例(指代消解过程)
第 1 轮 用户:「Transformer 最早是哪篇论文提出的?」→ 检索:Attention Is All You Need → 回答:"该论文提出 Transformer"
第 2 轮 用户:「那篇论文的核心贡献是什么?」→ 结合历史理解为:Attention Is All You Need 的核心贡献
第 3 轮 用户:「它相比 RNN 的关键改进是什么?」→ 结合历史理解为:Transformer 相比 RNN 的改进点
90%+
多轮追问准确率
自动
指代消解
LCEL
Runnable 链式架构

难点 7 量化评估体系:用数据证明 RAG 有效

问题:向导师汇报时说"AI 回答更准了"毫无说服力,学术研究需要数据支撑。
为什么难:"回答好不好"本身就是主观判断;需要设计可量化、可复现的评估方案,对比"有无 RAG"的效果差异。
方案:建立完整评测体系:① 准备 20+ 标准测试问答对;② 对比纯 LLM vs RAG 的准确率、幻觉率、响应时间;③ 用 Recall@K、MRR、nDCG 量化检索质量;④ 形成可视化评估报告,用数据驱动技术决策。
效果:准确率从 60% 提升至 85%,幻觉率从 30% 降至 5%,平均响应时间 <3s,用户满意度 4.5/5
60→85%
准确率提升
30→5%
幻觉率下降
<3s
端到端延迟
4.5/5
用户满意度

🏆 面试这样讲,让面试官眼前一亮

❌ "我做了一个知识库问答" → ✅ "我基于 RAG 架构设计并开发了一套论文学习知识库系统,核心亮点是:发现并修复了 LangChain 默认 keep_separator 导致句末命中率只有 25% 的隐蔽陷阱,用零宽断言方案将命中率提升至 100%;配合 MMR + BGE-Reranker 两阶段检索,最终准确率从 60% 提升至 85%,幻觉率从 30% 降至 5%。"

❌ "我用了向量检索" → ✅ "我对比了 similarity 和 MMR 两种检索策略,发现纯相似度检索的段落覆盖率只有 40%,引入 MMR 后提升到 80%+,再配合 BGE-Reranker 精排,Top-1 命中率从 45% 提升到 78%。"

❌ "我做了多轮对话" → ✅ "使用 LCEL 的 RunnableWithMessageHistory 管理对话上下文,通过 MessagesPlaceholder 注入历史消息,LLM 自动完成指代消解,多轮追问准确率 90%+。"

❌ "效果还不错" → ✅ "我建立了完整的量化评估体系,用 20 个标准测试问答对对比纯 LLM 和 RAG 的准确率、幻觉率、响应时间,用数据证明 RAG 方案的有效性。"

🎤 面试官最可能追问的 7 个问题(附参考回答)

Q1:chunk_size 为什么选 1000?切分策略有什么讲究?
→ 太小(如 200)语义不完整,检索到碎片无法回答;太大(如 3000)向量语义被稀释,检索精度下降。1000 是中文段落的经验最佳值,配合 overlap=50 防止跨段信息丢失。更关键的是切分边界质量:直接用 separators=["。"] 时,默认 keep_separator=True 会把句号推到下一个 chunk 开头,句末命中率只有 25%;改用零宽断言 (?<=。) + is_separator_regex=True,句号留在当前 chunk 结尾,命中率提升到 100%。

Q2:MMR 和 similarity 检索有什么区别?为什么选 MMR?
→ similarity 只按相似度排序,Top-K 可能全是重复内容;MMR 在"相关性"和"多样性"之间取平衡,先 fetch_k=20 大范围召回,再从中选出语义互补的 4 个片段,段落覆盖率从 40% 提升到 80%+。

Q3:Reranker 和向量检索有什么不同?
→ 向量检索用 Bi-Encoder,把 query 和 doc 分别编码后算余弦距离,速度快但精度一般;Reranker 用 Cross-Encoder,把 query 和 doc 拼在一起过 Transformer,精度高但速度慢。所以用两阶段:先用向量检索大范围召回(快),再用 Reranker 精排(准)。

Q4:多源知识库怎么解决不同文档的冲突?
→ 通过 metadata 标注来源和权威等级(论文原文 > 综述解读 > 个人笔记),答案生成时标注每条信息的来源,让读者自行判断;如果两个来源矛盾,明确展示差异而非隐藏。

Q5:如何确保模型"不知道就说不知道"?
→ 三层防御:① System Prompt 约束"仅基于上下文回答";② temperature=0.2 降低随机性;③ 可扩展相似度阈值判断,低于阈值时直接返回"文档未覆盖"。实际测试幻觉率从 30% 降到 5% 以下。

Q6:如果要支持 Word / Excel / 网页等更多格式怎么扩展?
→ LangChain 提供 Docx2txtLoader / UnstructuredExcelLoader / WebBaseLoader 等 50+ 加载器,只需替换 Loader 组件,后续的分块、向量化、检索链路完全复用,体现了 RAG 架构的模块化优势。

Q7:这套系统的性能瓶颈在哪?如何优化?
→ 瓶颈在 Embedding 生成和 LLM 推理。优化方案:① 文档 MD5 缓存避免重复处理;② Embedding 批量调用(batch_size=25)减少 API 请求;③ Streamlit @st.cache_resource 缓存向量库实例;④ 流式输出(streaming)提升用户感知速度。

📋 简历项目经验(可直接复制粘贴)

以下提供完整版精简版一句话版纯文本复制版,可根据简历篇幅选用,微调后直接粘贴。

完整版 · 项目经验重点描述
「智阅」论文知识库智能问答系统 Python / LangChain / 通义千问 / Chroma / Streamlit

项目描述:基于 RAG(检索增强生成)架构,独立设计并开发了一套论文/笔记知识库智能问答系统,支持多格式文档上传(PDF/Word/Markdown),实现多源知识融合、两阶段检索、引用溯源和多轮对话,覆盖论文原文、综述解读、实验复现笔记等场景。

核心职责与成果:
• 设计并实现完整 RAG 链路:文档加载(PyPDFLoader/TextLoader)→ 智能分块(RecursiveCharacterTextSplitter + 零宽断言 (?<=。),句末命中率从 25% 提升至 100%)→ 向量化(DashScope Embedding)→ 向量存储(Chroma)→ 语义检索 → LLM 生成,端到端单次问答延迟 < 3s
• 发现并修复 LangChain 默认 keep_separator=True 导致句号被推到下一 chunk 开头的隐蔽陷阱,改用 is_separator_regex=True + 零宽断言方案,Top-3 命中率从 55% 提升至 82%,检索相关性提升约 30%。
• 实现 MMR 多样性检索 + BGE-Reranker 两阶段精排:先 fetch_k=20 大范围召回,再用 Cross-Encoder 精排 Top-4;段落覆盖率从 40% 提升至 80%+,Top-1 命中率从 45% 提升至 78%
• 实现 多源知识库融合(论文原文 + 综述解读 + 实验笔记),为每份文档注入来源元数据,生成答案时自动标注来源页码,来源标注准确率 100%,多源覆盖率 ≥ 60%。
• 在 System Prompt 层面实施 3 层 RAG 幻觉抑制策略(prompt 约束 + temperature=0 + 引用溯源),文档中无相关信息时明确告知用户,幻觉回答率从 30% 降至 5% 以下
• 使用 LCEL RunnableWithMessageHistory 实现带指代消解的多轮检索问答,通过 MessagesPlaceholder 注入对话历史,自动解决追问中"它""这个"等指代问题,多轮追问准确率 90%+
• 建立 量化评估体系(准确率/幻觉率/Recall@K/MRR),对比"纯 LLM vs RAG"效果差异:准确率从 60% 提升至 85%,用数据驱动技术决策。
精简版 · 简历空间有限时使用
「智阅」论文知识库智能问答系统 Python / LangChain / 通义千问 / Chroma / Streamlit

• 基于 RAG 架构实现论文知识库问答系统,完整实现文档加载 → 智能分块(零宽断言 (?<=。),句末命中率 100%)→ 向量化 → 语义检索 → LLM 生成全链路,单次问答延迟 < 3s。
• 发现并修复 LangChain 默认 keep_separator 陷阱,Top-3 命中率从 55% 提升至 82%;MMR + BGE-Reranker 两阶段精排,Top-1 命中率从 45% 提升至 78%。
• 多源知识融合(论文原文 + 综述解读 + 实验笔记),来源标注准确率 100%;3 层幻觉抑制策略,幻觉率从 30% 降至 5%。
• LCEL RunnableWithMessageHistory 实现多轮追问指代消解,准确率 90%+;量化评估体系(Recall@K/MRR),RAG vs 纯 LLM 准确率提升 25 个百分点。
一句话亮点 · 不同场景使用
📧 内推/邮件:
独立开发了基于 RAG 架构的论文知识库系统,发现并修复 LangChain 默认切分陷阱(句末命中率 25%→100%),结合 MMR + BGE-Reranker 两阶段检索、多源融合与幻觉抑制,准确率从 60% 提升至 85%。

💬 面试自我介绍:
我独立做了一个论文学习 RAG 知识库项目,核心亮点有三个:① 发现并修复了 LangChain 默认 keep_separator 导致句末命中率只有 25% 的隐蔽陷阱,用零宽断言方案提升至 100%;② MMR + BGE-Reranker 两阶段检索,Top-1 命中率 78%;③ 建立量化评估体系,用数据证明 RAG 比纯 LLM 准确率高 25 个百分点。

📝 项目列表栏(一行):
论文知识库问答系统 | RAG + LangChain + 通义千问 + Chroma | 零宽断言切分句末命中率 100%,两阶段检索 Top-1 命中率 78%,幻觉率 <5%,准确率 85%
📋 纯文本版 · 直接复制粘贴到简历

以下为纯文本格式,可直接全选复制粘贴到 Word / PDF 简历中:

「智阅」论文知识库智能问答系统    Python / LangChain / 通义千问 / Chroma / Streamlit

项目描述:基于 RAG(检索增强生成)架构,独立设计并开发了一套论文学习知识库智能问答系统,支持多格式文档上传(PDF/Word/Markdown),实现多源知识融合、两阶段检索、引用溯源和多轮对话,覆盖论文原文、综述解读、实验笔记等场景。

- 设计并实现完整 RAG 链路:文档加载 → 智能分块(RecursiveCharacterTextSplitter + 零宽断言 (?<=。),句末命中率从 25% 提升至 100%)→ 向量化(DashScope Embedding)→ 向量存储(Chroma)→ 语义检索 → LLM 生成,端到端单次问答延迟 < 3s
- 发现并修复 LangChain 默认 keep_separator=True 导致句号被推到下一 chunk 开头的隐蔽陷阱,改用 is_separator_regex=True + 零宽断言方案,Top-3 命中率从 55% 提升至 82%,检索相关性提升约 30%
- 实现 MMR 多样性检索 + BGE-Reranker 两阶段精排:先 fetch_k=20 大范围召回,再 Cross-Encoder 精排 Top-4,段落覆盖率从 40% 提升至 80%+,Top-1 命中率从 45% 提升至 78%
- 实现多源知识库融合(论文原文 + 综述解读 + 实验笔记),为每份文档注入来源元数据,生成答案自动标注来源页码,来源标注准确率 100%
- 实施 3 层 RAG 幻觉抑制策略(prompt 约束 + temperature=0 + 引用溯源),幻觉回答率从 30% 降至 5% 以下
- 使用 LCEL RunnableWithMessageHistory 实现带指代消解的多轮检索问答,自动解决追问中"它""这个"等指代问题,多轮追问准确率 90%+
- 建立量化评估体系(准确率/幻觉率/Recall@K/MRR),对比纯 LLM vs RAG 准确率从 60% 提升至 85%,用数据驱动技术决策

💡 简历撰写技巧

  • 数据量化:"Top-1 命中率从 45% 提升到 78%"比"优化了检索效果"有说服力 10 倍。
  • 技术栈前置:项目名旁边直接列技术栈,HR 筛简历 10 秒内能看到关键词。
  • 动词开头:每条用"设计 / 构建 / 实现 / 优化"开头,不要用"负责 / 参与"。
  • 架构思维:体现你对 RAG 链路端到端的理解(分块→检索→精排→生成→评估),而不仅仅是"调了个 API"。
  • 对比实验:展示你做过 A/B 对比(similarity vs MMR、有无 Reranker、不同 chunk_size、硬切 vs 零宽断言),说明你有工程判断力。
  • 踩坑经历:"发现并修复了 XX 陷阱"比"实现了 XX 功能"更有深度,说明你真正跑过代码、遇到过问题。
  • 可验证性:写上去的每条都要能现场演示或画图讲清楚,数据要能复现。

📝 本章小结

🎓 你完成了「智阅」论文知识库助手的7大核心能力

  • 1、RAG 架构设计:掌握文档加载 → 智能切分 → 向量化 → 向量存储 → 检索增强生成的完整链路。
  • 2、文档智能处理:实现多格式文档加载,掌握不同切分策略对召回率的影响。
  • 3、向量检索系统:使用 DashScope/BGE Embeddings,掌握 Chroma/Qdrant 向量库存储与检索。
  • 4、多源知识库联动:对接产品手册、FAQ 等,实现"问题-答案"智能映射。
  • 5、检索质量优化:实现 Top-K 召回 + Rerank 两阶段检索,建立评测指标体系。
  • 6、上下文增强与引用溯源:实现上下文压缩、引用溯源,生成带来源引用的答案。
  • 7、业务价值量化:对比"有无 RAG"的效果差异,建立用户满意度等业务指标。
  • ✅ 你掌握了「智阅」项目的 7 大核心难点及其解决方案,面试时能讲出"问题→原因→方案→效果"的完整闭环。
  • ✅ 你拥有了一份可直接粘贴到简历上的 项目经验描述(完整版 / 精简版 / 一句话版 / 纯文本版)。

🚀 下一步学习

你已掌握「智阅」论文知识库助手的开发能力。 接下来,进入阶段 3:「数析」智能数据分析台, 学习如何将 AI 与数据分析结合,打造智能数据分析平台。

← 上一章 下一章 →