RAG:知识库问答系统教学②-零基础保姆级教程

第 6 章:文档解析 ------ 让 PDF/Word/Markdown 进得来

6.1 三种常见格式的读取代码

python 复制代码
# pip install pypdf python-docx
from pypdf import PdfReader
from docx import Document
from pathlib import Path

def load_pdf(path: str) -> str:
    reader = PdfReader(path)
    return "\n".join(page.extract_text() or "" for page in reader.pages)

def load_docx(path: str) -> str:
    doc = Document(path)
    return "\n".join(p.text for p in doc.paragraphs if p.text.strip())

def load_text(path: str) -> str:                       # .md / .txt 直接读
    return Path(path).read_text(encoding="utf-8")

def load_any(path: str) -> str:
    suffix = Path(path).suffix.lower()
    loaders = {".pdf": load_pdf, ".docx": load_docx, ".md": load_text, ".txt": load_text}
    if suffix not in loaders:
        raise ValueError(f"暂不支持的格式: {suffix}")
    return loaders[suffix](path)                       # 注册表模式再登场(03批12.2)

6.2 残酷现实:解析是 RAG 的隐形天花板

pypdf 这类"提取文本"的库对付规整的文字型 PDF 没问题,但真实企业文档充满地雷:

|--------------|---------------|-------------------------------|
| 地雷 | 现象 | 对策 |
| 扫描件(图片型 PDF) | 提取出来是空的 | 需 OCR(如 PaddleOCR) |
| 表格 | 行列关系被打散成一锅粥 | 版面解析工具(见下),或转成 Markdown 表格再入库 |
| 双栏/复杂版式 | 阅读顺序错乱、页眉页脚混入 | 版面解析工具 |
| 图表里的信息 | 纯文字提取完全丢失 | 多模态方案(第 15 章一提) |

行业主流对策 :上专业的文档版面解析 方案------开源的 MinerU、PaddleOCR 的 PP-Structure 系、以及各云厂商的文档智能 API,它们能把 PDF 还原成带结构的 Markdown(标题层级、表格、公式各归各位)。本教程用 Markdown/规整文档教学以聚焦主线,但面试聊到"你们文档解析怎么做的",能说出上面这张地雷表 + MinerU 这类方案名,就是有实战认知的信号


第 7 章:文档切分 Chunking ------ RAG 效果第一杠杆

7.1 为什么要切?切错会怎样?

不能整篇文档做一个向量:①一篇文档讲十个主题,向量成了"平均脸",谁搜都似像非像;②检索命中后要塞进上下文,整篇太长。所以切成块(chunk) ------每块最好只讲一件事

切分的两难(面试必考的 trade-off):

  • 块太大 → 向量语义被稀释、检索不准;命中后夹带大量无关内容浪费上下文。
  • 块太小 → 语义残缺("需在30天内提交"------什么东西30天内?主语在上一块!);模型拿到碎片拼不出完整答案。

7.2 策略一:固定长度 + 重叠(基线方案)

read_in_chunks正式转正,加上 overlap(重叠)

python 复制代码
def split_fixed(text: str, chunk_size: int = 400, overlap: int = 60) -> list[str]:
    """固定长度切分,相邻块重叠 overlap 个字符"""
    if overlap >= chunk_size:
        raise ValueError("overlap 必须小于 chunk_size")
    chunks, start = [], 0
    while start < len(text):
        chunks.append(text[start: start + chunk_size])
        start += chunk_size - overlap        # 每次前进"块长-重叠"
    return chunks

overlap 存在的意义 :一句关键话恰好被切口斩断时,重叠区保证它在相邻块里至少完整出现一次。经验值:overlap = chunk_size 的 10%~20%

chunk_size 经验值(中文,起点值,最终靠第 14 章评测定):制度/FAQ 类 200400 字;叙述性长文 400600 字。换算成 token 约乘 0.6~1。

7.3 策略二:递归切分(尊重自然边界)

固定切分会把句子拦腰砍断。递归切分按分隔符优先级"先段落、再句子、最后才硬切":

python 复制代码
def split_recursive(text: str, chunk_size: int = 400,
                    seps: list[str] = ["\n\n", "\n", "。", ","]) -> list[str]:
    """按分隔符优先级递归切分:能按段切不按句切,能按句切不硬切"""
    if len(text) <= chunk_size:
        return [text]
    if not seps:                                    # 分隔符用尽,只能硬切
        return split_fixed(text, chunk_size, overlap=0)
    sep, rest = seps[0], seps[1:]
    pieces = [p for p in text.split(sep) if p.strip()]
    chunks, buf = [], ""
    for p in pieces:
        candidate = (buf + sep + p) if buf else p
        if len(candidate) <= chunk_size:
            buf = candidate                          # 还装得下,继续攒
        else:
            if buf:
                chunks.append(buf)
            # 单段自身超长 → 用更细的分隔符递归收拾它
            buf = "" 
            if len(p) > chunk_size:
                chunks.extend(split_recursive(p, chunk_size, rest))
            else:
                buf = p
    if buf:
        chunks.append(buf)
    return chunks

