做Agent应用最烦的不是写那个调LLM的循环,而是“数据进得来、知识找得着”。做过RAG的兄弟应该都有同感:文档切得稀碎、向量化维度对不上、检索结果一团糟——这些锅最后全得背在Agent身上。前阵子给一套Agent框架搭基础检索设施,正好完整走了一遍Unstructured解析文档、FAISS建向量索引、BGE-M3做本地Embedding的链路,从下载安装到参数调优都有不少体会,整理出来给正要踩坑的朋友一个参考。
这篇内容主要面向两类人:一是自己在搭RAG或Agent记忆模块的技术人员,二是想私有化部署知识库、但被文档解析和向量检索搞得头疼的同学。核心解决三件事:让PDF、Word、网页等乱七八糟的文档格式变成干净文本,让文本能算成可比较的向量,让向量能被快速检索。这三步通了,Agent的“长期记忆”底座基本就稳了。
1. 整体设计:三件套在Agent框架里各自扮演什么角色
1.1 为什么偏偏是这三个组件
接触Agent框架越深,越会觉得底层其实就是“记忆+规划+工具”。记忆这块,光靠对话上下文那点token根本不够用,必须外挂一个能读文档、能存向量、能快速检索的子系统。而Unstructured、FAISS、BGE-M3这套组合,恰好覆盖了记忆子系统最核心的三个环节。
Unstructured负责的是“读文档”。它能把PDF、DOCX、HTML、PPT这些非结构化文件拆成有语义的区块,而不是简单按字符硬切。FAISS负责“存和找”,这是Meta开源的向量检索库,在千万级向量下还能保持毫秒级响应,本地部署没得挑。BGE-M3则是智源出的Embedding模型,最大的卖点是支持中文和英文混合场景,而且能把文档编码成1024维的稠密向量,配合FAISS刚好契合。
选这三个不是因为它们名气大,而是因为它们是同类里最容易打通闭环的。BGE-M3和FAISS的组合,维度匹配上不用做额外转换,Unstructured解析出来的文本块转成向量之后,直接丢进FAISS就算完事,整个过程没有多余的胶水代码。
1.2 数据流转链路
我先画一下整套数据是怎么走的,这样后面看安装和代码才不会懵:
原始文档 → Unstructured解析 → 文本分块 → BGE-M3编码 → FAISS索引存储 → 检索时Query经BGE-M3编码 → FAISS召回TopK → 送入LLM上下文
这里有个容易被忽略的点:Unstructured和BGE-M3之间的文本分块策略会直接影响最终检索效果。如果你解析完文档就直接整篇丢给BGE-M3编码,长文档的语义会被稀释,检索时命中率惨不忍睹。实际做法要结合文档结构做切片,比如按段落、按标题层级切,而不是无脑按字符数切。
1.3 部署环境的准备清单
动手之前先把环境列清楚,避免装到一半发现版本冲突。我这次用的是Ubuntu 22.04,Python 3.10,显卡是RTX 3090,驱动CUDA 11.8。如果你没有独立显卡,CPU跑BGE-M3也能跑通,只是首轮编码会慢一些,后面FAISS检索本来就不吃GPU。
需要提前装好的基础件有:Python 3.10+、pip、git、cmake(编译FAISS某些扩展用)、libmagic(Unstructured检测文件类型用)。建议直接用conda建一个独立环境,避免和系统Python打架:
conda create -n agent_rag python=3.10 conda activate agent_rag提示:别用系统自带的Python环境直接装这些库,Unstructured的依赖链非常长,很容易把系统环境搞坏。独立环境是底线,别贪方便。
2. Unstructured安装与文档解析实操
2.1 安装Unstructured的方式和坑
Unstructured的安装分两种:基础版和全功能版。基础版只支持纯文本和简单的HTML解析,如果你想解析PDF、Word、图片,必须装全功能扩展。刚开始我只装了基础版,结果partition_pdf直接报错,提示缺少detectron2和tesseract,折腾了好一阵子。
推荐直接用官方要求的全量安装方式:
pip install "unstructured[all-docs]"这一个命令会把检测文档类型的libmagic、处理PDF的pdf2image和detectron2、OCR用的tesseract等一并装好。但注意,detectron2是个难啃的硬骨头,pip直接装经常挂,尤其在Windows上。如果你在Linux上装,需要提前确认有没有编译好的wheel,没有的话就用官方推荐的源码编译方式,提前装好torch再编译detectron2会顺畅很多。
注意:Unstructured的依赖里涉及detectron2时,花的时间最多,耐心是关键。如果你不需要解析带复杂版式的PDF,可以跳过detectron2,用基础版配合tesseract做OCR也能凑合。
2.2 用partition函数做文档分区
安装完成后,直接用partition系列函数就能干活。以最常用的PDF为例:
from unstructured.partition.pdf import partition_pdf elements = partition_pdf( "example.pdf", strategy="hi_res", extract_images_in_pdf=True, infer_table_structure=True, )这个函数返回的不是纯文本列表,而是一个个Element对象,每个Element都带着类型信息,比如Title、NarrativeText、Table、ListItem等。为什么要保留类型?因为后续切分文本块时,可以根据类型做不同的处理——标题可以单独作为索引块,表格可以转成HTML或Markdown格式,正文段落按语义合并,这样向量化后的效果会比无脑切字符好得多。
strategy参数值得单独说一下。默认的auto策略会根据文档类型自动选择解析方式,但在表格多、版式复杂的PDF上,我推荐直接用hi_res策略,它能调用深度学习模型识别文档结构,准确率高很多,代价是解析耗时明显增加。实际用下来,一份30页的PDF,hi_res策略耗时是auto策略的5倍左右,但提取的标题层级和表格结构确实更完整。
2.3 表格提取与文本清洗
PDF里的表格是文档解析的重灾区。直接用文本提取方式读表格,往往会把表格内容挤成一坨,完全没有行列概念。Unstructured的infer_table_structure=True会调用表格结构识别模型,把表格输出为HTML格式的Element。
拿到Element之后,需要把表格块单独处理。我的做法是将表格转成Markdown格式,因为BGE-M3在Markdown格式下的表格语义理解效果更好。转换逻辑如下:
from unstructured.staging.base import elements_to_json # 提取所有文本类元素 for el in elements: if el.category == "Table": table_html = el.metadata.text_as_html # 将HTML表格转成Markdown # 这里可以用html2markdown之类的库做个转换文本清洗这块,我发现最关键的是去掉页眉页脚、页码、以及PDF里经常出现的重复水印。这些噪声如果不清除,编码进向量以后,检索时特别容易干扰相关性判断。Unstructured本身不做这个,需要自己在拿到Element列表之后加一步过滤,把文本长度过短、以及在每页都重复出现的片段直接丢掉。
3. FAISS部署:从安装到构建向量索引
3.1 FAISS的安装与选择
FAISS的安装相对简单,没有Unstructured那么麻烦。CPU版本和GPU版本需要分开装:
# CPU版 pip install faiss-cpu # GPU版(需要先有CUDA) pip install faiss-gpu需要注意的是,faiss-gpu的安装包版本要和你的CUDA版本匹配,否则import的时候会报libcudart.so找不到之类的错误。我建议一开始就用CPU版调试逻辑,等全部流程跑通之后再换GPU版加速索引构建。因为FAISS在构建千万级以下的索引时,CPU版也就几秒钟的事,完全够用。
3.2 索引类型选型:Flat、IVF还是HNSW
FAISS里的索引类型非常多,但实际做RAG场景,用到最多的就三个:IndexFlatIP、IndexIVFFlat、IndexHNSWFlat。
IndexFlatIP是暴力检索,原理是拿Query向量和库里的每个向量做内积计算,取TopK。优点是召回率绝对高,缺点也很明显——数据量大了以后内存和耗时线性增长。百万级向量以内,这个索引完全能扛住。
IndexIVFFlat是倒排索引的暴力版,先把向量空间聚类成nlist个桶,查询时只搜最近的几个桶,大幅减少计算量。缺点是存在召回损失,尤其当nlist设置不合理时,效果下降明显。
IndexHNSWFlat用的是HNSW图算法,这几年做RAG最常用。它在召回率和查询速度之间平衡得最好,内存占用比IVF稍大,但索引质量更高。我这次用的是IndexHNSWFlat。
import faiss dim = 1024 # BGE-M3的向量维度 index = faiss.IndexHNSWFlat(dim, 32) # M=32这里的M参数是HNSW算法的核心,表示每个节点的最大连接数。M越大,图越稠密,召回率越高,但内存占用和构建耗时也越大。实测下来M=32对于BGE-M3的1024维向量已经够用,再往上提升不明显,反而内存涨得快。
3.3 索引构建的完整流程与内存计算
把文本向量灌进FAISS之前,有一个容易忽略的操作:要确认向量的数据类型。FAISS默认使用float32存储,而BGE-M3输出的向量也是float32,所以可以直接add,不需要额外转换。
import numpy as np # embeddings: List[np.ndarray],每个元素是1024维的float32数组 embedding_matrix = np.vstack(all_embeddings).astype('float32') index.add(embedding_matrix)内存方面有个公式可以先算一下:总字节数 ≈ 4 × 维度 × 向量条数。如果是100万条BGE-M3向量(1024维),大约需要4GB内存。这个数字直接决定了你要不要考虑用IVFPQ这种压缩索引。我做的是几万条文档块级别的向量,4GB完全没压力,所以我直接用HNSWFlat,不去折腾量化。
保存和加载索引也很关键,直接调接口就行:
faiss.write_index(index, "doc_index.faiss") # 加载 index = faiss.read_index("doc_index.faiss")提示:FAISS索引文件和向量的元数据(比如对应的是哪段文本)需要分开保存。FAISS只管向量,不管文本内容,所以要把“向量→文本块”的映射关系单独存一份,可以用JSON或者SQLite,检索时根据FAISS返回的下标去查原文。
4. BGE-M3本地部署与向量化
4.1 为什么选择BGE-M3而不是其他Embedding模型
Embedding模型选择这事,团队里争论过好几次。有人用OpenAI的text-embedding-3-small,方便是方便,但数据出了内网,合规上过不去。也有人试过别的开源中文模型,中文还行,一遇到中英混合的内容就拉胯。
BGE-M3最打动我的有两个点:一是支持8192 token的长文档,不用分段就能编码,这对“整段合同条款”“整页技术文档”这类场景太友好了;二是它同时支持稠密检索、稀疏检索和多向量检索三种方式,后续如果想升级成混合检索,不需要换模型,一套向量走天下。
唯一需要注意的是,BGE-M3对中文查询有一个官方建议:在查询语句前加上“为这个句子生成表示以用于检索相关文章:”这串指令,能提升检索效果。实测确实有效,加了以后Top1命中率能提升几个百分点。
4.2 下载模型与本地加载
BGE-M3的模型下载,按照官方仓库的方式即可,从Hugging Face或ModelScope下载权重。权重文件不算小,大约2.2GB左右,建议提前下载到本地,后续加载不走网络,避免环境受限。下载完目录结构大概是:
bge-m3/ ├── config.json ├── model.safetensors ├── tokenizer.json ├── tokenizer_config.json └── ...加载方式我用的是sentence-transformers库,代码量最少,效果也稳定:
pip install sentence-transformersfrom sentence_transformers import SentenceTransformer model = SentenceTransformer("bge-m3目录路径", device="cuda")如果你是纯CPU环境,把device改成"cpu"就行,只是编码速度会慢不少。实测3090显卡上,BGE-M3编码1000个文本块大约需要15秒,CPU模式下可能需要两分钟。如果你的文档集达到百万级,建议直接用GPU编码,否则构建索引的时间会很煎熬。
4.3 三种检索模式与FAISS的适配
BGE-M3最强的地方在于它一个模型出了三种向量:Dense(稠密)、Sparse(稀疏)、ColBERT(多向量)。对应到FAISS上,Dense向量直接进FAISS做ANN检索就行,Sparse向量和ColBERT向量则需要配合别的检索组件。
我这次的方案只用了Dense向量,因为和FAISS的配合最成熟,能直接跑通整个RAG链路。Sparse和ColBERT虽然理论上召回效果上限更高,但需要引入Elasticsearch或专门的延迟交互模块,复杂度至少翻一倍。建议先跑通Dense这条线,把Agent框架整体工作流调顺了,再回头升级混合检索。
5. 联调过程中的常见问题与排查记录
5.1 环境与依赖报错速查表
实际操作中遇到的坑比预想中多,我把典型的几类问题整理出来了,遇到相同报错的直接对照排查:
| 问题现象 | 可能原因 | 解决办法 |
|---|---|---|
| import unstructured报错找不到magic | 缺少libmagic系统库 | sudo apt install libmagic-dev |
| partition_pdf提示detectron2缺失 | 全量依赖没装全 | pip install "unstructured[all-docs]"重装 |
| FAISS读取索引时维度报错 | 索引维度和Query维度不一致 | 确认建索引和编码都用BGE-M3的1024维 |
| BGE-M3编码特别慢 | 设备选择错误,跑在CPU上 | 检查device参数,显存不够就压缩batch_size |
| 检索结果总是返回空集合 | HNSW的efSearch设置太小 | 调大index.hnsw.efSearch参数,通常设为128或256 |
| 中文检索效果差 | 没有加BGE-M3官方查询指令前缀 | 在查询语句前拼上指定prompt |
FAISS的efSearch参数这里多讲一句。HNSW索引在查询阶段有一个动态候选集大小叫efSearch,默认值是16,对于小规模索引问题不大,但如果你有几十万条以上的向量,efSearch=16会明显影响召回率。我一般调到128,速度影响很小,但效果提升肉眼可见。
5.2 检索质量不理想的调优经验
有一次检索测试中,我拿一份合同PDF里的一句话当Query,Top5返回的结果里只有一条沾边,其他全是无关段落。排查了一圈,问题出在文档分块策略上——Unstructured的hi_res把很大一段文字识别成了Title,导致向量化时把包含标题和正文的大块混在一起,语义混乱。
后来我改了策略:Title类型的Element单独作为一个小块,后续跟着的正文段落再单独成块。这样一条文档能切出多个语义集中的小块,检索命中率立刻提上来了。另外,表格块建议直接单独处理,不要让表格和正文混在一个块里,否则表格的结构信息会被正文稀释。
还有一个从实际使用中总结出来的技巧:如果检索场景是按“问题找答案”,建议把Query过一遍同义改写再编码,让BGE-M3理解得更准确一些。这次虽然没在正式流程里加同义改写模块,但在手动测试时明显感觉到,原样Query和改写Query编码后的检索结果质量差距挺大。
5.3 部署与模型文件管理经验
模型文件和索引文件最好统一管理。我是把模型和索引都放在项目目录下的models/和indexes/里,配合一个版本号,方便回滚。BGE-M3重下了新版权重时,需要重新构建一遍FAISS索引,这个流程要自动化。
你可以写一个简单的shell脚本,每次更新完模型,按顺序跑一遍“文档解析→向量化→索引构建”三步,生成新版本的索引文件。不要图省事只在命令行手敲,后面模型升级或者文档库更新时,你一定会感谢当初写好的这个脚本。
6. 从单点组件到Agent记忆系统的串联思考
三件套各自部署完成后,还剩最后一层“胶水”:怎么把Unstructured解析出的文本块、BGE-M3的向量、FAISS的索引,和上层Agent框架的编排逻辑无缝衔接。我的做法是把这三步封装成三个独立服务,中间用消息队列串起来。
文档解析服务收到新文档后,解析出文本块,发给向量化服务;向量化服务调用BGE-M3生成向量,写入FAISS索引;索引写入成功后,再把对应的元数据(文档ID、文本块ID、摘要等)保存到关系型数据库里。Agent查询时,只用调用一个检索接口,输入Query,返回TopK的文本块和文档来源即可。
这样拆的好处是每一环都可以独立升级。Unstructured版本更新了,或者FAISS换成了别的向量数据库,都不用动其他模块。而且后续想支持增量索引,只要在文档解析服务里记录好每个文本块的唯一ID,FAISS侧配合IDMap类型的索引,就能做到新增、删除、更新而不重建全量索引。
这套链路跑通之后,其实你已经有了一个非常通用的Agent记忆基础设施。不只是Agent,任何一个需要“理解文档、按语义检索”的系统,都可以复用这套底座。如果在生产环境要进一步提高并发能力,FAISS可以换成分布式版本,BGE-M3也可以部署成独立的推理服务,这些都是后话了。先把单机版跑稳,比什么都实在。