人工智能实践(语言智能)
第4讲:RAG

上机实验:PKU 研究生手册 QA 系统

端到端构建北大研究生手册问答系统——从 PDF 解析、索引构建、混合检索到 LLM 生成与 RAGAS 评估

实验概述

本实验将完成第 4 讲的核心产出:一个可交互使用的北大研究生手册问答系统。你需要完成从原始文档到可评估系统的完整链路,并用 RAGAS + 人工标注诊断和优化。

项目详情
知识源北京大学研究生手册(PDF/HTML)
嵌入模型BAAI/bge-small-zh-v1.5(教学版)或 bge-m3(进阶)
向量库FAISS(Colab 友好)或 Chroma(本地持久化)
检索BM25 + Dense 混合(RRF 融合)
生成通义千问 qwen-max 或本地 Qwen3
评估RAGAS 四指标 + 人工标注
预计时间上机 80 分钟 + 课外完善

资源要求:Colab T4 即可完成基础版本;进阶版(bge-m3 + Rerank)建议 A100-40G。如无 GPU,可用 CPU 运行 bge-small 模型,仅生成环节依赖 API。


实验步骤

步骤 1:获取并解析手册

首先拿到原始文档。可选来源:

  • 北大研究生院官网公开的《研究生手册》PDF
  • HTML 版本(如有)
  • 学院或导师整理的制度文件集

解析 PDF 推荐 pymupdf,它对中文表格友好:

pip install -q pymupdf langchain-community langchain-text-splitters \
    rank_bm25 faiss-cpu sentence-transformers jieba ragas datasets \
    dashscope
import fitz  # pymupdf

def parse_pdf(pdf_path: str) -> list[dict]:
    """把 PDF 解析为 [{'page': i, 'text': '...'}, ...]"""
    doc = fitz.open(pdf_path)
    pages = []
    for i, page in enumerate(doc, 1):
        text = page.get_text().strip()
        if text:
            pages.append({"page": i, "text": text})
    print(f"共解析 {len(pages)} 页")
    return pages

pages = parse_pdf("pku_grad_handbook.pdf")

清洗要点

  1. 去除页眉页脚(固定字符串)
  2. 合并被换行打断的段落
  3. 识别并保留章节标题,作为 metadata
  4. 过滤空白页和目录页

先人工抽样检查 10 页 的解析结果,确认段落完整、表格不乱。文档解析质量直接决定整个系统的上限——这一步偷懒后面会付出 10 倍代价。

步骤 2:切分 + 建索引(FAISS)

使用 Recursive Splitter 切分,同时保留章节 metadata 用于后续溯源:

from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=400,
    chunk_overlap=60,
    separators=["\n\n", "\n", "。", ";", ",", " ", ""],
    length_function=len,
)

chunks = []
for pg in pages:
    pieces = splitter.split_text(pg["text"])
    for j, piece in enumerate(pieces):
        chunks.append({
            "chunk_id": f"p{pg['page']}_c{j}",
            "page": pg["page"],
            "text": piece.strip(),
        })
print(f"共切分 {len(chunks)} 块")

构建稠密索引(FAISS)

import faiss, numpy as np
from sentence_transformers import SentenceTransformer

embed_model = SentenceTransformer("BAAI/bge-small-zh-v1.5")
texts = [c["text"] for c in chunks]
emb = embed_model.encode(texts, normalize_embeddings=True,
                          show_progress_bar=True).astype(np.float32)

index = faiss.IndexFlatIP(emb.shape[1])
index.add(emb)
print(f"稠密索引:{index.ntotal} 向量 × {emb.shape[1]} 维")

构建稀疏索引(BM25)

from rank_bm25 import BM25Okapi
import jieba

tokenized = [list(jieba.cut(c["text"])) for c in chunks]
bm25 = BM25Okapi(tokenized)

步骤 3:混合检索器(BM25 + Dense + RRF)

把两路检索结果用 RRF 融合:

def dense_search(query: str, k: int = 10) -> list[int]:
    q = "为这个句子生成表示以用于检索相关文章:" + query
    q_emb = embed_model.encode([q], normalize_embeddings=True
                                ).astype(np.float32)
    _, ids = index.search(q_emb, k)
    return ids[0].tolist()

def bm25_search(query: str, k: int = 10) -> list[int]:
    tokens = list(jieba.cut(query))
    scores = bm25.get_scores(tokens)
    return scores.argsort()[::-1][:k].tolist()

def rrf_fuse(rankings: list[list[int]], k: int = 60,
              top_k: int = 5) -> list[dict]:
    scores = {}
    for ranking in rankings:
        for rank, idx in enumerate(ranking, start=1):
            scores[idx] = scores.get(idx, 0) + 1 / (k + rank)
    top = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:top_k]
    return [chunks[idx] | {"rrf_score": s} for idx, s in top]

def hybrid_retrieve(query: str, top_k: int = 5) -> list[dict]:
    return rrf_fuse([dense_search(query, 10), bm25_search(query, 10)],
                     top_k=top_k)

# 测试
for c in hybrid_retrieve("研究生学位论文答辩流程是什么"):
    print(f"[p{c['page']}] {c['text'][:80]}...")

