1 RAG介绍
1.1 大模型的局限
1.1.1 知识滞后
LLM 因其具有海量参数,需要花费相当的物力与时间成本进行预训练和微调,同时商用 LLM 还需要进行各种安全测试与风险评估等。因此 LLM 会存在知识滞后的问题。
1.1.2 知识缺失
在专有领域,LLM 无法学习到所有的专业知识细节,因此在面向专业领域知识的提问时,无法给出可靠准确的回答。
1.1.3 幻觉
LLM 在生成回答时,可能会"胡言乱语",这种现象称之为 LLM 的"幻觉"。
"幻觉"可以体现为错误陈述、编造事实、错误的复杂推理或者复杂语境下理解能力不足等。
"幻觉"产生的原因:
训练知识存在偏差,这些错误信息被 LLM 学习后在输出中复现
- LLM 训练时过度泛化,将普通的模式应用在特定场合导致不准确输出
- LLM 本身没有真正学习到训练数据中深层次的含义,导致在一些需要深入理解或复杂推理的任务中出错
- LLM 缺乏某些领域的相关知识,在面临这些领域的相关问题时编造不存在的信息
- 大模型生成内容的不可控,尤其是在金融和医疗领域等领域,一次金额评估的错误,一次医疗诊断的失误,哪怕只出现一次都是致命的。但这些错误对于非专业人士来说难以辨识。目前还没有能够百分之百解决这种情况的方案。
知识过时:模型的训练数据有截止日期,无法知晓之后发生的事情。
存在"幻觉":当模型不知道答案时,可能会一本正经地编造看似合理但错误的内容。
缺乏私有知识:模型不了解你公司内部的文档、数据库或专有信息。
RAG 的核心思路就是"不让模型硬背,而是让它学会查"。它把模型和一个外部知识库连接起来,模型在回答时,能实时从中检索相关信息作为依据。
1.2 什么是 RAG
RAG 是 检索增强生成(Retrieval-Augmented Generation) 的缩写。简单来说,它是一种让大语言模型在回答问题前,先去外部知识库"查资料"的技术,目的是让回答更准确、更新、更可信,而不是仅依赖模型训练时"记住"的旧知识。
-- 为了改善大模型在时效性、可靠性与准确性方面的不足,各种针对 LLM 优化的方法应运而生。RAG(Retrieval-Augmented Generation,检索增强生成)就是其中一种被广泛研究和应用的优化架构。
-- RAG 的基本思想为:将传统的生成式大模型和实时信息检索技术相结合,为大模型补充来自外部的相关数据和上下文,来帮助大模型生成更加准确可靠的内容。这使得大模型在生成内容时可以依赖实时与个性化的数据和知识,而非仅仅依赖训练知识。就相当于在大模型回答时给它一本参考书。
-- 可以说,当应用需求集中在利用大模型去回答特定私有领域的知识,且知识库足够大时,那么除了微调大模型外,RAG 就是非常有效的一种解决方案。LangChain 对这一流程提供了解决方案。
RAG 是如何工作的?
一个典型的 RAG 流程主要分两步:准备知识库 和实时问答。
1. 准备阶段(建立索引)
首先,系统会把你的文档(如 PDF、网页、数据库记录)切分成小块,然后用"嵌入模型"将这些文本块转换成计算机能理解的向量 (一串数字),并存入专门的向量数据库。这一步相当于为你的资料建立了一个可按语义搜索的智能目录。
2. 问答阶段(检索与生成)
当用户提问时,系统会:
- 检索:把用户的问题也转换成向量,去向量数据库中快速找出含义最相近的文本块。
- 增强:将找出的相关文本块(即"上下文")和用户的原始问题,一起打包成一个更丰富的提示词。
- 生成:把增强后的提示词发给大语言模型,模型基于这些真实的参考资料来组织答案。
可以把 RAG 理解成给 AI 配了一个"随身图书馆"和"搜索引擎",让它能在需要时查阅真实资料,再基于资料来回答你。
1.3 RAG 优缺点
1.3.1 RAG的优点
RAG 的主要价值在于:成本低 (无需重新训练模型)、知识可即时更新 (改知识库即可)、答案可溯源 (能提供引用来源),并且能有效减少模型胡编乱造的情况。
- 相比提示词工程,RAG 有更丰富的上下文和数据样本,可以不需要用户提供过多的背景描述,就能生成比较符合用户预期的答案。
- 相比于模型微调,RAG 可以提升问答内容的时效性和可靠性。
- 在一定程度上保护了业务数据的隐私性。
1.3.2 RAG的缺点
- 由于每次问答都涉及外部系统数据检索,因此 RAG 的响应时延相对较高。
- 引用的外部知识数据会消耗大量的模型 Token 资源。
1.4 RAG流程
1.4.1 典型的RAG有两个主要流程:
(1)索引:从数据源提取数据,构建索引。
(2)检索生成:接受用户查询并从索引中检索相关数据,然后将其传递给模型。。
1.4.2 索引阶段整体流程如下:
(1)从各种数据源加载数据;
(2)将文档切分为小块;
(3)对文本块进行嵌入;
(4)存储嵌入向量。
1.4.3 检索生成阶段:
(1)根据用户输入,使用检索器从存储中检索相关文本块;
(2)大模型使用包含问题和检索结果的提示生成回答。
2 文档加载
-- 数据源可能包含多种格式的文件,如文本文档、Markdown,PDF 等。因此我们首先需要对各种格式的文件进行处理。LangChain 实现和集成了众多文档加载器,方便从不同格式的文件中加载数据。可在 https://docs.langchain.com/oss/python/integrations/document_loaders 查看所有集成的文档加载器。
-- LangChain 所有文档加载器都实现了 BaseLoader 接口,接口提供了通用的 load(一次加载所有文档) 与 lazy_load(以延迟方式加载文档) 方法,用于从数据源加载数据并处理为 Document 对象。

LangChain 实现了 Document 抽象,用于表示文本单元及其元数据,它包含三个属性:
- page_content:文本内容字符串。
- metadata:包含元数据的字典,如文档的来源等。
- id:可选,文档标识符。
2.1 加载 Markdown
MarkDown 形式一种半结构化的数据,其原始文本,通过特定语法,标记出了标题、段落、有序列表、无序列表等相关信息,
如下所示,不同的层级的文本,在markdown当中表示的形式不一致:

可以使用 Unstructured 文档加载器来加载多种类型的文件,关于如何在 LangChain 中使用 unstructured 生态系统,可参考这里。
Unstructured.io对Markdown的解析流程,如下所示:
(1)按照Markdown结构进行切分,标题等会被切分成单独的element
(2)对于同一个标题下的文本,再按照段落进行切分,不同的段落(通过\n标识)会被切分成多个element。
可使用 langchain 集成的 UnstructuredMarkdownLoader 来加载 Markdown 文件,示例代码如下所示:
python
# 安装依赖命令:
# pip install markdown langchain_community unstructured[md]
def markdown_loader_demo():
"""
演示使用 UnstructuredMarkdownLoader 加载 Markdown 文件
mode="elements" 会把 md 文件按标题、段落、列表等拆分成独立文档块
"""
# 导入 Langchain 社区提供的 Markdown 加载器
from langchain_community.document_loaders import UnstructuredMarkdownLoader
# 初始化加载器
# 参数1:md文件路径
# encoding:指定文件编码为utf-8,防止中文乱码
# mode="elements":按markdown元素(标题/段落/列表/表格)拆分,而不是整段文本
loader = UnstructuredMarkdownLoader("./assets/sample.md", encoding="utf-8", mode="elements")
# 加载文档,返回 List[Document] 对象
# 每个 Document 包含 page_content(文本内容)和 metadata(元数据:类型、位置等)
docs = loader.load()
# 遍历打印每一个拆分出来的文档片段
for doc in docs:
# page_content 就是当前元素的文本内容
print(doc.page_content, end="\n============\n")
if __name__ == "__main__":
# 程序入口,执行加载函数
markdown_loader_demo()
2.2 加载 Docx
-- 现代的 Word 文档(.docx 格式)本质上也是一种半结构化(Semi-structured)且机器可读(Machine-readable)的文件。
-- .docx 的本质上是XML 的容器,XML 标签严格规定了文档的层级(比如 <w:p> 代表段落,<w:r> 代表文本运行块)。机器可以利用这些标签精确地提取信息。
但是,由于Word文档对于层级的定义相较于Markdown又更加灵活,我们可以自定义不同层级标题的样式,而这些样式通常只是正文样式,加了手动格式,使得解析库无法按照统一的标准格式将层级进行解析,这个特点给 Word 解析又带来了难点。
-- 同样可以使用 LangChain 封装的 Unstructured.io 的 Loader 对 .docx 进行解析。在使用 Unstructured.io 对 Word 进行解析时,仅会按照换行对文件进行解析,无法识别出文档标题,示例代码如下:
python
# pip install unstructured[docx]
def word_loader_demo():
from langchain_community.document_loaders import UnstructuredWordDocumentLoader
docs = UnstructuredWordDocumentLoader(
# 文件路径
file_path="assets/sample.docx",
# 加载模式:
# single 返回单个Document对象
# elements 按标题等元素切分文档
mode="elements",
).load()
for doc in docs[230:260]: # 从文档中间选取30个文档查看结构
print(doc.page_content)
print(doc.metadata,end="\n============\n")
if __name__ == "__main__":
word_loader_demo()
对于标题层级信息并不敏感的 .docx 文件,可以通过上面的 loader 进行加载。但是,对于标题层级信息敏感的 .docx 文件,上面的方式,会丢失掉标题层级信息,此时可以通过 Mineru 进行处理。
2.3 加载 PDF
-- PDF 存在多种来源格式,包括扫描版(图片 PDF)、电子文本版、混合版。并且布局格式也多种多样,包括单列布局、双列布局甚至竖排文本布局。并且包含段落、标题、页眉页脚、表格、数学公式、化学式、特殊符号、图片等各种元素。
-- 因此,PDF 解析存在很多挑战。对于复杂 PDF,需要进行文本提取、布局检测、表格解析、公式识别等处理。
-- 在此处,我们介绍一个开源的,专门用于解析PDF的工具:Mineru。
-- MinerU是一款将PDF转化为机器可读格式的工具(如markdown、json),可以很方便地抽取为任意格式。
-- Mineru可以配置使用VLM模型进行文档解析。其开源的opendatalab/MinerU2.5-2509-1.2B(https://huggingface.co/opendatalab/MinerU2.5-2509-1.2B)模型,在各项基础测试当中,都达到了SOTA水平。
-- 下图展示了MinerU2-VLM在多项基准测试当中得分排名:

-- MinerU2.5采用两阶段解析策略:首先对下采样图像进行高效的全局布局分析,然后对文本、公式和表格的原生分辨率裁剪图像进行细粒度内容识别。在大规模、多样化的数据引擎支持下进行预训练和微调,MinerU2.5 在多个基准测试中始终优于通用模型和特定领域模型,同时保持较低的计算开销。MinerU 提供了 PDF、Word、PPT、图片等文件的解析,支持图像提取、OCR、公式、表格解析等功能。
-- 另外,Mineru开源所有代码,支持本地通过Docker方式进行部署,其项目仓库链接:https://github.com/opendatalab/MinerU。Mineru官网也提供了直接调用API的方式,上传文件进行解析。首先需要在官网申请API_KEY,并将其放到环境变量当中
-- 调用上传文件接口示例代码如下:
python
def mineru_upload_file_demo():
"""
MinerU API 上传文件示例
流程:
1. 先向MinerU接口申请预签名上传地址(file_urls/batch)
2. 拿到PUT上传链接 batch_id
3. 使用PUT请求把本地PDF文件上传到预签名地址
文档解析后续可以用 batch_id 查询解析结果
官方文档:https://mineru.net/docs
"""
import requests
import os
# 从环境变量读取 MinerU 的token,避免硬编码密钥
token = os.getenv("MINERU_TOKEN")
# MinerU v4 批量申请上传地址接口
url = "https://mineru.net/api/v4/file-urls/batch"
# 请求头
header = {
"Content-Type": "application/json",
"Authorization": f"Bearer {token}"
}
# 请求体:声明要上传的文件信息、使用的解析模型版本
data = {
"files": [
{"name": "demo.pdf", "data_id": "abcd"} # name:文件名;data_id:自定义标识ID
],
"model_version": "vlm" # vlm 模型,支持图文、复杂公式、排版解析
}
# 本地待上传文件路径列表,和上面files一一对应,这里需要自己填充路径
file_path = [r""]
try:
# POST请求:申请预签名PUT上传地址
response = requests.post(url, headers=header, json=data)
# 判断HTTP层面请求成功
if response.status_code == 200:
result = response.json()
print('申请上传地址成功:{}'.format(result))
# MinerU业务码 code=0 代表接口业务成功
if result["code"] == 0:
batch_id = result["data"]["batch_id"] # 批次ID,后续查解析任务需要这个
urls = result["data"]["file_urls"] # 返回的预签名PUT上传链接列表
print('batch_id:{},urls:{}'.format(batch_id, urls))
# 循环,逐个文件PUT上传到预签名地址
for i in range(0, len(urls)):
# 二进制只读打开本地文件
with open(file_path[i], 'rb') as f:
# PUT上传文件流
res_upload = requests.put(urls[i], data=f)
if res_upload.status_code == 200:
print(f"{urls[i]} 上传成功")
else:
print(f"{urls[i]} 上传失败,状态码:{res_upload.status_code}")
return batch_id
else:
# code !=0,MinerU业务报错
print('申请上传地址失败, reason:{}'.format(result["msg"]))
return None
else:
# HTTP状态码非200,网络/接口访问异常
print(f"请求失败,状态码:{response.status_code},响应内容:{response.text}")
except Exception as err:
# 捕获全部异常:网络错误、文件不存在、json解析异常等
print(f"程序异常:{err}")
if __name__ == "__main__":
bid = mineru_upload_file_demo()
print("得到批次id:", bid)
上传完成之后,可以通过获取任务结果API来获取解析结果:
python
def mineru_check_result_demo(batch_id):
"""
MinerU 查询解析结果轮询Demo
功能:根据上传时拿到的 batch_id,循环轮询任务状态
直到任务状态变成 done(解析完成),返回压缩包下载地址
full_zip_url 里面包含解析好的 markdown、图片、json 等结果
"""
import requests
import time
import os # 原代码缺少os导入,补上,否则os.getenv会报错
# 从环境变量读取MinerU密钥
token = os.getenv("MINERU_TOKEN")
# 拼接查询结果接口地址,batch_id是上传文件返回的批次编号
url = f"https://mineru.net/api/v4/extract-results/batch/{batch_id}"
# 请求头:鉴权token + json类型
header = {
"Content-Type": "application/json",
"Authorization": f"Bearer {token}"
}
# 第一次发起GET请求,查询任务状态
res = requests.get(url, headers=header)
# 轮询:只要任务状态不是 done,就持续等待重试
while res.json()["data"]['extract_result'][0]['state'] != 'done':
print('当前状态为running,等待3秒后重试')
time.sleep(3) # 休眠3秒,避免高频请求打满接口
# 再次请求接口刷新状态
res = requests.get(url, headers=header)
print(res.status_code) # 打印http状态码,方便调试
# 打印当前任务状态
print(res.json()["data"]['extract_result'][0]['state'], end="\n\n=========\n\n")
# 退出while循环代表任务解析完成 done
# full_zip_url:结果压缩包链接,下载后可以拿到md、图片、结构化信息
print('提取结果压缩包地址为:', res.json()["data"]['extract_result'][0]['full_zip_url'])
return res.json()["data"]['extract_result'][0]['full_zip_url']
if __name__ == "__main__":
# 示例,替换成你上传拿到的batch_id
# zip_url = mineru_check_result_demo("这里填你的batch_id")
-- Mineru 解析之后,有多种不同的输出格式,具体可参见Mineru官方文档:https://opendatalab.github.io/MinerU/zh/reference/output_files/。
-- 要进行下一步处理,最简单的方式是通过解析之后得到的一个 MarkDown 文件,再利用 MarkDown 解析器,进行进一步的解析即可。
3 文档切分
文档切分是 RAG 流程里决定检索质量的关键一步。它的核心目标,是把长文档切成一个个语义完整、大小适中的片段,让检索时能准确命中,也让大模型拿到干净、聚焦的上下文。
为什么不能"随便切"?
如果切得不好,会直接拉低 RAG 的效果:
- 切得太碎:一个完整的知识点被劈成两半,检索只能拿到半截,答案就会断片。
- 切得太大:一个片段里混进了多个不相关的信息,噪声会淹没真正有用的内容,还浪费模型的上下文窗口。
- 在句子中间硬切:片段的嵌入向量会带上"残缺"的噪声,导致它跟任何问题都匹配不上。
两个关键参数的实操建议
块大小(Chunk Size)
微软的实践指南建议从约 512 个 token 作为起点。但这不是金科玉律。更合理的思路是看文档的"信息密度":FAQ 可以按 QA 对切,合同按条款切,代码按函数切,这些天然边界比固定字数可靠得多。对于结构均匀的连续文本,256--1024 token 是常见的摸索范围。
重叠(Overlap)
重叠是为了防止关键句子恰好被切在边界上而丢失。一个实用的起点是块大小的 10% 到 15%,比如 512 token 的块配 50--75 token 的重叠。但重叠不是越大越好:它会让索引膨胀、嵌入成本上升,还可能在检索结果里塞进大量近似重复的块,反而挤掉了真正多样化的信息。
简单落地建议
如果你的文档格式比较规整(有标题、段落清晰),优先尝试递归切分 或结构化切分,块大小从 512 token 起步,重叠设在 10% 左右。切完之后,用你的实际问题去测一测检索的召回情况,根据效果再微调参数。切分方案没有"标准答案",需要根据你的文档和查询特点来定。
3.1为什么切分
获取 Document 对象后,需要将其切分成 Chunk。之所以要进行切分是出于以下考虑:
(1)后续需要根据提问检索出相关的内容放入 Prompt,如果答案出现在某一个 Document 对象中,那么将检索到的整个 Document 对象直接放入 Prompt 中并不是最优的选择,因为 Document 可能包含非常多无关的信息,这些无效信息会干扰大模型的生成。有研究发现,尽管大模型能够处理长文本输入,但它们在利用长上下文方面存在显著不足。尤其是在多文档问答和键值检索等任务中,当相关信息位于输入文本的中间时,模型的性能显著下降。这种现象表明,当前的语言模型在长输入上下文中未能充分利用信息,尤其是位于中间部分的信息。
(2)大模型存在最大输入的 Token 限制,如果一个 Document 非常大,在输入大模型时会被截断,导致信息缺失。
基于此,一个方法是将完整的 Document 进行分块处理(Chunking),将 Document 切分为一个个小块(Chunk)。无论是在存储还是检索过程中,都将以这些块为基本单位,这样能有效地避免内容噪声干扰和超出最大 Token 的问题。
3.2 切分策略
3.2.1 几种主流切分策略
没有一种方法能通吃所有场景,选择取决于你的文档类型和查询需求。
1. 固定长度切分(最简单,但不推荐作为最终方案)
严格按预设的 token 数或字符数切割,块与块之间保留一定重叠。优点是实现极快,适合作为基线或处理结构完全未知的纯文本。缺点是极易切断句子和语义单元,对结构化文档是灾难性的。
2. 递归切分(通用性较强)
按分隔符的优先级从大到小尝试:先按段落(双换行)切,如果块还太大,再按句子(句号、问号)切,最后才按空格或字符切。这样能在控制块大小的同时,尽可能尊重文本的天然边界。LangChain 的 RecursiveCharacterTextSplitter 就是典型实现,是多数场景的稳妥默认选择。
3. 结构化切分(适合格式规整的文档)
直接利用文档本身的标记来切分,比如 Markdown 的 # 标题、HTML 的标签、代码的函数/类定义。这种方法能完美保留文档的原始逻辑层级,切出来的块质量很高,但前提是文档格式必须规范且可解析。
4. 语义切分(精度高,成本也高)
用嵌入模型计算相邻句子的语义相似度,在语义"断层"的地方切开。这样切出的块内部主题高度一致,理论上最符合人类理解。代价是需要额外的模型计算,且相似度阈值需要反复调试,适合法律、医疗等对检索精度要求极高的场景。
3.2.2 切分思路
具体切分时,可以采用如下策略,具体采用哪种,主要取决于在实际使用场景当中,对于语义连贯性,完整性的要求:
(1)按照固定字符数或 Token 数来切分,但可能会在不适当的位置切断句子。
(2)递归使用多个分隔符切分,同时尽量保证字符数或 Token 数不超出限制。能保证不切断完整的句子。
(3)语义切分:根据文本的语义内容切分,旨在保持相关信息的集中和完整,适用于需要高度语义保持的场景。但处理速度较慢,且可能出现不同块之间长度极不均衡的情况。具体切分过程为:将相邻的几个句子拼成一个句组。对所有句组进行嵌入,并比较嵌入向量的距离,找到语义变化大的位置,根据阈值确定切分点(比如计算相邻句子嵌入向量的余弦距离,取距离分布的第 N 百分位值作为阈值,高于此值则切分)。按照切分点切分出若干个语义段,并合并某些长度很短的语义段。
接下来,后面三种切分策略当中在保持语义连贯性上面的折中方案:按照特定字符进行切分,langchain为我们提供了一个现成的类:RecursiveCharacterTextSplitter.
3.3 RecursiveCharacterTextSplitter
-- RecursiveCharacterTextSplitter(递归字符文本切分器)是最常用的切分器,它由一个字符列表作为参数,默认列表为 "\\n\\n", "\\n", " ", "",并且会尝试按顺序使用这些字符进行切分,直到块足够小。
-- 由此尽可能地将所有段落(然后是句子,最后是词)保持在一起,因为这些段落通常看起来是语义上最相关的文本片段。
-- 同时为了保证段之间语义完整,可以设置每个块之间有一部分重叠。

代码示例 :
python
# pip install langchain-text-splitters
# 导入递归字符分割器(RAG最常用文本切分工具)
from langchain_text_splitters import RecursiveCharacterTextSplitter
# 导入Word文档加载器,用来读取docx文件
from langchain_community.document_loaders import UnstructuredWordDocumentLoader
# ========== 1. 加载docx文档 ==========
# mode="single":把整篇Word文档读取为一整个Document对象
docs = UnstructuredWordDocumentLoader(
file_path="assets/sample.docx",
mode="single"
).load()
# ========== 2. 文档切片(分块,RAG核心步骤) ==========
chunks = RecursiveCharacterTextSplitter(
# 分隔符优先级:从左往右尝试,优先用换行、句号等语义断点切
separators=["\n\n", "\n", "。", "!", "?", "......", ",", ""],
chunk_size=400, # 单个文本块允许的最大字符长度
chunk_overlap=50, # 相邻块重叠字符数,防止语义被切断丢失上下文
length_function=len, # 用内置len统计字符长度;换成token计数函数就可以按token切
add_start_index=True, # 在metadata里增加start_index,标记该片段在原文的起始位置,方便溯源
).split_documents(docs) # 对加载好的文档对象执行切分,返回Document列表
# 打印切分后的所有文本块
print(chunks)
python
# 切分为文本块
chunks = RecursiveCharacterTextSplitter(
separators=["\n\n", "\n", "。", "!", "?", "......", ",", ""], # 分隔符列表
chunk_size=400, # 每个块的最大长度
chunk_overlap=50, # 每个块重叠的长度
length_function=len, # 可选:计算文本长度的函数,默认为字符串长度,可自定义函数来实现按 token 数切分
add_start_index=True, # 可选:块的元数据中添加此块起始索引
).split_documents(docs)
整个切分过程可以通过如下流程图所示:

关键点说明
- RecursiveCharacterTextSplitter 原理
递归尝试分隔符:先用\n\n(空行)切,切出来片段还大于chunk_size,就尝试下一个分隔符\n,依次往下,保证尽量在语义断点分割。 - chunk_overlap 的作用
比如 A 块末尾 50 个字,会复制到下一块 B 的开头,避免一句话、一段逻辑被一刀切断。 - 对比前面的
UnstructuredMarkdownLoader
mode="single":整篇一份文档,适合后面统一切片;mode="elements":按标题 / 段落拆成多个小文档,适合先拆分元素再单独处理。
小坑提醒
- 中文场景,分隔符一定要带上中文标点
。!?,纯英文分隔符切中文效果很差; chunk_size不是硬性截断,是上限,找到合适分隔符就切,不会硬砍句子。
4 文档嵌入
文档嵌入是 RAG 流程中把文本片段(chunk)转换成数值向量的步骤。这些向量捕获了文本的语义含义,让系统能够通过计算向量之间的距离,找到与用户问题真正相关的内容,而不是仅靠关键词匹配。
嵌入在 RAG 中起什么作用?
切分好的文档块本身只是文本,计算机无法直接判断两段话意思是否相近。
嵌入模型将每个文本块映射成一个高维向量(一串数字),语义相近的文本,其向量在空间中的距离也更近。检索时,用户问题同样被转换成向量,系统在向量库中找出距离最近的若干个文档向量,作为回答的依据。因此,嵌入模型的质量直接决定了检索的召回效果。
4.1 嵌入模型介绍
将文档切分成合适的大小之后,就可以使用嵌入模型生成文档的嵌入向量,后续检索时用于与查询的嵌入向量进行相似度计算。

2018年谷歌推出的 BERT 能够将文本嵌入为简单的向量表示,但是 BERT 并未针对有效生成句子嵌入进行优化,由此促使了 Sentence-BERT 的诞生。Sentence-BERT(论文链接:https://arxiv.org/pdf/1908.10084) 调整了 BERT 的架构以及预训练任务以生成包含语义的句子嵌入向量,这些嵌入向量可以通过余弦相似度等相似性指标轻松进行比较,大大降低了查找相似句子等任务的计算开销。
常用嵌入模型:
| 模型 | 机构 | 描述 |
|---|---|---|
| bge-large-zh | 北京智源研究院(BAAI) | 开源,向量维度1024,序列长度512 |
| bge-base-zh | BAAI | 开源,向量维度768,序列长度512 |
| bge-small-zh | BAAI | 开源,向量维度512,序列长度512 |
| bge-m3 | BAAI | 开源,多语言,向量维度1024,序列长度8192 |
| text-embedding-3-small | OpenAI | 多语言,向量维度1536,序列长度8192 |
| text-embedding-3-large | OpenAI | 多语言,向量维度3072,序列长度8192 |
一个容易被忽略的调优点
嵌入维度不是越大越好。很多现代模型支持 MRL(Matryoshka Representation Learning),允许你在生成全维度向量后,截取前一部分维度使用(比如从 1024 维截到 256 维),在几乎不损失检索质量的情况下,显著降低存储成本和检索延迟。Qwen3 Embedding、OpenAI text-embedding-3 等模型都支持这一特性,在数据量较大时值得利用。
另外,如果你的领域非常特殊(如医学、法律或内部专有术语),通用嵌入模型可能表现不佳。这时可以考虑用领域数据对嵌入模型做微调,但通常建议先用现成模型配合好的切分策略试跑,确认确实是嵌入选型瓶颈后再投入微调。
4.2 通过 langchain 完成文档嵌入
LangChain 设计了一个 Embedding 抽象类,在该类当中定义了多个抽象方法:
python
@abstractmethod
def embed_documents(self, texts: list[str]) -> list[list[float]]:
"""Embed search docs.
Args:
texts: List of text to embed.
Returns:
List of embeddings.
"""
"""
【抽象方法】批量对文档文本生成向量(Embedding)
一般用于 RAG:对切分好的 chunk 文档批量向量化,存入向量库
:param texts: 字符串列表,多个文档/文本块
:return: 二维浮点列表,每一段文本对应一个一维向量
"""
@abstractmethod
def embed_query(self, text: str) -> list[float]:
"""Embed query text.
Args:
text: Text to embed.
Returns:
Embedding.
"""
"""
【抽象方法】对用户查询问题生成向量(Embedding)
一般用于 RAG:用户提问后,把问题转向量,去向量库做相似度检索
:param text: 单个查询问句字符串
:return: 一维浮点列表,问题对应的向量
"""
-- 该 Embedding 的具体实现类有:HuggingFaceEmbeddings,OpenAIEmbeddings等。
-- 要使用 huggingface 所提供的开源模型进行文本向量嵌入,可以通过 HuggingFaceEmbeddings 实例完成:
python
# pip install sentence-transformers langchain_huggingface
def embedding_demo():
"""
HuggingFaceEmbeddings 向量嵌入示例
使用本地 BGE 中文向量模型 bge-base-zh-v1.5 生成文本向量
兼容 LangChain Embeddings 抽象接口:embed_query / embed_documents
"""
from langchain_huggingface import HuggingFaceEmbeddings
# 加载本地向量模型
# model_name:模型本地路径,这里指向 assets 下的 bge-base-zh-v1.5
embed_model = HuggingFaceEmbeddings(
model_name=r'.\assets\models\bge-base-zh-v1.5'
)
# ===================== 单文本嵌入 embed_query =====================
# 对应前面抽象基类的 embed_query:用于用户查询问句转向量
query = "你好,世界"
query_result = embed_model.embed_query(query)
print(len(query_result)) # 打印向量维度,bge-base-zh-v1.5 是 768 维
print(query_result[0:10]) # 打印向量前10个浮点数,方便查看
# ===================== 批量文本嵌入 embed_documents =====================
# 对应前面抽象基类的 embed_documents:用于文档chunk批量入库向量化
# 多文本嵌入
docs = ["你好,世界", "你好,世界"]
res = embed_model.embed_documents(docs)
print(type(res)) # 打印返回类型:list[list[float]],二维列表
if __name__ == "__main__":
embedding_demo()
5 向量存储和检索
文档嵌入生成了向量,接下来的问题就是:这些向量存在哪、怎么快速找到最相似的那几个。
向量存储和检索的核心,是用近似最近邻(ANN)索引在"快"和"准"之间做权衡,而数据库选型则取决于你的数据规模和工程约束。
向量存储:不只是"存起来"
向量数据库与传统数据库的关键区别在于,它专为高维向量的相似性搜索而优化。
存入时,你需要选择一种索引结构来组织向量,否则每次查询都要和全量向量逐一比对,数据量稍大就无法接受。
主流索引结构有两种:
HNSW(分层可导航小世界图) 是目前生产环境最常用的默认选择。它把向量组织成多层图结构,查询时从粗到细逐层逼近目标,查询速度快、召回率高,代价是内存占用较大。
IVF(倒排文件索引) 先用聚类把向量空间分成若干"桶",查询时只搜索离问题最近的几个桶。内存占用比 HNSW 小,但召回率通常略低,需要调 nprobe 参数来平衡速度与精度。
如果内存极其紧张,还可以叠加 PQ(乘积量化) 对向量做压缩,用一定的精度损失换取显著的内存节省。
检索:度量方式与一个容易被忽略的坑
检索的本质是计算查询向量与库中向量的"距离"。三种常见度量中,余弦相似度 最常用于文本嵌入,因为它只关心向量方向、忽略模长差异。如果你在嵌入阶段已经把向量归一化了,那么用内积 计算等价于余弦相似度,而且速度更快。欧氏距离则在向量模长本身携带意义时更合适。
有一个实操中容易踩的坑:建索引时用的度量方式,查询时必须保持一致。如果索引是按余弦建的,查询却传了欧氏距离,数据库可能无法使用索引,退化成全量暴力扫描。
另一个值得注意的点是过滤。在生产环境中,你往往需要"先限定租户或文档版本,再在范围内做语义检索"。如果先检索再过滤,可能因为最近的邻居恰好都不在过滤范围内,导致召回数量不足。更稳妥的做法是使用支持过滤的索引,或者确保过滤在检索之前完成。
数据库选型:先跑通,再升级
选型时最容易犯的错误是一上来就部署重型分布式系统。一个务实的路径是:先用最轻量的方案验证语义检索在你的数据上有没有价值,再根据规模决定是否迁移。
如果你的数据在百万级以内 ,而且团队已经在用 PostgreSQL,pgvector 是最省事的起点。它直接在关系库里加向量检索能力,不用维护第二套系统。代价是在千万级以上规模时,查询延迟会明显上升。
如果需要独立的向量数据库 ,Qdrant 在多项基准测试中展现出较低的中位查询延迟(约 4.55 ms),且过滤和水平扩展能力较好。Milvus 适合更大规模的分布式场景,支持 IVF、HNSW、DiskANN 等多种索引。Weaviate 在召回率上表现突出,多个数据集上 Recall@100 领先。如果你只需要一个嵌入式的检索库 、不需要数据库的 CRUD 和持久化功能,FAISS 性能极强(单节点可达 866 QPS),但你需要自己处理存储和元数据管理。
一个简单的判断标准:数据量在万级到十万级、想快速跑通,用 pgvector 或 Chroma;百万级以上、需要生产级稳定性和过滤能力,考虑 Qdrant 或 Milvus;只需要算法库、愿意自己造轮子,选 FAISS。
5.1 向量数据库的理解
-- 假设你是一名摄影师,拍了大量的照片。为了方便管理和查找,你决定将这些照片存储到一个数据库中。传统的关系型数据库(如 MySQL、PostgreSQL 等)可以帮助你存储照片的元数据,比如拍摄时间、地点、相机型号等。
-- 但是,当你想要根据照片的内容(如颜色、纹理、物体等)进行搜索时,传统数据库可能无法满足你的需求,因为它们通常以数据表的形式存储数据,并使用查询语句进行精确搜索。那么此时,向量数据库就可以派上用场。
-- 我们可以构建一个多维的空间使得每张照片特征都存在于这个空间内,并用已有的维度进行表示,比如时间、地点、相机型号、颜色....此照片的信息将作为一个点,存储于其中。以此类推,即可在该空间中构建出无数的点,而后我们将这些点与空间坐标轴的原点相连接,就成为了一条条向量,当这些点变为向量之后,即可利用向量的计算进一步获取更多的信息。当要进行照片的检索时,也会变得更容易更快捷。
-- 注意,在向量数据库中进行检索时,并不是检索唯一的匹配结果,而是查询和目标向量最为相似的一些向量,具有模糊性。
-- 延伸思考一下,只要对图片、视频、商品等素材进行向量化,就可以实现以图搜图、视频相关推荐、相似商品推荐等功能。
5.2 常用的向量数据库
LangChain 提供了众多向量存储的集成,包括开源的本地向量存储与云托管的私有向量存储。并公开了一个标准接口,可以轻松地在向量存储之间进行交换。
常用向量数据库:
| 向量数据库 | 描述 |
|---|---|
| FAISS | 一个用于高效相似性搜索和密集向量聚类的库 |
| Chroma | 开源的轻量级向量数据库,有极简的 API |
| Milvus | 开源的专为向量搜索设计的云原生数据库。性能强悍,功能丰富。覆盖轻量级的原型开发到十亿级向量的大规模生产系统 |
| Pgvector | 开源关系型数据库 PostgreSQL 的扩展,为PostgreSQL增加了向量数据类型和相似性搜索功能 |
| Redis | 开源内存数据结构存储,现已原生支持向量相似性搜索功能 |
| Elasticsearch | 开源分布式搜索和分析引擎,提供了一个基于文档的数据库,结构化、非结构化和向量数据通过高效的列式存储统一管理 |
5.3 Milvus (稠密向量 和 稀疏向量)
5.3.1 Milvus架构
Milvus因其强大的性能(可支持数百亿级别的向量存储和检索),以及可扩缩容等相关特点,在实际生产环境下,使用非常普遍。
Milvus的架构如下图所示:

Milvus 的组件解耦良好,其中三个最关键的任务
- 搜索
- 数据插入
- 索引 / 压缩
被设计为易于并行化的进程,复杂逻辑被分离出来。
这确保了相应的查询节点、数据节点和索引节点能够独立地进行纵向和横向扩展,从而优化性能和成本效率。
5.3.2 Milvus Collection及数据类型
Milvus 通过 数据库---Collections---实体 的结构管理数据。Collections 和实体就类似关系型数据库中的表和记录。
具体来说,Collection 是一个二维表,具有固定的列和变化的行。每列代表一个字段,每行代表一个实体。

Collection 通过 Collection Schema 来定义有哪些字段以及字段的类型、索引等。
以下是Milvus所支持的数据类型:
| 向量字段类型 | 字段 | 描述 |
|---|---|---|
| 密集向量 | FLOAT_VECTOR | 32位浮点数列表 |
| FLOAT16_VECTOR | 16位半精度浮点数列表 | |
| BFLOAT16_VECTOR | 16位浮点数列表,精度稍低,但指数范围与 Float32 相同 | |
| INT8_VECTOR | 8位有符号整数向量 | |
| 稀疏向量 | SPARSE_FLOAT_VECTOR | 非零数字及其序列号列表 |
| 二进制向量 | BINARY_VECTOR | 一个0和1的列表 |
| 标量字段类型 | 描述 |
|---|---|
| VARCHAR | 字符串 |
| BOOL | 存储true或false |
| INT | INT8、INT16、INT32、INT64 |
| FLOAT | 32位浮点数 |
| DOUBLE | 64位双精度浮点数 |
| ARRAY | 相同数据类型元素的有序集合 |
| JSON | 结构化的键值数据 |
-- 一个 Collection Schema 有一个主键、最多四个向量字段和若干标量字段。主键用于唯一标识一个实体,只接受 Int64 或 VarChar 值。插入实体时,默认情况下应包含主键值。但是,如果在创建 Collections 时启用了 AutoId,Milvus 将在插入数据时生成主键值,此时插入的实体中不应包含主键值。
-- 向量字段是最重要的字段,可以分为稠密向量或者是稀疏向量。
- 稠密向量 : 通常由基于 Transformer Encoder 架构的深度学习模型(如 Sentence-BERT)生成。
- 稀疏向量 : 用于表示文本中的关键词及其对应权重,其构建方式经历了从早期基于 TF-IDF 等统计方法的传统词袋模型,到如今基于深度学习模型的学习式构建方法的发展演进。
-- 使用深度学习模型构建稀疏向量的原理如下图所示(分词过程和稠密向量生成当中类似):

通过深度学习模型生成一个稀疏向量时,不再"只看一句话的总体意思",而是让模型推理:这句话里哪些词重要、重要到什么程度,并把这些重要词单独拎出来加权表示。
下面以BGE-M3为例,来展示稠密向量和稀疏向量的不同:
python
# pip install FlagEmbedding==1.3.5
# 导入 BGEM3FlagModel,BGE-M3 是支持稠密向量+稀疏向量+多语言的混合检索模型
from FlagEmbedding import BGEM3FlagModel
# 加载本地的 BGE-M3 模型
# model_name_or_path:本地模型文件夹路径
model = BGEM3FlagModel(model_name_or_path=r"D:\PycharmProjects\lessons\demo_class\LangChainDemo\04_retrieval\assets\models\bge-m3")
# encode:文本编码主函数
# 传入文本列表;return_sparse=True 返回稀疏向量;return_dense=True 返回稠密向量
# BGE-M3 支持混合检索(dense + sparse,也就是稠密语义检索 + 词法BM25类稀疏检索)
res = model.encode(["标量字段通常用来存储一些元数据,并可以在搜索时通过元数据进行过滤"], return_sparse=True, return_dense=True)
print('encode结果为:', res, end='\n\n')
# 1、打印稀疏向量 lexical_weights
# key: token_id,value: 权重分数;类似BM25,捕捉关键词、字面匹配
print('稀疏向量为:', res["lexical_weights"], end='\n\n')
# 2、把稀疏向量里的 token id 映射回原始字符串token
# 方便肉眼查看稀疏向量到底是哪些词起作用
sparse_vecs = model.convert_id_to_token(res["lexical_weights"])
print('稀疏向量转换为token后的结果为:', sparse_vecs, end='\n\n')
# 3、打印稠密向量 dense_vecs
# 就是我们RAG最常用的语义向量,捕捉语义含义,用于相似度匹配
print('稠密向量为:', res["dense_vecs"], end='\n\n')
标量字段通常用来存储一些元数据,并可以在搜索时通过元数据进行过滤,以提高搜索结果的正确性。
简单总结 BGE-M3 两种向量
- dense_vecs(稠密向量):一维浮点数数组,语义匹配,适合找意思相近的内容;就是前面 bge-base-zh 那种向量
- lexical_weights(稀疏向量) :
{token_id: score}字典格式,关键词字面匹配,类似增强版 BM25;
混合检索(稠密 + 稀疏一起算分)可以兼顾语义相似 和关键词命中,检索效果通常比单用稠密向量更好。
小点
BGE-M3 还支持 return_colbert_vecs=True 拿到 ColBERT 细粒度向量,做 late-interaction 检索,代价是显存开销更大
5.3.3 Mivus索引 -- 稠密向量
索引是建立在数据之上的附加结构,可以加快搜索速度。不同字段数据类型适用不同的索引类型。
1) 稠密向量是什么
稠密向量的每一个维度都有数值,通常是一个几百到几千维的浮点数数组,比如 768 维或 1024 维。嵌入模型把一段文本压缩成这个数组,语义相近的文本,向量在空间中的方向也相近。
关键特征是:单个维度本身没有可解释的含义。你无法指出第 37 维代表"天气"或第 200 维代表"情感"。语义信息是分布式地编码在整个向量的模式中。这正是它和稀疏向量的根本区别。
2)稠密向量 ------ IVF(聚类算法),FLAT(全量匹配),HNSW
-- 稠密向量可使用 HNSW(分层导航小世界)索引。
-- HNSW (分层导航小世界)是当下常用的一种基于图的索引算法,可以提高搜索高维浮点数向量时的性能。它具有出色的搜索精度和低延迟,但需要较高的内存开销来维护其分层图结构。
-- 该算法构建了一个多层图(类似不同缩放级别的地图),底层包含所有数据点,而上层则由从底层采样的数据点子集组成。在这种层次结构中,每一层都包含代表数据点的节点,节点之间由表示其接近程度的边连接。上层提供远距离跳转,以快速接近目标,而下层则进行细粒度搜索,以获得最准确的结果。
其工作原理如下:
- 入口点:搜索从顶层的一个固定入口点开始,该入口点是图中的一个预定节点。
- 贪婪搜索:算法贪婪地移动到当前层的近邻,直到无法再接近查询向量为止。上层起到导航作用,作为粗过滤器,为下层的精细搜索找到潜在的入口点。
- 层层下降:一旦当前层达到局部最小值,算法就会利用预先建立的连接跳转到下层,并重复贪婪搜索。
- 最后细化:这一过程一直持续到最底层,在最底层进行最后的细化步骤,找出最近的邻居。