这就是 LangChain 里大名鼎鼎的 RecursiveCharacterTextSplitter 的简化原理版

7.4 策略三:结构感知切分(Markdown 按标题切)★综合项目主力

对有标题层级的文档(Markdown/解析后的 PDF),按章节切天然语义完整,还白送元数据(标题):

python 复制代码
def split_by_headers(md_text: str) -> list[dict]:
    """按 Markdown 标题切分,每块携带标题作为元数据"""
    sections, title, buf = [], "文档开头", []
    for line in md_text.splitlines():
        if line.lstrip().startswith("#"):            # 遇到新标题
            if buf and "".join(buf).strip():
                sections.append({"title": title, "text": "\n".join(buf).strip()})
            title, buf = line.lstrip("#").strip(), []
        else:
            buf.append(line)
    if buf and "".join(buf).strip():
        sections.append({"title": title, "text": "\n".join(buf).strip()})
    return sections

生产组合拳:先按标题切大节 → 超长的节内部再递归切 → 每个小块的元数据记下它所属的标题路径。检索时标题还能拼进块文本一起向量化("考勤制度 > 迟到处理:......"),显著提升召回------这个技巧综合项目直接用。

7.5 策略四速览:语义切分与"一页纸决策表"

语义切分:逐句算 Embedding,相邻句相似度骤降处(话题切换点)下刀。效果好但建库成本高(每句都要向量化),大规模场景酌情使用------知道原理即可。

|----------------|----------------------------------|
| 文档类型 | 首选策略 |
| Markdown/有标题结构 | 标题切分 + 节内递归(★默认答案) |
| 无结构长文 | 递归切分 |
| FAQ/条款(天然一条一意) | 按条切,一条一块 |
| 代码 | 按函数/类切(语法感知) |
| 什么都不确定时 | 递归切分 400 字 + 15% overlap 起步,评测调优 |


第 8 章:向量数据库入门 ------ Chroma

8.1 为什么需要向量数据库

第 4 章的"for 循环逐条算相似度"是暴力检索 :5 条文档没问题,500 万条时每次提问算 500 万次余弦=灾难。向量数据库解决三件事:①近似最近邻索引(ANN,如 HNSW 算法)把检索从"遍历"变"跳查",百万级毫秒响应;②向量+原文+元数据一起持久化管理;③增删改查、过滤、多租户等数据库该有的一切。

HNSW 一句话(面试够用):把向量组织成多层"高速公路网",查询时从高层粗跳到低层精找,以极小的精度损失换取数量级的速度提升------"近似"二字的由来。

8.2 Chroma 五分钟上手(学习与原型首选)

python 复制代码
# pip install chromadb
import chromadb

chroma = chromadb.PersistentClient(path="./chroma_db")     # 数据落盘到本目录
col = chroma.get_or_create_collection(
    name="handbook",
    metadata={"hnsw:space": "cosine"},        # 指定用余弦距离(默认是L2,中文检索务必改)
)

chunks = [
    {"id": "c1", "text": "员工每年享有5天带薪年假,入职满3年增加至10天。", "title": "休假制度"},
    {"id": "c2", "text": "报销需在费用发生后30天内提交,超期不予受理。",     "title": "报销制度"},
    {"id": "c3", "text": "试用期为3个月,考核合格后转正。",                 "title": "入职转正"},
]

col.add(                                       # 入库:四个平行列表,一一对应
    ids=[c["id"] for c in chunks],
    documents=[c["text"] for c in chunks],
    embeddings=embed([c["text"] for c in chunks]),          # 用第4章的embed函数
    metadatas=[{"title": c["title"]} for c in chunks],
)

res = col.query(
    query_embeddings=embed(["休假有什么规定"]),
    n_results=2,
)
print(res["documents"][0])      # 命中的文本列表(注意是列表套列表:外层对应多个查询)
print(res["distances"][0])      # 距离列表:cosine空间下 距离=1-相似度,越小越相似!
print(res["metadatas"][0])      # 元数据跟着回来 ------ 引用溯源的原料

三个必踩坑提前排雷:①返回结构是"列表套列表" (支持一次多查询),取结果先 [0];②cosine 空间下 Chroma 返回的是距离 (1−相似度),别把 0.3 当成"不相似"------它其实=相似度 0.7;③自己传 embeddings(如上)而不是让 Chroma 用内置默认模型------默认模型偏英文,中文效果差,且自己传才能保证"查询和文档用同一个模型"(第 5 章细节 2)。

8.3 增删改与"重建库"

python 复制代码
col.update(ids=["c1"], documents=["新版:年假改为10天起步。"],
           embeddings=embed(["新版:年假改为10天起步。"]))   # 改内容必须重算向量!
