RAG 实战 (上):构建向量知识库

文章目录

  • [1. 数据加载 (Loading)](#1. 数据加载 (Loading))
    • [1.1 常用文档加载器](#1.1 常用文档加载器)
    • [1.2 批量加载目录 (DirectoryLoader)](#1.2 批量加载目录 (DirectoryLoader))
    • [1.3 加载网页 (WebBaseLoader)](#1.3 加载网页 (WebBaseLoader))
  • [2. 文本分割 (Splitting) - **RAG 的核心艺术**](#2. 文本分割 (Splitting) - RAG 的核心艺术)
    • [2.1 核心参数详解:Chunk Size 与 Overlap](#2.1 核心参数详解:Chunk Size 与 Overlap)
    • [2.2 递归字符分割器 (RecursiveCharacterTextSplitter) - **首选推荐**](#2.2 递归字符分割器 (RecursiveCharacterTextSplitter) - 首选推荐)
    • [2.3 基于 Token 的分割 (TokenTextSplitter)](#2.3 基于 Token 的分割 (TokenTextSplitter))
    • [2.4 多模态数据处理 (进阶思考)](#2.4 多模态数据处理 (进阶思考))
  • [3. 向量存储 (Vector Store)](#3. 向量存储 (Vector Store))
    • [3.1 Embedding 模型选择 - **决定检索上限的关键**](#3.1 Embedding 模型选择 - 决定检索上限的关键)
    • [3.2 ChromaDB 实战 (本地轻量级向量库)](#3.2 ChromaDB 实战 (本地轻量级向量库))
    • [3.3 相似度检索 (Similarity Search)](#3.3 相似度检索 (Similarity Search))
  • [4. 检索器接口 (Retriever)与优化](#4. 检索器接口 (Retriever)与优化)
    • [4.1 MMR (Maximal Marginal Relevance)](#4.1 MMR (Maximal Marginal Relevance))
    • [4.2 相似度阈值过滤 (Similarity Score Threshold)](#4.2 相似度阈值过滤 (Similarity Score Threshold))
  • [5. 进阶检索策略 - **解决上下文丢失 (Parent Document Retriever)**](#5. 进阶检索策略 - 解决上下文丢失 (Parent Document Retriever))

核心痛点:大模型没有"私有数据"。如何让它读取我的 PDF、Markdown 文档甚至网页?本篇将带你走通 RAG 的前 80% 流程:ETL(加载、切分、嵌入、存储)。

学习目标

  1. 掌握 PyPDFLoader, WebBaseLoader 等常用加载器。
  2. 理解 RecursiveCharacterTextSplitter 的切分原理与 Token 长度控制。
  3. 熟练使用 Chroma 进行向量存储与持久化,避免重复计算。
  4. 了解 MMR 与相似度阈值检索,提升检索质量。

1. 数据加载 (Loading)

RAG (Retrieval-Augmented Generation) 的第一步是将数据加载进系统。LangChain 提供了 100+ 种 Loader,覆盖了你能想到的绝大多数数据源。

1.1 常用文档加载器

加载 PDF

python 复制代码
from langchain_community.document_loaders import PyPDFLoader

# 自动处理分页,每一页对应一个 Document 对象
loader = PyPDFLoader("./Docs/langchain_paper.pdf")
pages = loader.load()

print(f"Loaded {len(pages)} pages")
print(f"Source: {pages[0].metadata['source']}") # 元数据包含来源路径
print(pages[0].page_content[:100])

加载 Markdown/文本

python 复制代码
from langchain_community.document_loaders import TextLoader

# 加载 Markdown、TXT 等纯文本
loader = TextLoader("./Docs/readme.md", encoding="utf-8")
docs = loader.load()

1.2 批量加载目录 (DirectoryLoader)

实际应用中,我们通常需要加载整个文件夹。

python 复制代码
from langchain_community.document_loaders import DirectoryLoader

# glob参数支持通配符,例如 "**/*.md" 加载所有子目录的 md 文件
loader = DirectoryLoader("./Docs", glob="*.pdf", loader_cls=PyPDFLoader)
docs = loader.load()
print(f"Loaded {len(docs)} documents from directory")

1.3 加载网页 (WebBaseLoader)

直接抓取网页内容进行问答是常见需求。

python 复制代码
from langchain_community.document_loaders import WebBaseLoader

# 抓取 LangChain 官方文档作为知识库
loader = WebBaseLoader("https://python.langchain.com/docs/get_started/introduction")
docs = loader.load()

# Tips: 网页包含大量 HTML 标签和换行符,后续切分前建议简单清洗
content = docs[0].page_content
cleaned_content = " ".join(content.split()) # 简单去除多余空白
docs[0].page_content = cleaned_content

2. 文本分割 (Splitting) - RAG 的核心艺术

为了不让 Token 超限,并提升检索精度,我们需要将长文档切分成小块 (Chunks)。这不仅仅是简单的字符串切割,而是 RAG 效果调优的核心超参数

2.1 核心参数详解:Chunk Size 与 Overlap

在面试中,常被问到:"你的 chunk_size 设了多少?为什么?"

  • chunk_size (粒度)
    • 太小 (e.g., 100):虽能精准定位关键词,但缺乏上下文,导致 LLM 看了片段也不知道在讲什么(如:"它非常好用"------"它"是谁?)。
    • 太大 (e.g., 2000):包含大量无关噪音,检索不准,且容易撑爆 Prompt 窗口,浪费 Token 费用。
    • 经验值 :通常在 500 - 1000 字符之间。如果是回答细腻的逻辑问题,偏大一点;如果是查阅具体事实(如工号、时间),偏小一点。
  • chunk_overlap (重叠)
    • 作用:防止核心语义在切分点被"腰斩"。例如一句话"LangChain 的核心优势是组件化架构"若正好在"优势是"后面切开,两块 chunk 都会丢失关键信息。
    • 经验值 :通常设置为 chunk_size 的 10% - 20% (如 50-200 字符)。

2.2 递归字符分割器 (RecursiveCharacterTextSplitter) - 首选推荐

相比简单的 CharacterTextSplitter(只按单一字符如换行切分),递归分割器会按顺序尝试 ["\n\n", "\n", " ", ""] 进行切分。它会尽量保持段落、句子的完整性,是处理自然语言的最佳选择。

python 复制代码
from langchain_text_splitters import RecursiveCharacterTextSplitter

text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,     # 目标块大小
    chunk_overlap=50,   # 上下文重叠
    length_function=len, # 长度计量函数 (默认按字符数,也可以换成 tiktoken 计算 token 数)
    add_start_index=True, # 记录切分位置到元数据,方便回溯
)

chunks = text_splitter.split_documents(docs)
print(f"Split into {len(chunks)} chunks")

2.3 基于 Token 的分割 (TokenTextSplitter)

LLM 的上下文窗口是按 Token 限制的,而非字符。中英文的 Token 密度不同(中文通常 1 字符 ≈ 0.6-0.8 Token,英文 1 单词 ≈ 1.3 Token)。为了精准控制 Context Window,使用 Token 分割更严谨。

python 复制代码
from langchain_text_splitters import TokenTextSplitter

# 需要安装 tiktoken: pip install tiktoken
# 严格保证每块不超过 500 tokens
splitter = TokenTextSplitter(chunk_size=500, chunk_overlap=50)
chunks_token = splitter.split_documents(docs)

2.4 多模态数据处理 (进阶思考)

如果文档里包含图片、表格怎么办?简单 OCR 转文字往往会丢失结构信息。

  • 表格:建议单独提取,转为 Markdown 格式或 JSON 格式存储,避免被从中间切断。

  • 图片

    1. 使用多模态大模型 (GPT-4o) 生成图片摘要 (Image Captioning),存为文本向量。
    2. 或者直接使用多模态 Embedding 模型(如 CLIP)将图片映射到向量空间。
    • LangChain 的 UnstructuredLoader 配合 MultiVectorRetriever 可以较好地处理这些复杂场景。

3. 向量存储 (Vector Store)

3.1 Embedding 模型选择 - 决定检索上限的关键

Embedding 是将文本转换为向量的过程。很多同学只知道用 OpenAI,但在面试中,面试官常问:"除了 OpenAI 你还用过什么?为什么选它?"

  1. MTEB 榜单 (Massive Text Embedding Benchmark) :选择模型不要凭感觉,要看榜单。目前中文效果较好的有 BGE-M3, bge-large-zh, gte-large 等。
  2. API vs 本地部署
    • API (OpenAI/Zhipu):简单,无需显卡,且模型维度通常较大(效果好),但会有隐私顾虑和成本。
    • 本地 (HuggingFace)数据不出内网,无需 API 费,但需要计算资源。

代码实战:分别调用 OpenAI 和 本地 BGE 模型

python 复制代码
# 方案 A: 使用 OpenAI (最强通用)
from langchain_openai import OpenAIEmbeddings
embeddings_openai = OpenAIEmbeddings(model="text-embedding-3-small")

# 方案 B: 使用 HuggingFace 本地模型 (如 BAAI/bge-small-zh-v1.5)
# 需安装: pip install sentence_transformers
from langchain_community.embeddings import HuggingFaceEmbeddings

# model_name 可以是 HuggingFace 上的 ID,也可以是本地下载好的路径
embeddings_local = HuggingFaceEmbeddings(
    model_name="BAAI/bge-small-zh-v1.5",
    model_kwargs={'device': 'cpu'}, # 有显卡填 'cuda'
    encode_kwargs={'normalize_embeddings': True} # 归一化,利于计算余弦相似度
)

3.2 ChromaDB 实战 (本地轻量级向量库)

对于初学者和中小型项目,Chroma 是首选。它无需 Docker 部署,直接作为一个 Python 库运行,且支持数据持久化。
(注:生产环境海量数据通常选用 MilvusElasticsearch,但开发阶段 Chroma 足矣)

1. 初始化并存储 (构建索引)

python 复制代码
from langchain_chroma import Chroma

# 这一步会调用 Embedding API,可能产生费用
db = Chroma.from_documents(
    documents=chunks,
    embedding=embeddings,
    persist_directory="./chroma_db" # 指定本地保存路径
)
print("向量库构建完成并保存。")

2. 加载已存在的库 (避免重复 Embedding)

在实际服务重启后,我们需要加载之前的索引,而不是重新计算。

python 复制代码
db = Chroma(
    persist_directory="./chroma_db", 
    embedding_function=embeddings # 必须传入相同的 embedding 函数
)
print("向量库加载成功。")

3.3 相似度检索 (Similarity Search)

有了向量库,我们来看看检索效果。

python 复制代码
query = "LangChain 如何进行数据加载?"

# 默认使用余弦相似度 (Cosine Similarity)
docs = db.similarity_search(query, k=3) 
print(f"最相关的文档内容: {docs[0].page_content}")

4. 检索器接口 (Retriever)与优化

向量库本身只是存储层,LangChain 提供了 Retriever 接口,并在其上实现了多种高级检索策略

4.1 MMR (Maximal Marginal Relevance)

单纯的相似度搜索可能会返回 3 个内容几乎一样的文档块(冗余)。MMR 算法在"相关性"和"多样性"之间寻找平衡,确保检索结果既相关又包含不同的信息点。

python 复制代码
retriever = db.as_retriever(
    search_type="mmr",
    search_kwargs={
        'k': 5,         # 最终返回 5 个文档
        'fetch_k': 20,  # 先从库里拿 20 个候选
        'lambda_mult': 0.5 # 多样性因子,0=完全多样,1=完全相关
    }
)

4.2 相似度阈值过滤 (Similarity Score Threshold)

为了避免模型回答这些"我在知识库里找不到"的问题时产生幻觉,我们可以设置一个阈值。如果检索出的文档相似度过低,直接丢弃。

python 复制代码
retriever = db.as_retriever(
    search_type="similarity_score_threshold",
    search_kwargs={
        'score_threshold': 0.7, # 只返回相似度 > 0.7 的文档
        'k': 5
    }
)

5. 进阶检索策略 - 解决上下文丢失 (Parent Document Retriever)

除了 MMR 和阈值过滤,面试中还有一个经典问题:"切分太细导致语义丢失,切分太粗包含噪音,怎么平衡?"

如果你只用了 RecursiveCharacterTextSplitter,可以是一个 60 分的答案。但如果你能提到 Parent Document Retriever (父文档检索器),那就是满分。

核心思想

小块检索,大块生成

  1. 索引时:将文档切分为非常小的块 (Child Chunks),便于精确定位问题答案所在。
  2. 生成时:并不直接把这个小块给 LLM,而是找到它所属的"父文档块" (Parent Chunk),把更大的上下文给 LLM。

代码实现 (简易版)

LangChain 提供了封装好的 ParentDocumentRetriever,它需要在向量库之外再挂一个 InMemoryStore 来存大文档。

python 复制代码
from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import InMemoryStore
from langchain_chroma import Chroma
from langchain_text_splitters import RecursiveCharacterTextSplitter

# 1. 定义两个层级的切分器
# 父切分器:切大块 (用于 LLM 阅读完整上下文)
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=2000)
# 子切分器:切小块 (用于向量检索精准定位)
child_splitter = RecursiveCharacterTextSplitter(chunk_size=400)

# 2. 存储层
vectorstore = Chroma(collection_name="split_parents", embedding_function=embeddings)
docstore = InMemoryStore() # 实际项目中可以用 Redis

# 3. 初始化检索器
retriever = ParentDocumentRetriever(
    vectorstore=vectorstore,
    docstore=docstore,
    child_splitter=child_splitter,
    parent_splitter=parent_splitter,
)

# 4. 存入文档 (内部会自动切分父子块并建立关联)
retriever.add_documents(docs)

# 5. 检索
# 这一步虽然是基于小块(child)做相似度搜索,但返回的是对应的大块(parent)
result = retriever.invoke("LangChain 加载器有哪些?")
print(len(result[0].page_content)) #你会发现返回的内容很长,上下文很全

至此,我们的知识库已经准备完毕。下一篇我们将把它接入 LLM,构建真正的问答系统。

相关推荐
JaydenAI3 小时前
[拆解LangChain执行引擎]支持自然语言查询的长期存储
python·langchain
DevilSeagull3 小时前
LangChain 生态包全解析:你真正需要安装什么?
langchain
minhuan4 小时前
大模型应用:遗传算法 (GA)+大模型:自动化进化最优Prompt与模型参数.95
prompt·大模型应用·遗传算法 ga·prompt自动调优
ZWZhangYu5 小时前
【LangChain专栏】LangChain模块中Chains 链的使用
人工智能·langchain
Main. 245 小时前
LangChain - AI应用开发利器(一)
langchain
不会敲代码16 小时前
从零开始掌握LangChain工具调用:让AI拥有“动手能力”
前端·langchain
Emotional。14 小时前
2025 年度技术总结与规划:AI 时代的开发者成长之路
人工智能·python·ai·langchain
重生之我要成为代码大佬18 小时前
AI框架设计与选型
人工智能·langchain·大模型·llama·qwen