前两篇解决了"怎么调模型"和"怎么编排流程",但模型有个绕不开的局限:它不知道你的私有文档,知识也停留在训练截止的那一天。RAG(检索增强生成)就是为此而生。这一篇讲 RAG 的前半场——索引:如何把一堆文档变成一个可以按语义检索的知识库;检索与问答是下一篇的主题。
一、为什么需要 RAG
直接问模型公司内部的规章制度、上周刚发布的产品文档,它要么答不上来,要么一本正经地编(幻觉)。根源有三个:
- 知识滞后:模型的知识冻结在训练截止日;
- 幻觉:对不知道的东西,模型倾向于编造而不是承认不知道;
- 微调太贵:为了让模型"记住"你的文档去做续训,成本高、更新慢,文档一变又要重训。
RAG 的思路简单直接:不改模型,改输入。把你的文档做成一个可检索的知识库,用户提问时先从库里检索出最相关的几段内容,连同问题一起塞进提示词,让模型"看着资料回答"。
整个体系分成两条流程:
- 索引流程(离线,本篇):文档加载 → 切分成块 → 向量化 → 存入向量数据库;
- 检索流程(在线,下篇):把问题向量化 → 与库中的块做相似度比对 → 选出 Top-K → 交给模型生成答案。
下面沿着索引流程的四步走一遍。
二、第一步:文档加载
LangChain 用统一的 Document 对象承载文档内容——page_content 是正文,metadata 是来源、位置等元数据。各种格式的文件通过对应的文档加载器(Document Loader)转成 Document。
结构化程度高的文本(txt、md)加载很直接:
from langchain_community.document_loaders import TextLoader
docs = TextLoader(file_path="sample.md", encoding="utf-8").load()
print(docs[0].page_content[:100])
对 Markdown、Word 这类有结构的文档,Unstructured 系列加载器提供两种模式:
from langchain_community.document_loaders import UnstructuredMarkdownLoader
# single 模式:整个文件合成一个 Document
docs = UnstructuredMarkdownLoader(file_path="sample.md", mode="single").load()
# elements 模式:按标题、段落等结构元素拆成多个 Document
docs = UnstructuredMarkdownLoader(file_path="sample.md", mode="elements").load()
Word 文档换成 UnstructuredWordDocumentLoader,用法完全一样。至于 PDF——简单版式的用 pypdf 系加载器就够了;但扫描件、多栏排版、带表格公式的复杂 PDF,业界通行做法是先交给专门的解析服务(如 MinerU、Marker 这类工具)转成 Markdown,再按普通文本加载。别在"用通用加载器硬啃复杂 PDF"上浪费时间,解析质量决定了整个知识库的上限。
三、第二步:文档切分
为什么不能把整篇文档直接向量化入库?四个理由:模型上下文窗口有限,超长文本塞不进去;整篇文档噪声太多,检索不精确还容易诱发幻觉;长文本会稀释关键信息,短文本的语义表达更聚焦;向量化和推理的成本都随长度上涨。
但切分也不是越碎越好,核心考量有三条:
- 长度适中:太大噪声多,太小语义残缺;
- 语义完整:不能把句子拦腰砍断;
- 边界自然:尽量在段落、句子这些天然分界处下刀。
常见策略里,按固定字符数切最简单但会截断语义,按语义切效果好但又慢又不均匀。工程上的主流选择是递归字符切分——兼顾速度、语义完整和大小均匀:
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
separators=["\n\n", "\n", "。", "!", "?", ",", ""], # 分隔符按优先级排列
chunk_size=400, # 每块最大长度
chunk_overlap=50, # 相邻块之间的重叠
add_start_index=True, # 在 metadata 里记录块在原文中的起始位置
)
chunks = splitter.split_documents(docs)
for chunk in chunks[:3]:
print(chunk.page_content)
print("-" * 40)
"递归"的含义是:先尝试用最高优先级的分隔符(段落 \n\n)切,切出来的块还超长,就降级用下一个分隔符(换行、句号……)继续切,直到满足 chunk_size。这样能保证优先在自然边界断开。中文文档记得像上面这样把中文标点加进 separators,默认分隔符是面向英文的。
chunk_overlap 让相邻块共享一小段内容,避免关键信息恰好被切在边界上导致两边都检索不到——一般设为 chunk_size 的 10% 左右。
四、第三步:向量化(Embedding)
4.1 把语义变成数字
向量化(嵌入)就是用嵌入模型把一段文本映射成一个高维数字向量,语义相近的文本,向量在空间中的距离也近。这是"按语义检索"的全部基础:比较两段话像不像,变成了算两个向量的距离。
这背后的技术源自 BERT 的一次关键改造:原生 BERT 能编码文本,但生成的向量不适合直接拿来比相似度;Sentence-BERT 改造了训练方式,专门产出"可对比"的句向量,才让大规模语义检索变得可行。今天你能用到的嵌入模型基本都是这条路线的后代。
选型上两条主流路线:开源自部署(如 BAAI 的 BGE 系列,中文效果好、本地跑不花 API 钱)和闭源 API(如 OpenAI 的 text-embedding 系列,免运维)。除了效果,还要看向量维度、最大序列长度(一次能编码多长的文本)、是否支持多语言和算力成本。
用 LangChain 调用本地嵌入模型(推荐从 langchain-huggingface 包导入,langchain_community 里的同名类是旧位置):
pip install -U langchain-huggingface
from langchain_huggingface import HuggingFaceEmbeddings
embed_model = HuggingFaceEmbeddings(model_name="BAAI/bge-base-zh-v1.5")
# 单条文本 → 一个向量
vector = embed_model.embed_query("床前明月光")
print(len(vector)) # 768,向量维度由模型决定
# 批量文本 → 向量列表
vectors = embed_model.embed_documents(["床前明月光", "疑是地上霜"])
4.2 稠密向量与稀疏向量
上面生成的是稠密向量:几百上千个维度几乎都非零,整体编码"这段话是什么意思",擅长比较语义相似度。但它有个软肋:对"必须精确命中某个关键词"的查询(型号、人名、专业术语)不够敏感。
这时需要稀疏向量来补位:维度对应整个词表(几万维),只有文本中实际出现的词对应的位置非零,值是这个词的权重。比如:
# 词表 id → 词:331→人工智能,892→技术,1203→transformer
sparse_vector = {
331: 1.8, # "人工智能"出现多次且整体较罕见,权重高
892: 0.6, # "技术"太常见,权重低
1203: 2.3, # "transformer"非常罕见,权重最高
}
它本质上是在给关键词的重要性打分,擅长精确匹配。产生权重的方式有两种:BM25 直接按词频/逆文档频率的统计公式算分,不经过神经网络;BGE-M3 这类模型则通过学习产出词权重,理解力更强。实践中常用 BGE-M3——它一次编码同时产出稠密和稀疏两种向量:
from FlagEmbedding import BGEM3FlagModel
model = BGEM3FlagModel(model_name_or_path="BAAI/bge-m3")
result = model.encode(
sentences=["LangChain 是一个大模型应用开发框架"],
return_dense=True, # 稠密向量:管语义
return_sparse=True, # 稀疏向量:管关键词
)
dense = result["dense_vecs"] # 1024 维稠密向量
sparse = result["lexical_weights"] # {词表id: 权重} 字典
两种向量各管一路,检索时"语义召回 + 关键词召回"双路并发再融合,就是下一篇会讲的混合检索——所以入库时把两种向量都存上。
五、第四步:存入向量数据库
传统数据库回答"是不是"(WHERE name = 'xx',精确匹配),向量数据库回答"像不像"(谁和这个向量最近,相似度排序)——这是两种完全不同的查询范式,所以需要专门的存储引擎。这里以开源的 Milvus 为例,它同时支持稠密向量、稀疏向量和标量字段。
5.1 建集合:定义字段和索引
集合(Collection)相当于关系库里的表。为混合检索准备的典型 schema 是五个字段:主键、稠密向量、稀疏向量、原始文本、元数据:
from pymilvus import MilvusClient, DataType
client = MilvusClient(uri="http://localhost:19530")
# 字段定义
schema = MilvusClient.create_schema(auto_id=True) \
.add_field(field_name="id", datatype=DataType.INT64, is_primary=True) \
.add_field(field_name="vector", datatype=DataType.FLOAT_VECTOR, dim=1024) \
.add_field(field_name="sparse_vector", datatype=DataType.SPARSE_FLOAT_VECTOR) \
.add_field(field_name="text", datatype=DataType.VARCHAR, max_length=1000) \
.add_field(field_name="metadata", datatype=DataType.JSON)
# 索引定义:稠密用 HNSW,稀疏用倒排索引
index_params = MilvusClient.prepare_index_params()
index_params.add_index(field_name="vector", index_type="HNSW", metric_type="L2")
index_params.add_index(field_name="sparse_vector",
index_type="SPARSE_INVERTED_INDEX", metric_type="IP")
client.create_collection(
collection_name="knowledge_base",
schema=schema,
index_params=index_params,
)
两种索引简单说两句:
- HNSW(分层导航小世界)是稠密向量的主流索引,用多层图结构做近似最近邻查找,牺牲一点精度换来极快的检索速度;
- 稀疏倒排索引和搜索引擎同源:为每个词维护一张"哪些文档包含它"的清单,查询时先快速筛出包含查询词的候选文档,再用内积(IP)算相似度得分。比如查询"苹果手机",倒排表能瞬间定位到同时含"苹果"和"手机"的文档,两个词的权重乘积相加即为得分。
5.2 入库:完整流水线
最后把前面四步串起来——加载、切分、双向量编码、组装入库:
from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from FlagEmbedding import BGEM3FlagModel
from pymilvus import MilvusClient
# 1. 加载 + 切分
docs = TextLoader(file_path="sample.md", encoding="utf-8").load()
chunks = RecursiveCharacterTextSplitter(
separators=["\n\n", "\n", "。", "!", "?", ",", ""],
chunk_size=400,
chunk_overlap=20,
).split_documents(docs)
# 2. 一次编码,同时得到稠密和稀疏向量
model = BGEM3FlagModel(model_name_or_path="BAAI/bge-m3")
vectors = model.encode(
sentences=[chunk.page_content for chunk in chunks],
return_dense=True,
return_sparse=True,
)
# 3. 组装成记录列表,批量插入
data = [
{
"text": chunk.page_content,
"vector": dense,
"sparse_vector": sparse,
"metadata": chunk.metadata,
}
for chunk, dense, sparse in zip(
chunks, vectors["dense_vecs"], vectors["lexical_weights"]
)
]
client = MilvusClient(uri="http://localhost:19530")
client.insert(collection_name="knowledge_base", data=data)
注意 text 和 metadata 也一并存了进去:检索命中后要还原出原文喂给模型,元数据则用于溯源(回答里标注"出自哪个文档哪一段")。
六、小结与下一步
索引流程四步,每步一个关键决策:
- 加载:格式对应加载器,复杂 PDF 先用专门服务解析成 Markdown 再进流水线;
- 切分:首选
RecursiveCharacterTextSplitter,中文补充中文标点分隔符,chunk_overlap防止边界信息丢失; - 向量化:稠密向量管语义、稀疏向量管关键词,BGE-M3 一次编码两者兼得;
- 存储:Milvus 集合同时建 HNSW(稠密)和稀疏倒排两种索引,原文和元数据一起入库。
到这里,文档已经变成了一个"可以按语义提问"的知识库。但入库只是前半场——下一篇讲检索侧:如何做稠密+稀疏的混合检索、用 RRF 融合多路结果、再用 reranker 精排,最后把检索结果接回 LangChain 的链,完成一个能引用你自己文档回答问题的完整 RAG 应用。
老规矩,以官方文档为准,把每段代码亲手跑一遍。






还没有公开留言,成为第一个写下回声的人。