进阶(可选):加一层 BAAI/bge-reranker-base 做精排,Top-10 → Top-3。

步骤 4:生成器(LLM + Prompt 模板)

from dashscope import Generation
import os
os.environ["DASHSCOPE_API_KEY"] = "sk-xxxxx"  # 替换

SYSTEM_PROMPT = """你是北大研究生手册问答助手。请严格基于【参考资料】回答【问题】。

要求:
1. 只使用参考资料中明确提到的信息,不要补充或推测
2. 在每个结论后用【资料 N】形式标注依据
3. 如果参考资料中没有答案,请直接回答"根据现有资料无法回答"
4. 回答应简洁、准确、可溯源"""

def format_context(ctxs: list[dict]) -> str:
    return "\n\n".join(
        f"【资料 {i}】(第 {c['page']} 页)\n{c['text']}"
        for i, c in enumerate(ctxs, 1)
    )

def rag_answer(question: str, top_k: int = 5) -> dict:
    ctxs = hybrid_retrieve(question, top_k=top_k)
    prompt = f"""{SYSTEM_PROMPT}

【参考资料】
{format_context(ctxs)}

【问题】
{question}

【回答】"""
    resp = Generation.call(
        model="qwen-max",
        prompt=prompt,
        result_format="message",
        temperature=0.2,
    )
    answer = resp.output.choices[0].message.content.strip()
    return {
        "question": question,
        "answer": answer,
        "contexts": [c["text"] for c in ctxs],
        "sources": [f"第 {c['page']} 页" for c in ctxs],
    }

# 试一问
result = rag_answer("研究生请假一周需要哪些审批手续?")
print(result["answer"])
print("\n来源:", result["sources"])

步骤 5:评估(RAGAS + 人工标注)

5.1 准备金标数据集:至少 15-20 条,覆盖以下类型:

  • 简单事实(答案在单一段落)
  • 综合(需要多段落)
  • 知识库外(测拒答能力)
  • 模糊/开放式
gold_dataset = [
    {
        "question": "研究生学位论文答辩委员会的人数要求是什么?",
        "ground_truth": "博士学位论文答辩委员会由 5-7 人组成;硕士为 3-5 人。",
    },
    {
        "question": "研究生休学的最长期限是多少?",
        "ground_truth": "...",
    },
    # ... 至少 15 条
]

5.2 运行 RAGAS

from ragas import evaluate
from ragas.metrics import (faithfulness, answer_relevancy,
                            context_precision, context_recall)
from datasets import Dataset

eval_data = {"question": [], "ground_truth": [],
             "answer": [], "contexts": []}
for item in gold_dataset:
    r = rag_answer(item["question"])
    eval_data["question"].append(item["question"])
    eval_data["ground_truth"].append(item["ground_truth"])
    eval_data["answer"].append(r["answer"])
    eval_data["contexts"].append(r["contexts"])

results = evaluate(
    Dataset.from_dict(eval_data),
    metrics=[faithfulness, answer_relevancy,
             context_precision, context_recall],
)
print(results)

5.3 人工标注:对每条记录一个 1-5 分的综合可读性评分,并把明显失败案例做 case study。

case study 的重要性高于单个数字。一个 Recall=0.85 的系统可能在某一类问题上完全失效——只有通过逐条人工检查你才能发现。


提交物清单

  • 完整 Jupyter Notebook(含解析、切分、索引、检索、生成、评估)
  • RAGAS 四指标评估表
  • 至少 15 条金标 QA 对
  • 至少 3 个失败 case 分析(为什么失败、如何修复)
  • 2 页实验报告:系统架构、评估结果、未来改进方向

进阶加分项

from sentence_transformers import CrossEncoder
reranker = CrossEncoder("BAAI/bge-reranker-base")

def hybrid_retrieve_rerank(query, top_retrieve=20, top_final=5):
    candidates = hybrid_retrieve(query, top_k=top_retrieve)
    pairs = [[query, c["text"]] for c in candidates]
    scores = reranker.predict(pairs)
    for c, s in zip(candidates, scores):
        c["rerank_score"] = float(s)
    candidates.sort(key=lambda x: x["rerank_score"], reverse=True)
    return candidates[:top_final]

对比 Rerank 前后的 Context Precision 变化。

把 400 字符的块作为子块用于嵌入和检索,但在召回时返回其所在的 1000 字符父段落。

关键实现:维护 parent_id → parent_text 映射,检索到子块后查找并返回父文本。

import gradio as gr

def ui_answer(question):
    r = rag_answer(question)
    return r["answer"], "\n".join(r["sources"])

gr.Interface(
    fn=ui_answer,
    inputs=gr.Textbox(label="问题"),
    outputs=[gr.Textbox(label="回答"), gr.Textbox(label="来源")],
    title="北大研究生手册问答",
).launch()

评分标准

项目分值评分要点
数据处理15 分PDF 解析质量、清洗、切分合理性
检索实现25 分BM25 + Dense + RRF 正确、召回合理
生成实现15 分Prompt 设计、溯源标注、拒答机制
RAGAS 评估20 分金标数据集质量、四指标计算
故障分析15 分case study 深度、可改进方向明确
加分项10 分至少完成一项进阶加分