前两篇解决了"怎么调模型"和"怎么编排流程",但模型有个绕不开的局限:它不知道你的私有文档,知识也停留在训练截止的那一天。RAG(检索增强生成)就是为此而生。这一篇讲 RAG 的前半场——索引:如何把一堆文档变成一个可以按语义检索的知识库;检索与问答是下一篇的主题。

一、为什么需要 RAG

直接问模型公司内部的规章制度、上周刚发布的产品文档,它要么答不上来,要么一本正经地编(幻觉)。根源有三个:

  1. 知识滞后:模型的知识冻结在训练截止日;
  2. 幻觉:对不知道的东西,模型倾向于编造而不是承认不知道;
  3. 微调太贵:为了让模型"记住"你的文档去做续训,成本高、更新慢,文档一变又要重训。

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)

注意 textmetadata 也一并存了进去:检索命中后要还原出原文喂给模型,元数据则用于溯源(回答里标注"出自哪个文档哪一段")。

六、小结与下一步

索引流程四步,每步一个关键决策:

  • 加载:格式对应加载器,复杂 PDF 先用专门服务解析成 Markdown 再进流水线;
  • 切分:首选 RecursiveCharacterTextSplitter,中文补充中文标点分隔符,chunk_overlap 防止边界信息丢失;
  • 向量化:稠密向量管语义、稀疏向量管关键词,BGE-M3 一次编码两者兼得;
  • 存储:Milvus 集合同时建 HNSW(稠密)和稀疏倒排两种索引,原文和元数据一起入库。

到这里,文档已经变成了一个"可以按语义提问"的知识库。但入库只是前半场——下一篇讲检索侧:如何做稠密+稀疏的混合检索、用 RRF 融合多路结果、再用 reranker 精排,最后把检索结果接回 LangChain 的链,完成一个能引用你自己文档回答问题的完整 RAG 应用。

老规矩,以官方文档为准,把每段代码亲手跑一遍。