欢迎光临
我们一直在努力

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

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

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

# 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(重叠):

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 策略二:递归切分(尊重自然边界)

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

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),按章节切天然语义完整,还白送元数据(标题):

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 五分钟上手(学习与原型首选)

# 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 增删改与"重建库"

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 十行体验(理解"索引库"是什么)

# 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 装完一个文件就是库:

# 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)做多租户隔离",就是用过的样子。

赞(0)
未经允许不得转载:171主机测评 » RAG:知识库问答系统教学②-零基础保姆级教程
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址