col.delete(ids=["c3"])
print(col.count())

工程习惯 :文档源变更后,简单粗暴但可靠的方案是按文档为单位删旧增新 (元数据里记 source 文件名,col.delete(where={"source": "员工手册.md"}) 后重灌)------增量同步的复杂度综合项目里体会。


第 9 章:FAISS 与 Milvus ------ 从单机到大厂主流

9.1 三兄弟怎么分工(选型必考)

|----|-----------|--------------------------------------------|----------------------------------|
| | Chroma | FAISS | Milvus |
| 定位 | 轻量向量数据库 | Meta 开源的向量索引库(不是数据库:无持久化管理/无过滤器/纯算法引擎) | 分布式向量数据库(Zilliz 出品,国内大厂事实主流) |
| 规模 | 单机百万级 | 单机千万级、算法性能天花板 | 集群十亿级、高可用 |
| 场景 | 学习/原型/小项目 | 嵌进自己代码做极致性能检索、学术 | 企业生产(JD 里点名率最高) |

其它常见选项一句话备着:Elasticsearch (8.x 起支持向量,老 ES 团队顺手升级的选择,天然强于混合检索)、pgvector (PostgreSQL 插件,"不想多养一个数据库"的团队最爱)、Qdrant/Weaviate(海外流行)、腾讯云 VectorDB 等云托管。

9.2 FAISS 十行体验(理解"索引库"是什么)

python 复制代码
# pip install faiss-cpu numpy
import faiss, numpy as np

vecs = np.array(embed([c["text"] for c in chunks]), dtype="float32")
faiss.normalize_L2(vecs)                      # 归一化后,内积 == 余弦相似度
index = faiss.IndexFlatIP(vecs.shape[1])      # IP=内积;Flat=暴力精确检索
index.add(vecs)

q = np.array(embed(["休假规定"]), dtype="float32")
faiss.normalize_L2(q)
scores, ids = index.search(q, k=2)
print(scores, ids)        # 相似度 与 向量的序号 ------ 原文要靠你自己按序号对回去

看到了吗------FAISS 只管向量进向量出,原文、元数据、持久化全要你自己管。这就是"库(library)"与"数据库(database)"的区别,面试一句话讲清。

9.3 Milvus:大厂里的真主角(Lite 版本地练)

Milvus 完整版是分布式集群;练手用官方 Milvus Lite------pip 装完一个文件就是库:

python 复制代码
# pip install pymilvus
from pymilvus import MilvusClient

mc = MilvusClient("milvus_demo.db")            # 本地文件即数据库(Lite模式)
mc.create_collection(collection_name="handbook", dimension=1024,
                     metric_type="COSINE")

mc.insert(collection_name="handbook", data=[
    {"id": i, "vector": v, "text": c["text"], "title": c["title"]}
    for i, (v, c) in enumerate(zip(embed([c["text"] for c in chunks]), chunks))
])

res = mc.search(collection_name="handbook",
                data=embed(["休假规定"]), limit=2,
                filter='title == "休假制度"',              # 标量过滤(下一章主角)
                output_fields=["text", "title"])
for hit in res[0]:
    print(hit["distance"], hit["entity"]["text"])

概念对齐(Milvus 黑话 → 人话):Collection =表;Schema =字段定义(生产上会显式定义而非用上面的快捷模式);Index =为向量字段建的 HNSW/IVF 索引;标量过滤=按普通字段筛。面试聊到 Milvus,能说出"生产上我们会显式定义 Schema、为向量字段建 HNSW 索引、用分区(Partition)做多租户隔离",就是用过的样子。

相关推荐
YOLO数据集集合12 分钟前
无人机视角交通目标检测数据集 | 无人机感知 交通检测 城市监控 目标检测 YOLO格式 深度学习数据集 9016期
人工智能·深度学习·yolo·目标检测·计算机视觉·无人机·交通数据集
2601_9574188012 分钟前
相机连续拍摄时,照片为什么会乱?从传输队列到相机即拍即传 SDK 的设计
人工智能·数码相机
谢白羽15 分钟前
SGLang模型加载过程笔记
笔记·llm·论文·agent·vllm·大模型部署·sglang
蓝悦无人机17 分钟前
端侧神经网络量化(一):为什么要量化 & 量化基础
人工智能·深度学习·神经网络·边缘计算
2401_8322981018 分钟前
绿色AI:算力优化与低碳发展的双向共生之路
人工智能
Wang's Blog19 分钟前
Vibe Coding一人即团队系列19:Figma原型导出与Claude Code优化流程实践
人工智能·figma·stitch
Amir_zy20 分钟前
AI 测试实战:从功能到安全,三层质量验证体系详解
人工智能·安全
绿算技术22 分钟前
大模型本地化的经济账:DeepSeek V4 Flash 部署实测
人工智能·科技·算法·spark
阿尔法工场研究院24 分钟前
小米向下扎根,中国科技向上
人工智能·科技