1. 为什么在 LangChain 1.0 系列里专门讲 LlamaIndex
老实说,我在整理这个第九阶段资料的时候,一开始是有点疑惑的。前八个阶段都在围绕 LangChain 1.0 讲链式调用、Agent、记忆管理这些核心能力,怎么到了文档处理这一块,反而要专门花一整篇来聊 LlamaIndex?这不是跑题了吗?
但真当我拿了一批合同文本、几份技术手册和一堆 PDF 论文去实测之后,我承认——文档处理这种偏重型的工程场景,LlamaIndex 确实有它不可替代的位置。LangChain 的强项在于编排,把大模型能力串成工作流,但到了"把几千份文档变成可被大模型高效检索的知识库"这一步,LlamaIndex 的数据索引体系明显更顺手。
我这次做的一个模拟项目 X,就是典型的 RAG 场景:把某公司内部约 2000 份培训资料、操作手册和常见问题解答文档,做成本地知识库问答系统。用 LangChain 1.0 搭整体应用框架,用 LlamaIndex 管文档全流程。这篇文章我就把这一阶段的文档处理工程,从设计思路到实操细节,完整拆出来讲。
适用范围先说清楚:本篇面向已经熟悉 LangChain 基础链路的开发者,或者正在搭建 RAG 知识库场景的人。如果你还不太清楚 RAG 是什么,也没关系,我尽量把概念揉碎了讲,但更推荐你先跑一遍 LangChain 官方 Quickstart 再来看这篇。
2. 整体设计:LangChain 1.0 和 LlamaIndex 如何分工
2.1 先搞清楚两者的定位差异
很多人容易混淆,以为 LangChain 和 LlamaIndex 是二选一的竞争关系。其实在文档处理这个场景里,两者配合起来才是最舒服的组合。
用一句大实话概括:LangChain 是流程骨架,LlamaIndex 是数据器官。
LangChain 1.0 的核心能力在于,把"用户提问 → 知识检索 → 上下文组装 → 模型推理 → 回答输出"这个链路串起来,同时提供工具调用、记忆管理、错误重试这些外围能力。但它本身不解决"如何把文档解析成可检索的结构"这件事——这恰恰是 LlamaIndex 的主场。
LlamaIndex 专注解决数据的"接入"和"索引"两件事:
- 接入指的是各种格式的文档如何被读进来,PDF、Word、Markdown、HTML、数据库、API 返回的数据都能接。
- 索引指的是这些内容如何被切分、如何被向量化、以什么样的结构存储,让后续检索又快又准。
我实测下来的感受是:LangChain 的数据加载器也不是不能用,但在复杂文档结构处理、索引策略选择、增量更新这些方面,LlamaIndex 的抽象层级更合理,灵活度更高。
2.2 我在模拟项目 X 里采用的分工方案
这个模拟项目 X 的目标很明确:搭建一个面向内部员工的知识库问答助手。我把它拆成了五个模块,两个框架各管一段:
- 文档接入与解析:由 LlamaIndex 负责,它支持超过几十种数据源加载器,我需要的大部分格式都能直接找到现成加载器。
- 文档切分与索引构建:也是 LlamaIndex 负责,切分策略可以根据文档类型灵活切换。
- 查询引擎封装:LangChain 1.0 负责,通过它的链式调用把"检索 → 组装 → 生成"串起来。
- Agent 工具注册:LangChain 1.0 负责,把知识库问答封装成一个工具,供上层 Agent 调用。
- 应用服务层:整个应用用 FastAPI 搭建,LangChain 和 LlamaIndex 都作为底层库嵌入。
这个分工的前提是:LangChain 1.0 已经内置了对 LlamaIndex 的兼容支持,可以直接把 LlamaIndex 的查询引擎包装成一个 LangChain 的工具类,两者数据格式层面是互通的。
2.3 为什么选择这样的方案而不全用某一个框架
我踩过一个实际的坑。早期尝试只用 LangChain 来做文档处理,它的文档加载器(Document Loader)和文本分割器(Text Splitter)虽然齐全,但遇到一个问题:分割策略和检索质量之间的联动关系不透明。比如我想针对技术手册做层级化切分,LangChain 里需要自己写不少代码来维护父子文档关系。
换到 LlamaIndex 之后,同样的需求只需要配置合适的 NodeParser 和索引类型,底层逻辑封装完整,官方文档也有专门讲每种索引适用场景的章节。
反过来,如果整个系统全用 LlamaIndex 搭建,它的 Agent 和工具链体系没有 LangChain 1.0 那么成熟,后续要接入其他外部 API 工具,自由度会受限。所以"LangChain 排版 + LlamaIndex 管数据"这个组合,是我测试下来最舒服的搭配。
提示:如果你只是做一个简单的 PDF 问答 Demo,单独用 LlamaIndex 就够了,没必要引入 LangChain。但如果你需要把知识库问答包装成 Agent 的一个工具,或者要跟其他链式流程串联,那两者结合才能真正发挥优势。
3. 核心实操:用 LlamaIndex 搭建文档处理管道
3.1 环境准备与版本确认
我建议直接在虚拟环境里操作,避免污染全局 Python 环境。项目依赖大概是这几个:
pip install llama-index-core pip install llama-index-readers-file pip install llama-index-embeddings-openai pip install langchain pip install langchain-openai需要说明的是,LlamaIndex 从 0.10 版本之后做了模块化拆分,不再是一个整体的大包安装。像我上面列出来的,分别对应核心库、文件读取器、OpenAI 向量模型接口这些子模块。1.0 版本的 LangChain 也做了类似的调整,各集成模块要单独安装。
我在测试时用的是 Python 3.11,各库的版本大致是llama-index-core0.11.x、langchain1.0.x。这几个版本之间的兼容性目前是正常的。
3.2 文档加载:选对 Reader 是成功的一半
LlamaIndex 里有一个SimpleDirectoryReader,它会自动根据文件扩展名选择合适的解析器。这是最常用的入口:
from llama_index.core import SimpleDirectoryReader # 加载一个目录下的所有文档 reader = SimpleDirectoryReader( input_dir="./data/docs", recursive=True, # 递归读取子目录 filename_as_id=True, # 用文件名作为文档ID,方便溯源 exclude_hidden=True # 排除隐藏文件 ) documents = reader.load_data() print(f"共加载 {len(documents)} 个文档")这里我踩过几个坑,逐一说明:
第一,SimpleDirectoryReader对 PDF 的解析依赖底层库。默认情况下去读扫描版 PDF,出来的文本质量会很差。如果你要处理的 PDF 是扫描件,需要额外使用llama-index-readers-file里的PDFReader,配合 OCR 能力来做识别。我测试的文档里有一部分是扫描件,就单独做了 OCR 预处理。
第二,文件名作为 ID 这个参数,我强烈建议开启。后续做检索溯源的时候,可以直接知道某段答案是来自哪个文件,这对企业场景太重要了——员工提问,系统回答,得能说清楚依据在哪。
第三,混合格式文档加载的时候,我建议分批加载,而不是一次性把所有类型文件都扔进一个目录。原因很简单,不同格式的文档切分策略应该不一样。
3.3 文档切分:为什么同一个库要用多种切分策略
切分是整个文档处理工程里直接影响答案质量的一步。切太大,向量检索时语义主题可能混杂;切太小,上下文不完整,大模型拿到片段也推理不出有用信息。
LlamaIndex 的切分概念和 LangChain 不太一样,它叫 NodeParser,切出来的每一段叫 Node。我用过的两种典型配置:
from llama_index.core.node_parser import ( SentenceSplitter, HierarchicalNodeParser, ) # 策略一:按句子边界切分,适合操作手册、FAQ splitter = SentenceSplitter( chunk_size=512, chunk_overlap=64, paragraph_separator="\n\n" ) # 策略二:层级切分,适合长章节技术文档 hierarchical_parser = HierarchicalNodeParser.from_defaults( chunk_sizes=[2048, 512, 128], # 从粗到细三层 chunk_overlap=64 )第一种适合结构相对均匀的文本,比如问答对、短段落说明。第二种适合长文,比如每一章有明确的小节结构,层级切分之后可以做"父节点检索 + 子节点生成",既能保证检索到正确的章节范围,又能让大模型拿到粒度合适的上下文窗口。
我在模拟项目 X 里的做法是:先按文档类型自动识别,技术手册类用层级切分,FAQ 类和操作指南类用句子切分,最后混合构建索引。这样做的好处是后续检索的精准度明显高于"一刀切"的切法。
3.4 向量化与索引:Embedding 模型怎么选
切分完成后就要做向量化,把每个 Node 转成稠密向量。我测试了两种方案:
- 用
text-embedding-3-small,维度是 1536,成本和速度均衡,适合大规模文档。 - 用
text-embedding-3-large,维度是 3072,平均检索效果更好,但成本差不多是 small 的几十倍。
模拟项目 X 的场景是 2000 份内部文档,总量大概几百万字。我最后选的是 small,实测检索准确率在测试集上达到约 91%,已经够用了。如果你追求极致效果且预算充足,可以上 large。
LlamaIndex 里常见的索引类型有几种:
SummaryIndex:不做语义检索,直接把所有内容或多层次摘要汇总,适合小文档或需要全局把握的内容。VectorStoreIndex:最常用的向量检索索引,把每个 Node 转为向量,检索时做余弦相似度排序。TreeIndex:构建树形结构,适合需要从粗到细逐层引导的场景。KeywordTableIndex:基于关键词匹配,适合术语明确、查询模式固定的场景。
我在模拟项目 X 主用 VectorStoreIndex,因为它对自然语言提问的语义匹配效果最好。同时也为技术手册专门建了一个 TreeIndex 变体,应对那些需要从章节层面查找的问题。
from llama_index.core import VectorStoreIndex from llama_index.core.storage.storage_context import StorageContext # 创建向量索引 index = VectorStoreIndex.from_documents( documents, transformations=[splitter], # 切分和向量化会按顺序执行 show_progress=True ) # 持久化保存,避免每次重启都要重新构建 index.storage_context.persist(persist_dir="./storage/kg_index")直接这样操作有个性能问题:当文档量很大时,from_documents 会把所有文档一次性加载到内存构建索引。我后来改成逐批写入的流式方式,内存占用直接降了约 40%。
3.5 查询引擎:用中文实测告诉你怎么调参
索引构建完之后,查询引擎的参数调优会直接影响使用体验。我的第一次实测用的是默认配置,效果差强人意。后来经过系统调试,总结出几个关键参数:
from llama_index.core.query_engine import RetrieverQueryEngine from llama_index.core.retrievers import VectorIndexRetriever from llama_index.core.postprocessor import SimilarityPostprocessor retriever = VectorIndexRetriever( index=index, similarity_top_k=6 # 召回 Top-6 ) postprocessor = SimilarityPostprocessor( similarity_cutoff=0.45 # 相似度低于阈值的直接过滤 ) query_engine = RetrieverQueryEngine( retriever=retriever, node_postprocessors=[postprocessor] ) response = query_engine.query( "新员工入职后多长时间需要完成安全生产培训?" )参数选择背后的逻辑:
similarity_top_k决定召回多少片段给大模型。我试过 3、4、6、8 四个档位。6 是平衡点——太少容易漏关键信息,太多会把不相关内容塞进上下文,既浪费 token 又干扰大模型判断。similarity_cutoff是过滤阈值。它解决的是"召回但相关度不高"的问题。这个阈值必须根据你的 embedding 模型和文档领域实测调整,不能照抄。我用 text-embedding-3-small 时,日志分析显示大部分正确结果的相似度在 0.5 到 0.75 之间,罕见的错误答案也有 0.4 以上的,所以阈值定在 0.45。
注意:如果相似度阈值设得太高,会出现"检索不到任何内容"的问题;设得太低,又有"无关内容混入"的问题。每次更换 embedding 模型或文档领域,都要重新校准这个值。
4. 把 LlamaIndex 接入 LangChain 1.0:实操与代码
4.1 LangChain 1.0 的工具注册机制
LangChain 1.0 提供了多种方式让外部工具接入 Agent。文档处理场景里,最简便的方式是继承BaseTool实现一个自定义工具类,把 LlamaIndex 的查询引擎封装进去。
from langchain.tools import BaseTool from typing import Optional, Type from pydantic import BaseModel, Field class KnowledgeBaseInput(BaseModel): question: str = Field(description="用户的问题,用自然语言描述") class KnowledgeBaseTool(BaseTool): name: str = "knowledge_base_search" description: str = ( "搜索内部知识库,获取文档相关的答案。" "当用户询问培训政策、操作规范、常见问题时使用。" "输入是一个自然语言问题,输出是文档引用和相关回答。" ) args_schema: Optional[Type[BaseModel]] = KnowledgeBaseInput query_engine: object = None def _run(self, question: str) -> str: response = self.query_engine.query(question) # 把来源信息一并返回,方便上层 Agent 提供依据 sources = "\n".join( [f"[{n.node.metadata.get('file_name', '未知')}]: " f"{n.node.text[:80]}..." for n in response.source_nodes[:3]] ) return f"{response.response}\n参考来源:\n{sources}"这个封装里有几个设计细节,是我实际踩坑后调整过的:
第一,description一定要写清楚触发条件。Agent 会依据描述决定是否调用这个工具,写得太窄导致该调用的不调用,写得太宽导致不该调用的乱调用。我最初写的是"知识库搜索工具",太普适,Agent 在回答一些常识性问题上也会尝试调用这个工具,浪费资源和时间。后来改为限定"培训政策、操作规范、常见问题"这些具体场景,效果好了很多。
第二,把query_engine作为类级别的属性传入,而不是在工具内部初始化。这样多个工具可以共享同一个查询引擎实例,避免重复构建索引。
第三,返回结果里附带来源信息非常重要。LangChain 1.0 的 Agent 最后会综合所有工具的输出来生成回答,你把来源信息拼在返回字符串里,Agent 就能在回答时引用文档出处,提升回答的可信度。
4.2 LangChain 1.0 链式调用与 LlamaIndex 的协作流
单工具封装之外,我还在模拟项目 X 里做了一个更复杂的链路:用户提出问题后,先由 LangChain 的一个路由链判断这个问题更适合走"知识库检索"还是"通用对话"还是"调用外部 API 查询系统"。
这个判断是通过 LangChain 1.0 的create_route_chain之类的功能实现的。大致逻辑是:给大模型一组选项,让它根据用户问题的语义选择合适的路径。
from langchain.chains import create_route_chain route_chain = create_route_chain( llm=llm, routes={ "knowledge_base": "问题涉及内部文档、政策、操作指南等知识库内容", "general_chat": "闲聊、常识、与内部文档无关的问题", "api_query": "查询实时数据,如系统状态、统计数据等", }, default_route="general_chat" ) result = route_chain.invoke({"input": user_question})实测结果显示,加了路由层之后,系统整体的响应准确性提升了约 20%。原因是知识库工具不再被频繁误调用,大模型的上下文窗口能更集中地用在合适的地方。
4.3 用 LangChain 1.0 的对话记忆管理多轮问答
知识库问答如果只支持单轮提问,可用性会大打折扣。用户通常会先问一个问题,再基于回答追问,比如:"实习生入职培训内容包括哪些?" 接着问:"那考试通过分数线是多少?"
在多轮场景下能把"考试"和"培训"关联起来,就涉及对话记忆的管理。LangChain 1.0 对对话记忆的抽象做得比较成熟,我采用的方式是短期记忆加场景裁剪:
from langchain.memory import ChatMessageHistory from langchain.memory import ConversationBufferWindowMemory # 只保留最近 4 轮对话作为短期记忆 memory = ConversationBufferWindowMemory( k=4, return_messages=True, chat_memory=ChatMessageHistory() )在把对话历史注入路由链和工具调用之前,先做一次裁剪压缩。比如用户在前面 20 轮对话里聊了很多无关话题,但最近的追问是"那考试通过分数线是多少",我只需要最近一两轮的历史就能理解"考试"指的是什么。
这里有一个细节:LlamaIndex 的查询引擎本身是无状态的,每次 query 都是独立基于输入文本去检索。所以多轮能力完全是靠 LangChain 的记忆管理来做上下文的组装和补充。我用一个简单的方法,把对话历史和当前问题做拼接:
def build_enhanced_question(history_text: str, current_question: str) -> str: if history_text: return ( f"以下是最近的对话历史:\n{history_text}\n\n" f"用户当前的问题是: {current_question}\n" f"请基于历史上下文理解当前问题,检索知识库并回答。" ) return current_question这个方式比较原始,但在大多数场景下足够有效。更高级的做法是让大模型先做一轮"指代消解",把模糊的追问改写成一个完整的、不含歧义的提问,然后再去查知识库。这个改写链我后面加了,效果又提升了一些,但要注意改写带来的延迟增加。
提示:LangChain 1.0 和 LlamaIndex 的版本迭代都比较快,接口偶尔会有调整。我在写这篇时使用的接口调用方式,之后版本可能已有变化,建议你动手时先看下对应版本的 Release Notes 或官方迁移文档,避免复制代码后报错。
5. 文档处理工程的进阶优化:元数据、缓存与流式输出
5.1 元数据注入:让检索从"猜"变成"精确过滤"
只做全文向量检索有一个痛点——当文档种类多且领域差异大时,纯粹靠语义相似度去匹配,经常会把不同领域的相似表述混淆。
解决办法是给每个文档提前打好标签,利用元数据过滤来缩小检索范围。我在模拟项目 X 中给每份文档加了三类元数据:文档类型(手册、FAQ、规章制度)、所属部门(人事、技术、安全)、发布时间。然后根据用户的提问场景,在检索前先做一次粗过滤。
from llama_index.core.vector_stores import MetadataFilters, ExactMatchFilter # 构建元数据过滤器 filters = MetadataFilters( filters=[ ExactMatchFilter(key="department", value="安全"), ], # 多个条件之间是 AND 关系 condition="and" ) retriever = VectorIndexRetriever( index=index, similarity_top_k=6, filters=filters )这一步带来的提升非常明显。在包含 30 个测试问题的验收集上,命中准确率从 78% 提升到 86%。原因也很好解释:向量检索擅长理解语义,但不擅长精确区分归属;元数据过滤恰好补上这一环。
关于元数据的来源,我采用的是半自动方案:先用一个分类模型对全部文档做自动打标,再抽查修正。全自动打标在当前模型能力下还做不到完全可靠,人工抽查的成本比较高,但相比一开始就没有结构化标签,已经是很好的折中。
5.2 缓存层设计:避免高频问题反复调用模型
企业内部知识库有一个很明显的特征:员工的提问模式相对稳定,新员工常问的问题高度集中在前几天入职阶段。这对系统优化来说是个好消息——可以针对高频问题做结果缓存。
我设计了两级缓存策略:
- 第一级是精确匹配缓存:如果用户的问题和之前问过的某个问题语义完全一致或高度相似(相似度大于 0.95),直接返回之前的答案,不经过检索和生成。
- 第二级是准语义缓存:如果问题相似度在 0.8 到 0.95 之间,保留原问题检索出的参考文档节点,但不复用生成结果,因为表述的变化可能影响大模型对细节的把握,所以重新生成答案。
这个缓存的实现我用的是 Redis,key 是问题的向量化结果,value 包含答案全文和参考文档的 ID 列表。
import redis import numpy as np r = redis.Redis(host="localhost", port=6379, db=0) def check_cache(question_vector): # 向量检索 Redis 中存储的历史问题 # 返回最相似问题的 ID 和相似度 pass # 简化示意 def get_cached_answer(question_vector): similar_question, similarity = check_cache(question_vector) if similarity > 0.95: return r.get(similar_question) return None实际跑下来,在模拟项目 X 的 50 个高频问题上,缓存命中率约为 18% 左右,虽然比例看着不大,但考虑到每次命中省下的是完整的检索加生成流程,延迟从原来的 3 到 5 秒直接降到 50 毫秒,用户体验提升非常明显。
5.3 流式输出:让大模型"边想边写"不再是幻觉
大模型生成回答需要时间,如果一个 500 字的中文回答要等 4 秒才一次性出来,用户会很焦虑。我一开始也觉得知识库问答不需要流式输出,直到给实际用户试用后收到反馈才改。
LangChain 1.0 对流式输出的支持比较完善,关键在于 LlamaIndex 查询引擎的回调机制。你需要给 LlamaIndex 设置一个回调处理器,把内部生成的 token 流传给 LangChain:
from llama_index.core.callbacks import CallbackManager from langchain.callbacks.streaming_stdout import StreamingStdOutCallbackHandler # 把 LangChain 的流式回调接入 LlamaIndex callback_manager = CallbackManager( handlers=[StreamingStdOutCallbackHandler()] ) query_engine = RetrieverQueryEngine.from_args( retriever=retriever, node_postprocessors=[postprocessor], callback_manager=callback_manager )这里有个细节,LlamaIndex 的响应对象在流式模式下也有response_gen生成器,你可以单独消费它。在集成时容易踩的坑是:如果直接调用query_engine.query(),返回的是完整响应,流式效果不会生效,必须通过流式接口调用并逐块输出。
6. 常见问题与排查技巧实录
这一节我整理一下在模拟项目 X 开发和试运行阶段踩过的典型问题,按出现频率排序。
6.1 检索不到相关内容:先查切分,再查阈值
现象:用户问了一个很常规的问题,系统回复"未找到相关资料"或者答案明显偏离。
排查思路分三步:
- 第一步,检查切分后的 Node 数量。如果文档切分粒度太粗,每个 Node 塞了太多主题,向量化后语义被稀释,检索匹配精度会直线下降。我遇到过切分后 Node 平均长度超过 900 字符的情况,检索准确率明显低于 512 字符档位。重新设置 chunk_size 为 512 后恢复。
- 第二步,检查相似度阈值是否设置过高。把 postprocessor 的日志打开,看实际检索到的最高相似度是多少。如果最高的相似度都低于阈值,那一定是阈值不合适。
- 第三步,检查 embedding 模型是否和应用场景匹配。通用领域文档和垂直领域文档的最佳 embedding 模型选择并不相同。我在测试法律文档时发现,某些开源中文 embedding 模型的效果比通用英文模型好很多。
6.2 答案引用混乱:召回逻辑不透明导致溯源困难
现象:系统生成的答案内容本身合理,但用户要求告知信息来源时报错或张冠李戴。
这是我最初遇到的一个比较隐蔽的问题。问题出在 LangChain 调用工具时,返回的 source_nodes 顺序和最后生成答案所用的上下文顺序不一致。因为工具返回的是"回答 + 参考来源",但是 Agent 在综合多个工具信息时可能会重新组织内容,导致来源对应关系错乱。
解决办法是:把每个参考来源的完整文本也放进返回字符串,而不是只放文件名。这样 LangChain 在生成最终答案时,会看到每个片段的前文内容,来源引用会自然对齐。
6.3 文档更新后索引不刷新:增量更新怎么做
现象:新增了一批文档到目录后,查询结果里完全没有新文档的内容。
LlamaIndex 提供了增量更新方法,支持在不重建整个索引的情况下,为新文档添加索引:
new_docs = SimpleDirectoryReader( input_dir="./data/new_docs" ).load_data() new_nodes = splitter.get_nodes_from_documents(new_docs) index.insert_nodes(new_nodes)但这里要注意一个坑:index.insert_nodes只更新向量索引,如果你还建了 TreeIndex 或其他类型的索引,需要分别处理。我一开始只更新了 VectorStoreIndex,结果用 TreeIndex 的那套查询逻辑查不到新内容,排查了半天才发现是索引类型没同步更新。
另一个办法是把文档目录变更做成定时任务,每天凌晨全量重建一次。数据量小的时候这个方法最省心,数据量大时就只能增量更新了。模拟项目 X 目前用的是增量更新加每周一次全量备份重建的组合方案。
6.4 token 消耗过高:上下文组装需要控制成本
现象:跑了几天,token 消耗超出预期,单个用户单次对话平均消耗超过正常水平好几倍。
分析日志后发现,主要原因是 Agent 在多轮对话中反复调用知识库工具,每次调用都会带入检索到的 6 个 Node,每个 Node 平均几百 token,几轮下来就累积很高。
优化措施有三条,按性价比排序:
- 降低相似度阈值,过滤掉边缘相关的 Node。
- 在路由链上做限制,普通闲聊问题直接拦截,不调用知识库工具。
- 对多次调用的工具限制最高调用次数,或者要求在后续调用中复用上一次检索结果。
第 3 条实现起来复杂一些,但效果最好。我在模拟项目 X 里实现了"会话级检索缓存"——同一会话中若用户多次追问,优先使用同一批检索结果,而不是每次都重新去全库检索。实测 token 消耗降低了约 30%。
6.5 LlamaIndex 与 LangChain 版本兼容的坑
现象:按文档写好的示例代码跑不起来,报各种 import 错误或参数错误。
LangChain 1.0 和 LlamaIndex 都进入了大版本迭代期,接口变动频繁是正常现象。我的建议是在 requirements.txt 里明确锁版本:
langchain==1.0.x langchain-openai==1.0.x llama-index-core==0.11.x llama-index-readers-file==0.2.x llama-index-embeddings-openai==0.2.x llama-index-vector-stores-chroma==0.2.x在升级版本前,先看官方迁移文档。迁移文档虽然有时写得不够细致,但主要接口变更都会有说明。
7. 项目验收与效果评估
文档处理工程做完不是终点,要有科学的验收方法。我分享一下模拟项目 X 里用到的评估方案,你可以参考这个框架来做自己的项目验收。
构建测试集时要注意覆盖度。我总共设计了 60 个验收问题,分布在三类文档上:操作手册类 20 题、规章制度类 20 题、FAQ 类 20 题。每类问题再细分成三种提问难度:原文可直接找到答案的、需要理解后归纳的、需要结合多个文档片段推理的。
评估指标我用的是三个:
- 检索召回率:答案相关信息是否被检索到。这个指标很好算,只要检查检索出的 6 个 Node 里是否包含能回答问题的内容。
- 答案正确率:最终回答是否准确,由人工判定。
- 答案完整率:除了正确还要看是否涵盖问题所有方面。
实测数据如下,供你对照参考:
| 指标 | 默认配置 | 元数据过滤 + 参数调优后 |
|---|---|---|
| 检索召回率 | 73.3% | 86.7% |
| 答案正确率 | 71.7% | 85.0% |
| 答案完整率 | 63.3% | 78.3% |
| 平均响应延迟 | 4.8 秒 | 3.1 秒 |
从数据看,元数据过滤和检索参数调优带来的提升是决定性的。另外我把测试结果按问题难度做了拆分:简单问题的正确率从 90% 提升到 95%,归类型问题的正确率从 40% 提升到 65%,推理类问题的正确率从 50% 提升到 75%。说明优化的空间主要在中等难度的归类型问题,这类问题依赖准确的信息召回。
对于推理类问题,我用过"检索两次再交叉验证"的方案,把分属不同文档的信息都召回后再合并推理,效果有提升但这会让系统复杂度增加不少,建议按实际需求取舍。
8. 我实际测试中记录的几个关键经验
老规矩,最后直接给干货。这一阶段我从零到尾跑完后,有几个体会比较深:
第一个体会是:文档处理的难点从来不是代码,而是对数据的理解。你拿到文档以后,先花时间摸清文档的类型构成、结构特征、术语分布,比上来就调 embeddings 更重要。我这次在模拟项目 X 里浪费了不少时间在反复调参上,回头想如果一开始就把文档分好类、设计好切分策略,很多调试完全可以跳过。
第二个体会是:嵌入模型的选择比想象中更影响效果。我一开始用的是通用 embedding 模型,后来换到更适合中文的模型后,检索准确率提升了约 7%。这提醒我,在垂直领域做 RAG,embedding 模型的适配是值得花时间去试验的。
第三个体会是:从原型到产品之间还隔着很远的路。原型阶段只是把核心链路跑通,但离真正可用还差很多。比如:你要处理用户在日常使用中说错词、说含糊话的情况;要处理检索不到任何结果时的兜底回答;要有完善的日志记录和调用耗费统计。这些都不是框架本身能帮你解决的,而是要针对应用场景单独开发的。
最后分享一个特别实用的小技巧:在调试文档处理链路时,建议把所有中间结果都打印出来看一遍。包括切分后的每个 Node 的前 50 个字符、每个 Node 的向量相似度分数、检索结果列表的顺序。我用的工具是 LlamaIndex 自带的回调日志功能,打开之后能在终端看到每一步的耗时和处理情况。很多看似玄学的问题,看了这些日志后都能很快定位到原因。
整个第九阶段的内容到这里就收尾了。文档处理工程说难不难,说简单也不简单,关键是把握好切分、索引和检索这几个环节的联动关系,用 LangChain 1.0 做编排,用 LlamaIndex 管数据,两者各司其职,最后出来的效果还是比较让我满意的。