另外一种稠密向量所支持的索引类型是FLAT, FLAT 索引是用于对浮点向量进行索引和搜索的最简单、最直接的方法之一。它采用暴力搜索的方式,每个查询向量直接与数据集中的每个向量进行比较,无需任何高级的预处理或数据结构化操作。这种方法能保证准确性,提供 100% 的召回率,因为每一个潜在的匹配项都会被评估。
稠密向量是文档嵌入最主流的形式,也是你前面流程中默认使用的向量类型。理解它的特点以及和稀疏向量的关系,能帮你在检索效果不理想时判断问题出在哪。
3) 稠密向量的优势与短板
优势在于语义泛化能力。 用户问"怎么退钱",文档里写的是"退款流程",两者字面重叠很少,但稠密向量能把它们拉到很近的位置。它擅长处理同义表达、跨语言检索和模糊语义匹配。
短板主要有三个:
-
对精确匹配不敏感。用户搜一个产品型号"XG-2000A",或者一个罕见的人名、错误码,稠密向量可能把语义上"差不多"但实际完全不同的内容排到前面,而真正包含这个精确字符串的文档反而被漏掉。
-
不可解释。检索结果不相关时,你很难从向量维度上诊断原因,只能回到切分策略或嵌入选型去排查。
-
存储和计算成本随维度线性增长。1024 维的 float32 向量,每条占 4KB,百万条就是 4GB,这还只是原始向量,不含索引开销。这也是 MRL 截断维度、PQ 量化等技术存在的意义。
4) 什么时候该警惕稠密向量的局限
如果你的知识库里有大量标识符、编号、专有名词、代码片段,或者用户查询经常是短关键词而非完整问句,单纯依赖稠密向量很可能召回不足。这时候优先考虑引入稀疏检索做混合,而不是急着换更大的嵌入模型。反过来,如果查询以自然语言问句为主、文档是叙述性文本,稠密向量单独使用通常就能有不错的效果。
5.3.4 Mivus索引 -- 稀疏向量
稀疏向量是稠密向量的互补形式,它用高维但极度稀疏的表示,保留了精确的词项匹配能力和可解释性,正好弥补稠密向量在型号、人名、错误码等精确查询上的短板。
长什么样
稀疏向量建立在一个很大的词表空间上(维度可达数万甚至数十万),但一条文档或查询只激活其中少数几个维度,每个非零维度对应一个明确的词项(term)及其权重。存储时只保存非零项,而不是完整的高维数组。
举个例子,"I love black cat" 的稀疏向量可能表示为 {"love": 0.71, "black": 0.82, "cat": 0.93, "feline": 0.51, "kitty": 0.28}。其中 feline 和 kitty 并不是原文出现的词,而是学习稀疏模型(如 SPLADE)根据上下文激活的语义相关词项。这意味着稀疏向量不是纯粹的关键词匹配,它通过词项扩展也具备一定的语义能力,只是这种语义仍然保留在可解释的词表空间中。
原理
SPARSE_INVERTED_INDEX 索引是 Milvus 用于高效存储和搜索稀疏向量的一种索引类型。这种索引类型利用倒排索引的原理,为稀疏数据创建高效的搜索结构。
以下是倒排索引示意图:

在实际查询时,先通过倒排索引查询到包含query当中token的文档有哪些,然后计算查询和各个文档之间的相似度分数。
在计算查询和文档之间的相似度分数时,有两种指标类型(metric type):
- IP: 即Inner Product内积,例如查询所对应的稀疏向量为:{27:0.7, 100:0.4},doc1所对应的稀疏向量为:{27:0.5, 100:0.3, 5369:0.6},则相似度分数为similarity = (0.5×0.7) + (0.3×0.4) = 0.35 + 0.12 = 0.47
- BM25: 通过对 query 与文档中重合的词项进行加权求和,综合考虑词频(TF)、逆文档频率(IDF: 反映了一个词在全部文档当中的重要性,词出现的文档数量越少,IDF越高)以及文档长度归一化,从而计算二者的相关性得分。
在 RAG 中怎么用
稀疏向量在检索中通常有两种角色:
-
一是作为精确匹配的补充 。
当用户查询包含产品型号、错误码、罕见专有名词时,稠密向量可能把语义"相近"但实际无关的内容排到前面。稀疏向量能确保包含精确词项的文档被召回。
-
二是参与混合检索 。
生产环境常见的做法是同时跑稀疏检索和稠密检索,再用 RRF(倒数排名融合) 或加权分数把两路结果合并。这样既能接住语义泛化,又不会漏掉精确命中。BM25 是最经典的稀疏检索基线,而 SPLADE 等学习稀疏模型则在保留词项可解释性的同时引入了语义扩展能力。
如果你的知识库里有大量标识符、编号、代码片段,或者用户查询经常是短关键词而非完整问句,引入稀疏向量做混合检索通常是比单纯换更大稠密模型更直接的改进方向。
5.3.5 稠密向量 和 稀疏向量
稠密向量和稀疏向量不是新旧替代关系,而是互补的两条检索路径 。
理解它们的关系,关键是看它们各自编码了什么信息、丢失了什么信息。
核心区别:一个保留词项,一个保留语义
- 稀疏向量的非零维度对应具体词项,所以它保留了"这个词有没有出现"的精确信息。
- 稠密向量把整段文本压成一个固定长度的数组,保留了语义方向,但丢失了词项级别的精确性。
这个区别决定了它们的能力边界:
| 维度 | 稀疏向量 | 稠密向量 |
|---|---|---|
| 精确匹配(型号、人名、错误码) | 强 | 弱 |
| 语义泛化(同义改写、跨语言) | 有限 | 强 |
| 可解释性 | 每个非零维度对应一个词,可诊断 | 单维不可解释 |
| 维度与存储 | 词表级高维,但只存非零项 | 固定低维,存完整数组 |
| 对未登录词 | 依赖词表,OOV 词无法表示 | 可泛化到训练中见过的语义模式 |
关键点在于:稀疏向量丢失的是语义泛化能力,稠密向量丢失的是精确词项信息。两者丢失的东西恰好是对方擅长的。
为什么它们是互补而非竞争
一个具体场景能说明这种互补性。用户搜"XG-2000A 报错 E47":
- 稠密向量可能把"设备故障代码说明"这类语义相近的文档排到前面,但真正包含"XG-2000A"和"E47"的文档不一定排第一,因为这两个字符串在嵌入模型眼里没有稳定的语义方向。
- 稀疏向量能精确命中包含这两个词项的文档,但如果用户换一种说法问"那个两千系列设备的四七错误怎么处理",稀疏向量可能因为词项不重叠而完全召回不到。
混合检索同时跑两路,用 RRF 或加权分数融合,就能同时接住这两种查询。这也是为什么生产环境里混合检索往往是默认推荐,而不是二选一。
一个容易混淆的点:学习稀疏模型模糊了边界
BM25 是纯词项匹配,但 SPLADE 这类学习稀疏模型会通过词项扩展激活原文没出现的相关词(比如给"cat"的文档激活"feline"的权重)。这让稀疏向量也具备了一定语义能力,边界变得不那么绝对。但即便如此,它的语义仍然保留在可解释的词表空间中,每个非零项仍对应一个具体词项,这和稠密向量的分布式编码有本质不同。
选择上的实用判断
如果查询以自然语言问句为主、文档是叙述性文本,稠密向量单独使用通常够用。如果知识库里标识符、编号、专有名词密集,或者查询经常是短关键词,单纯稠密向量召回不足时,优先引入稀疏检索做混合,而不是急着换更大的嵌入模型。混合检索的收益通常比单换模型更直接。
5.3.6 Milvus部署
Milvus 提供了多个版本以在不同场景下选择合适的使用方式:
- Milvus Lite:本地轻量化运行,通过
pip install pymilvus[milvus-lite]即可安装。但 Milvus Lite 有一些限制,比如 Milvus Lite 仅支持 FLAT 索引类型。无论在 Collections 中指定了哪种索引类型,它都使用 FLAT 类型。另外,Milvus Lite仅支持在MacOs和Linux上面使用。 - Milvus Standalone:单点部署,支持通过 Docker 部署。
- Milvus Distributed:分布式部署,支持在 Kubernetes 集群上部署。
5.4 Milvus 创建 Collection
正如前面介绍,Milvus当中的数据,存储到独立的Collection当中。创建Collection可以分为以下步骤:
(1)构建schema信息;
(2)添加索引;
(3)创建collection。
示例代码如下所示:
python
# pip install pymilvus
def get_milvus_client():
"""
获取 Milvus 客户端连接
连接本地启动的 Milvus 服务,默认端口 19530
"""
from pymilvus import MilvusClient
# 初始化客户端
# uri:Milvus 服务地址
# token:鉴权令牌,本地单机默认空字符串
client = MilvusClient(
uri="http://localhost:19530",
token="",
)
# 查询当前Milvus里所有已存在的集合(collection)
res = client.list_collections()
print(res)
return client
def build_schema():
"""
构建集合的字段结构 Schema
适配 BGE-M3:同时存稠密向量 + 稀疏向量 + 原文 + 元数据
auto_id=True:主键id由Milvus自动生成,不需要手动传入
"""
from pymilvus import MilvusClient, DataType
return (
MilvusClient.create_schema(
# 自动生成主键id,不用自己维护id
auto_id=True,
)
# 主键id字段,INT64,主键必须唯一
.add_field(field_name="id", datatype=DataType.INT64, is_primary=True)
# 稠密向量字段,BGE-M3 dense_vecs维度是1024
.add_field(field_name="vector", datatype=DataType.FLOAT_VECTOR, dim=1024)
# 存储文本chunk原文,VARCHAR字符串,限制最大长度1500
.add_field(field_name="text", datatype=DataType.VARCHAR, max_length=1500)
# metadata:JSON类型,存放切片来源、页码、起始索引等附加信息
.add_field(field_name="metadata", datatype=DataType.JSON)
# 稀疏向量字段,专门存放BGE-M3输出的lexical_weights
.add_field(field_name="sparse_vector", datatype=DataType.SPARSE_FLOAT_VECTOR)
)
def build_index():
"""
构建索引配置
稠密向量、稀疏向量分别创建各自类型的索引,用于快速检索
"""
from pymilvus import MilvusClient, DataType
# 初始化索引参数对象
index_params = MilvusClient.prepare_index_params()
# 给稠密向量 vector 建索引
index_params.add_index(
field_name="vector", # 需要建索引的稠密向量字段名
index_type="HNSW", # HNSW:主流高效稠密向量索引,检索速度快
metric_type="L2", # 距离度量:L2距离(欧氏距离);越小代表向量越相似
)
# 给稀疏向量 sparse_vector 建索引
index_params.add_index(
field_name="sparse_vector",
index_type="SPARSE_INVERTED_INDEX", # 稀疏倒排索引,专门用于稀疏向量检索
metric_type="IP", # 内积IP,稀疏向量推荐用IP计算相似度
)
return index_params
def create_collection(client):
"""
创建集合主逻辑
先删除旧集合(方便反复调试),再新建集合,绑定schema和索引
"""
from pprint import pprint
# 先删除 demo_collection,调试用,避免旧结构冲突;生产环境不要随意drop
client.drop_collection(collection_name="demo_collection")
# 判断集合是否不存在
if not client.has_collection(collection_name="demo_collection"):
print("collection demo_collection not exists, create it")
# 创建集合,指定名称、字段结构、索引配置
client.create_collection(
collection_name="demo_collection",
schema=build_schema(),
index_params=build_index(),
)
# 打印当前所有集合列表
print(client.list_collections())
# 打印集合详情(字段、索引信息)
pprint(client.describe_collection(collection_name="demo_collection"))
# 入口:获取客户端并执行创建集合
create_collection(get_milvus_client())
5.5 Milvus 操作实体
在创建完了collection之后,就可以"增删"操作。
5.5.1 插入实体
将数据构建成 ListDict 形式,其中List表示批量数据,Dict表示符合创建 collection 时所定义的 schema 结构的数据,构建完成后,即可通过 client.insert 方法,将数据插入到指定的 collection 当中去,
示例代码如下:
python
from pymilvus import MilvusClient, DataType
def get_client():
"""获取Milvus客户端连接,连接本地Milvus服务"""
return MilvusClient(uri="http://localhost:19530", token="")
def insert_data(client: MilvusClient, collection_name: str):
"""
构建数据,并插入到collection中
完整流程:加载docx文档 → 文本切片 → BGE-M3生成稠密+稀疏向量 → 组装数据写入Milvus
"""
# ========== 步骤1:加载本地Word文档 ==========
from langchain_community.document_loaders import UnstructuredWordDocumentLoader
# mode="single" 将整篇文档加载为单个Document对象
doc_list = UnstructuredWordDocumentLoader("assets/sample.docx", mode="single").load()
# ========== 步骤2:文档文本切分(Chunk) ==========
from langchain_text_splitters import RecursiveCharacterTextSplitter
# 递归字符分割器:优先按空行、换行、中文句号切分
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=500, # 单个文本块最大字符数
chunk_overlap=50, # 相邻块重叠字符,保留上下文
separators=["\n\n", "\n", "。"]
)
splitted_doc_list = text_splitter.split_documents(doc_list)
splitted_doc_list = splitted_doc_list[0:20] # 限制最多取前20块,调试用,避免一次性插入太多
# 计算文本转utf-8字节后的最大长度,用来校验Milvus VARCHAR字段容量
max_len = max([len(bytes(doc.page_content.encode("utf-8"))) for doc in splitted_doc_list])
print('当前文本块最大字节长度是:', max_len)
# ========== 步骤3:使用BGE-M3生成稠密向量、稀疏向量 ==========
from FlagEmbedding import BGEM3FlagModel
# 加载本地BGE-M3混合向量模型,需要cuda版本torch加速
model = BGEM3FlagModel("assets/models/bge-m3")
# 批量编码所有chunk文本
# return_dense=True:输出稠密向量;return_sparse=True:输出稀疏lexical_weights
all_vectors = model.encode(
[doc.page_content for doc in splitted_doc_list],
return_dense=True,
return_sparse=True
)
dense_vectors = all_vectors["dense_vecs"] # 稠密向量数组,用于语义检索
sparse_vectors = all_vectors['lexical_weights']# 稀疏向量(token_id:权重字典),用于关键词检索
# ========== 步骤4:组装Milvus插入数据,格式为字典列表 ==========
insert_data_list = []
# 循环打包:文档块、稠密向量、稀疏向量一一对应
for doc, dense_vector, sparse_vector in zip(splitted_doc_list, dense_vectors, sparse_vectors):
insert_data_list.append({
"vector": dense_vector, # 稠密向量字段,对应schema里FLOAT_VECTOR
"sparse_vector": sparse_vector, # 稀疏向量字段,对应SPARSE_FLOAT_VECTOR
"metadata": doc.metadata, # 文档元信息(来源、页码等)存入JSON字段
"text": doc.page_content # 原始切片文本
})
# ========== 步骤5:调用Milvus Client执行插入 ==========
res = client.insert(
collection_name=collection_name,
data=insert_data_list
)
# 打印插入结果,包含成功插入条数、id列表
print("插入结果:", res)
if __name__ == "__main__":
cli = get_client()
insert_data(cli, "demo_collection")
重点提示
- 和前面
build_schema是配套的:字段名vector / sparse_vector / text / metadata必须完全一致 splitted_doc_list[0:20]是调试限制,上线去掉;- BGE-M3 encode 批量编码效率远高于循环单条 encode;
- Milvus
auto_id=True,我们不需要传入 id,服务端自动生成主键; - 字节长度校验:Milvus 的 VARCHAR 限制是字节数不是字符数,中文 utf8 一个字占 3 字节。
5.5.2 删除实体
删除实体,可以调用 MilvusClient的delete 方法,该同样也可以通过传递id列表,或者是通过其他字段过滤条件来进行删除,该方法返回结果为一个字典,包含了一个 delete_count键,值为删除的数据条数,
示例代码如下所示:
python
def delete_demo(client):
"""
Milvus 删除数据示例
根据过滤条件删除集合内指定向量数据
"""
res = client.delete(
collection_name="demo_collection",
# filter:过滤表达式,满足条件的数据会被删除
# 这里根据主键id精准删除两条记录;也可以用text、metadata等字段写过滤条件
filter="id in [463480757150366907, 463480757150366908]",
)
# 打印删除接口返回结果,里面包含删除成功数量等信息
print(res)
if __name__ == "__main__":
# 获取Milvus连接客户端
client = get_milvus_client()
# 执行删除操作
delete_demo(client)
补充说明
- 过滤表达式写法示例
python
# 删除id大于100的数据
filter="id > 100"
# 根据元数据过滤,metadata是json字段
filter='metadata["source"] == "sample.docx"'
- ⚠️ 注意
- 删除是硬删除,数据直接移除,无法恢复;调试务必小心
filter不能为空,如果想清空整张表,更推荐client.drop_collection()(测试场景)- 删除后 Milvus 不会立刻释放磁盘空间,后台会异步清理
5.6 Milvus 检索
Milvus 支持多种检索方式。对向量字段,Milvus 支持精确检索(KNN)以及近似近邻检索(ANN)。对标量字段,Milvus 提供条件过滤能力,主要用于配合向量检索进行结果筛选,其能力和定位不同于传统关系型数据库普通检索。
5.6.1 向量检索
-- 通过调用 client.search,传入需要检索的query向量,以及需要和query向量进行比较的向量字段,即可进行向量检索。
在执行向量检索时,通常需要关注以下参数:
- 查询向量(query vector):表示待检索对象的向量表示,维度需与集合中向量字段一致。
- 向量字段(anns_field):指定参与检索的向量字段名称。
- TopK(limit):返回相似度最高的结果数量。
- metric_type:指定相似度计算方式,如 COSINE、L2 或 IP,需与索引和向量语义保持一致。
-- 另外向量检索还支持同时使用稠密向量和稀疏向量,进行混合检索,并配置重排序器,来对两路召回结果进行一个重排序。
-- 重排序(Reranker)是指在初步检索(Recall)完成后,对候选结果进行二次排序优化的过程。其目标不是扩大召回范围,而是在已有候选集内提升排序质量和结果相关性。在典型的检索系统中,重排序通常位于向量检索或混合检索之后,用于融合多路检索结果或引入更精细的排序策略。
此处介绍 RRFReranker。RRFRanker 策略的主要工作流程如下:
- (1)收集搜索排名:收集来自向量搜索各路径的结果排名(rank_1、rank_2)。
- (2)合并排名 :根据公式转换各路径的排名(rank_rrf_1、rank_rrf_2)。
计算公式涉及 N,N 代表检索器的数量。ranki (d) 是第 i 个检索器生成的文档 d 的排名位置。k 是一个平滑参数,通常设置为 60。 - (3)聚合排名 :基于合并后的排名对搜索结果进行重新排序,以生成最终结果。
其示意图如下:

代码示例:
python
from typing import Any, Dict, List, Tuple
# Milvus集合名称常量,和前面创建的collection保持一致
COLLECTION_NAME = "demo_collection"
def get_milvus_client(uri: str = "http://localhost:19530", token: str = ""):
"""
获取Milvus客户端连接
:param uri: Milvus服务地址,本地默认19530端口
:param token: 鉴权token,单机本地默认空
:return: MilvusClient实例
"""
from pymilvus import MilvusClient
return MilvusClient(uri=uri, token=token)
def get_bge_m3_model():
"""
加载本地BGE-M3混合向量模型
BGE-M3:同时支持稠密向量 + 稀疏向量编码
:return: BGEM3FlagModel 对象
"""
from FlagEmbedding import BGEM3FlagModel
return BGEM3FlagModel(model_name_or_path="./assets/models/bge-m3")
def encode_query(model, query: str) -> Tuple[List[float], Dict[int, float]]:
"""
对用户查询问句进行编码,一次性生成稠密向量 + 稀疏向量
:param model: BGE-M3模型实例
:param query: 用户查询文本
:return: (稠密向量list[float], 稀疏向量dict{token_id:权重})
"""
all_embeddings = model.encode([query], return_dense=True, return_sparse=True)
dense_vec = all_embeddings["dense_vecs"][0] # 取出第一条(唯一)稠密向量
sparse_raw = all_embeddings["lexical_weights"][0] # 取出第一条(唯一)稀疏向量
return dense_vec, sparse_raw
def print_hits(title: str, hits: List[dict]):
"""
格式化打印检索结果,方便调试查看召回内容
:param title: 检索类型标题
:param hits: milvus search返回的单批次命中列表
"""
print("\n" + "=" * 20)
print(title)
print("=" * 20)
for i, hit in enumerate(hits, start=1):
entity = hit.get("entity", {})
print(
{
"rank": i,
"id": entity.get("id"),
"distance": hit.get("distance"),
"text": entity.get("text"),
"metadata": entity.get("metadata"),
}
)
def dense_vector_search_example(client, query: str, limit: int = 5):
"""
【稠密向量检索】
基于语义相似度检索,使用vector字段(BGE-M3 dense_vecs)
:param client: milvus客户端
:param query: 用户问题
:param limit: 返回top-k结果数量
:return: 检索原始结果
"""
model = get_bge_m3_model()
dense_vec, _ = encode_query(model, query)
results = client.search(
collection_name=COLLECTION_NAME,
data=[dense_vec], # 查询稠密向量,套列表
anns_field="vector", # 指定检索稠密向量字段
limit=limit, # 返回前N条
search_params={"metric_type": "L2"}, # 度量方式L2欧氏距离
output_fields=["id", "text", "metadata"], # 需要附带返回的原始字段
)
print_hits("稠密向量检索(vector)", results[0])
return results
def sparse_vector_search_example(client, query: str, limit: int = 5):
"""
【稀疏向量检索】
基于关键词词法匹配检索,使用sparse_vector字段(BGE-M3 lexical_weights)
:param client: milvus客户端
:param query: 用户问题
:param limit: 返回top-k结果数量
:return: 检索原始结果
"""
model = get_bge_m3_model()
_, sparse_vec = encode_query(model, query)
results = client.search(
collection_name=COLLECTION_NAME,
data=[sparse_vec], # 查询稀疏向量
anns_field="sparse_vector",# 指定稀疏向量字段
limit=limit,
search_params={"metric_type": "IP"}, # 稀疏向量固定使用内积IP
output_fields=["id", "text", "metadata"],
)
print_hits("稀疏向量检索(sparse_vector)", results[0])
return results
def hybrid_vector_search_example_rrf(client, query: str, limit: int = 5):
"""
【混合检索 Hybrid Search + RRF重排】
同时发起稠密检索 + 稀疏检索两路查询,使用RRF Reciprocal Rank Fusion融合排序
RRF:倒数排名融合,不依赖两路分数,只靠排名位置融合结果,稠密语义+稀疏关键词兼顾
:param client: milvus客户端
:param query: 用户问题
:param limit: 最终返回top-k
:return: 融合后的检索结果
"""
from pymilvus import AnnSearchRequest, RRFRanker
model = get_bge_m3_model()
dense_vec, sparse_vec = encode_query(model, query)
# 构造稠密检索请求
dense_req = AnnSearchRequest(
data=[dense_vec],
anns_field="vector",
param={"metric_type": "L2"},
limit=limit,
)
# 构造稀疏检索请求
sparse_req = AnnSearchRequest(
data=[sparse_vec],
anns_field="sparse_vector",
param={"metric_type": "IP"},
limit=limit,
)
# 混合检索接口,传入两路检索请求,RRF融合
results = client.hybrid_search(
collection_name=COLLECTION_NAME,
reqs=[dense_req, sparse_req],
ranker=RRFRanker(k=60), # RRF里的k值,常用60,用来平滑排名权重
limit=limit,
output_fields=["id", "text", "metadata"],
)
print_hits("混合向量检索(RRF 融合稠密+稀疏)", results[0])
return results
if __name__ == "__main__":
cli = get_milvus_client()
question = "标量字段的作用是什么?"
dense_vector_search_example(cli, question)
sparse_vector_search_example(cli, question)
hybrid_vector_search_example_rrf(cli, question)
5.6.2 标量检索
标量检索(或标量查询)是指不涉及向量相似度计算,仅基于标量字段条件对数据进行筛选和返回的检索方式。
标量检索示例代码如下所示:
python
def scalar_query_examples(client, like_keyword: str = "大模型"):
"""
Milvus 标量过滤检索示例
scalar query:不做向量相似度检索,直接按字段条件过滤数据
适用场景:按来源、文件名、文本内容、页码等条件筛选chunk
"""
# ========== 示例1:VARCHAR字段模糊匹配,text字段包含指定关键词 ==========
try:
like_res = client.query(
collection_name=COLLECTION_NAME,
# like 模糊查询,% 是通配符:%xxx% 代表包含xxx
filter=f'text like "%{like_keyword}%"',
# 指定需要返回哪些字段
output_fields=["id", "text"],
limit=5, # 最多返回5条
)
print("\n" + "=" * 20)
print(f'标量检索(query: text like "%{like_keyword}%")')
print("=" * 20)
for row in like_res:
print({"id": row.get("id"), "text": row.get("text")})
except Exception as e:
print("VARCHAR like 过滤失败:", e)
# ========== 示例2:JSON类型metadata内部字段过滤 ==========
try:
json_res = client.query(
collection_name=COLLECTION_NAME,
# metadata是JSON字段,metadata["source"]取出json内source值,再模糊匹配
filter='metadata["source"] like "%sample%"',
output_fields=["id", "metadata"],
limit=5,
)
print("\n" + "=" * 20)
print('标量检索(query: metadata["source"] like "%sample%")')
print("=" * 20)
for row in json_res:
print({"id": row.get("id"), "metadata": row.get("metadata")})
except Exception as e:
print("JSON 过滤失败(metadata 结构不匹配或不支持该表达式):", e)
6 生成
查找到相关数据之后,就可以将所有的数据,放到和LLM交互的上下文当中,让LLM基于完整的上下文信息来进行生成。
python
def rag_demo(client: MilvusClient, query):
"""
RAG 主流程演示
流程:RRF混合检索召回上下文 → 拼接Prompt → 调用大模型基于上下文回答
"""
from langchain_openai import ChatOpenAI
# 初始化LLM,这里使用兼容OpenAI接口的gpt-4o-mini
llm = ChatOpenAI(model_name="gpt-4o-mini")
# 调用前面写好的混合检索函数:稠密+稀疏RRF融合召回
retrieval_res = hybrid_vector_search_example_rrf(client=client, query=query)
# 从召回结果里取出text字段,用换行拼接成上下文字符串
# retrieval_res[0]才是命中列表,原代码这里有bug,下面注释标出
context = "\n".join([data["text"] for data in retrieval_res[0]])
# 组装对话消息
message_list = [
{
"role": "system",
"content": "你是一个专业的法律问答机器人,请根据上下文回答问题,当上下文无法回答问题时,请回答"根据上下文无法回答该问题""
},
{
"role": "user",
"content": f"根据以下上下文回答问题:{context}\n问题:{query}"
}
]
# 调用大模型生成回答
res = llm.invoke(message_list)
print(res)
if __name__ == "__main__":
# 获取Milvus客户端
client = get_milvus_client()
# 执行RAG问答,提问:不动产被占有了怎么办?
rag_demo(client, '不动产被占有了怎么办?')