初步优化检索效果
提高模型给出答案的准确率的方法有两种:增加每次拿到的“参考段落”的数量(增加召回文档切片数量)、将“参考段落”的知识点整理成更清晰的结构(文档内容结构化)。下面先从第一点入手。
让大模型获得更多的参考信息
举个例子:你想了解下 react,知识库中会存在多个叫 react 的名词。如果一次性只召回 2 条,很可能不是你想了解的 react。既然知识库中有 react,你则可以通过增加一次性召回的文档切片的数量,扩大检索范围,提升找到相关信息的概率。
代码如下:
# rag 为自己之前封装的 rag 应用,此处省略了源代码
index = rag.load_index()
query_engine = index.as_query_engine(
streaming=True,
# 一次检索出 5 个文档切片,默认为 2
similarity_top_k=5
)
不过,单纯增加召回的切片数量并不是一个好办法,如果这种方式可以解决问题,那完全可以召回整个知识库。但这样不仅会超出大模型的输入长度限制,过多的无关信息还会降低大模型的回答效率和准确性。因此,我们还需要用其他办法进一步改进 RAG 效果。
给大模型更清晰的参考信息
在实际应用中,文档的组织结构对检索效果有着重要影响。同样的信息,分别在表格中和散乱的文字中查找,显然是在表格中更容易找到。
大模型也是如此。当把原本在表格中的信息转换成普通文本时,虽然信息还在,但是结构性降低了。
重建索引
Markdown 格式是一个很好的选择,因为:
-
结构清晰、层次分明
-
语法简单,易于阅读
-
非常适合 RAG 场景下的文档组织
RAG 应用各个环节的改进策略
文档准备阶段
想象下你是公司某个业务的 oncall 值班人,你会根据用户所提的问题,积累成知识库,共享给其他人员参考。在构建 RAG 应用时,这是非常重要的一个环节。
-
意图空间:把用户提问背后的需求绘制成点,这些点构成了一个用户意图空间。
-
知识空间:沉淀在知识库文档中的知识点,构成了知识空间。这里的知识点,可以是一个段落,也可以是一个章节。
当把意图空间与知识空间投影在一起,会发现两个空间存在交集与差异。这些区域分别对应了后面三个优化策略:
-
重叠区域:
-
即可以依靠知识库的内容来回答用户的问题的部分,这是 RAG 应用效果保障的基础。
-
对于这部分用户意图,你可以通过优化内容质量、优化工程和算法,不断地提高回答质量。
-
-
未被覆盖的意图空间:
-
因为缺乏知识库内容的支撑,大模型容易输出幻觉回答。
-
需要做的是主动补充缺漏的知识,不断跟进用户意图空间的变化。
-
-
未被利用的知识空间:
-
召回不相关知识点可能会干扰大模型的回答。
-
需要优化召回算法避免召回无关内容。还需定期查验知识库,剔除无关内容。
-
在尝试优化工程或算法之前,你应该优先构建一套可以持续收集用户意图的机制。通过系统化采集真实用户需求来完善知识库内容,并邀请对用户意图有深刻理解的领域专家参与评估,形成“数据采集-知识更新-专家验证”的闭环优化流程,保障 RAG 应用的效果。
文档解析与切片阶段
在 RAG 应用拿到文档后,会对文档进行解析、切片。
而大模型在回答问题时拿到的文档切片如果缺少相关信息,会回答不准确。如果拿到的文档切片非关联信息过多,也会影响回答质量。
因此,在对文档进行解析、切片时,需保证最终切片信息的完整,但不要包含太多干扰信息。
问题分类及改进
| 阶段 | 问题类型 | 改进策略 |
|---|---|---|
| 文档解析 | 文档类型不统一,部分格式的文档不支持解析 | 转换文档格式或开发对应格式的解析器 |
| 文档格式支持解析,但其中的内容无法被解析。如文档中的流程图无法被解析 | 改进解析器 | |
| 文档切片 | 文档中有很多主题接近的内容。如需求分析、开发、测试阶段都有注意事项 | 扩写文档标题及子标题。如“注意事项”改为“需求分析-注意事项”、“需求开发-注意事项”建立文档元数据(打标) |
| 文档切片长度过大,引入过多干扰项 | 减少切片长度,或结合业务开发选择更合适的切片策略 | |
| 文档切片长度过短,有效信息被截断 | 扩大切片长度,或结合业务开发选择更合适的切片策略 |
切片方法
Token 切片
适合对 token 数量有严格要求的场景,比如使用上下文长度较小的模型时。
# chunk_size:每个切片的最大 token 数(不是字符数)。文本会按 tokenizer 计数,满 100 个 token 切一刀。
# chunk_overlap:相邻两个切片之间重叠的 token 数。即下一个 chunk 的开头会包含上一个 chunk 末尾的 20 个 token,用于保留上下文连续性,避免在切割边界丢失语义。
token_splitter = TokenTextSplitter(
chunk_size=100,
chunk_overlap=20
)
句子切片
这是默认的切片策略,会保持句子的完整性。
sentence_splitter = SentenceSplitter(
chunk_size=512,
chunk_overlap=50
)
句子窗口切片
每个切片都包含周围的句子作为上下文窗口。下面是一个使用句子窗口切片(window_size=1)进行切片的效果:
示例文本:LlamaIndex是一个强大的RAG框架。它提供了多种文档处理方式。用户可以根据需要选择合适的方法。
-
切片1: LlamaIndex是一个强大的RAG框架。上下文:它提供了多种文档处理方式。
-
切片2: 它提供了多种文档处理方式。上下文:LlamaIndex是一个强大的RAG框架。用户可以根据需要选择合适的方法。
-
切片3: 用户可以根据需要选择合适的方法。上下文:它提供了多种文档处理方式。
sentence_window_splitter = SentenceWindowNodeParser.from_defaults(
window_size=3,
window_metadata_key="window",
original_text_metadata_key="original_text"
)
# 注意:句子窗口切片需要特殊的后处理器,node_postprocessors **后处理器管道**,检索之后、送 LLM 之前对 node 做加工
query_engine = index.as_query_engine(
similarity_top_k=5,
streaming=True,
node_postprocessors=[MetadataReplacementPostProcessor(target_metadata_key="window")]
)
语义切片
根据语义相关性自适应的选择切片点。
示例文本: "LlamaIndex是一个强大的RAG框架。它提供了多种文档处理方式。用户可以根据需求选择合适的方法。此外,它还支持向量检索。这种检索方式非常高效。"
语义切片可能的结果:
-
切片1: "LlamaIndex是一个强大的RAG框架。它提供了多种文档处理方式。用户可以根据需求选择合适的方法。"
-
切片2: "此外,它还支持向量检索。这种检索方式非常高效。" (注意这里是按语义相关性分组的)
# buffer_size=1 — 计算语义相似度时,每个句子前后各合并 1 个句子形成 "句组",再对相邻句组做 embedding 余弦相似度比较。值越大,上下文平滑效果越强,切分越粗。
# breakpoint_percentile_threshold=95 — 切割阈值百分位。计算所有相邻句组间的语义距离(1 - cosine similarity),取第 95 百分位作为阈值,距离超过该值的位置视为语义断点,执行切割。值越高,断点越少,chunk 越大。
# embed_model=Settings.embed_model — 用于计算句子 embedding 的模型,语义距离的计算依赖它。
semantic_splitter = SemanticSplitterNodeParser(
buffer_size=1,
breakpoint_percentile_threshold=95,
embed_model=Settings.embed_model
)
Markdown 切片
专门针对 Markdown 文档优化的切片方法。
示例 Markdown:
**# RAG框架**
LlamaIndex是一个强大的RAG框架。
**## 特点**
- 提供多种文档处理方式
- 支持向量检索
- 使用简单方便
**### 详细说明**
用户可以根据需求选择合适的方法。
切割效果可能是:
-
切片1: # RAG框架\nLlamaIndex是一个强大的RAG框架。
-
切片2: ## 特点\n-提供多种文档处理方式\n-支持向量检索\n-使用简单方便
-
切片3: ### 详细说明\n用户可以根据需求选择合适的方法。
markdown_splitter = MarkdownNodeParser()
在实际应用中,选择切片的方法可以这样思考:
-
如果刚开始接触 RAG,建议先使用默认的句子切片方法,她在大多数场景下都能提供不错的效果
-
当发现检索效果不够理想时,可以尝试:
-
处理长文档且需要保持上下文?试试句子窗口切片
-
文档逻辑性强、内容专业?语义切片可能会有帮助
-
模型总报 Token 超限?Token 切片可以帮助你精准控制
-
处理 Markdown 文档?使用 Markdown 切片
-
切片向量化与存储阶段
文档切片后,还需要对其建立索引,以便后续检索。一个常见的方案是使用 Embedding 模型将切片向量化,并存储到向量数据库中。
在这一阶段,选择合适的 Embedding 模型以及向量数据库,这对于提升检索效果至关重要。
选择合适的向量数据库
在构建 RAG 应用时,有多种向量存储方案可以选择,从简单到复杂依次是:内存向量存储、本地向量数据库、云服务向量存储。
内存向量存储
最简单的方式是使用 LlamaIndex 内置的内存向量存储。只需安装 llama-index 包,无需额外配置,就能快速开发和测试 RAG 应用:
from llama_index.core import VectorStoreIndex
# 创建内存向量索引
index = VectorStoreIndex.from_documents(documents)
优点是快速上手,适合开发测试;缺点是数据无法持久化,且受限于内存大小。
本地向量数据库
当数据量增大时,可以使用开源的向量数据库,如 Milvus、Qdrant 等。这些数据库提供了数据持久化和高效检索能力。
优点是功能完整、可控性强;缺点是需要自行部署维护。
云服务向量存储
对于生产环境,推荐使用云服务提供的向量存储能力。
优点是:无需关注运维,自动扩缩容、提供完善的监控和管理工具、按量付费,成本可控、支持向量 + 标量的混合检索,提升检索准确性
选择建议:
-
开发测试时使用内存向量存储
-
小规模应用可以使用本地向量数据库
-
生产环境推荐使用云服务
检索召回阶段
检索阶段会遇到的主要问题就是,很难从众多文档切片中,找出和用户问题最相关、且包含正确答案信息的片段。
从切入时机来看,可将解法分为两大类:
-
执行检索前:很多用户问题描述是不完整、甚至有歧义的,需要想办法还原用户真实意图,以便提升检索效果。
-
执行检索后:你可能会发现存在一些无关的信息,需要想办法减少无关信息,避免干扰下一步的答案生成。
| 时机 | 改进策略 | 示例 |
|---|---|---|
| 检索前 | 问题改写 | 「附近有好吃的餐厅吗?」-> 「请推荐我附近的几家评分较高的餐厅」 |
| 问题扩写 | 「张伟是哪个部门的?」-> 「张伟是哪个部门的?他的联系方式、职责范围、工作目标是什么?」 | |
| 基于用户画像扩展上下文 结合用户信息、行为等数据扩写问题 | 前端提问「工作注意事项」->「前端工程师有哪些注意事项」后端提问「工作注意事项」->「后端工程师有哪些注意事项」 | |
| 提取标签,用于后续标签过滤 + 向量相似度检索 | 「前端工程师有哪些注意事项」-> - 标签过滤:{"岗位": "前端工程师"} - 向量检索:「前端工程师有哪些工作注意事项」 | |
| 反问用户 | 「工作职责是什么」-> 大模型反问:「请问你想了解那个岗位的工作职责」 | |
| 思考并规划多次检索 | 「张伟不在,可以找谁」-> 大模型思考规划:task1:张伟的职责是什么?task2: ${task1_result}职责的人有谁 -> 按顺序依次检索 | |
| …… | ||
| 检索后 | 重排序 ReRank + 过滤。多数向量数据库会考虑效率,牺牲一定精确度,召回的切片中可能有一些实际相关性不够高 | chunk1、chunk2、……、chunk10 -> chunk2、chunk5、chunk6 |
| 滑动窗口检索。在检索到一个切片后,补充前后相邻的若干个切片。这样做的原因是:相邻切片至今往往存在语义联系,仅看单个切片可能会丢失重要信息。滑动窗口检索确保了不会因为过度切分而丢失文本间的语义连接。 | 原始文本:ABCDE,检索到切片为 D,补充相邻切片后:CDE(前后各取 1 个切片) | |
| …… |
问题改写
方法一:使用大模型扩充用户问题
可以让大模型充当一个问题改写助手。他会帮你把简单的问题改写的更加完整清晰。
query_gen_str = """\
系统角色设定:
你是一个专业的问题改写助手。你的任务是将用户的原始问题扩充为一个更完整、更全面的问题。
规则:
1. 将可能的歧义、相关概念和上下文信息整合到一个完整的问题中
2. 使用括号对歧义概念进行补充说明
3. 添加关键的限定词和修饰语
4. 确保改写后的问题清晰且语义完整
5. 对于模糊概念,在括号中列举主要可能性
原始问题:
{query}
请生成一个综合的改写问题,确保:
- 包含原始问题的核心意图
- 涵盖可能的歧义解释
- 使用清晰的逻辑关系词连接不同方面
- 必要时使用括号补充说明
输出格式:
[综合改写] - 改写后的问题
"""
query_gen_prompt = PromptTemplate(query_gen_str)
def generate_queries(query: str):
response = Settings.llm.predict(
query_gen_prompt, query=query
)
return response
# 生成扩展查询
print("\n🔍 原始问题:")
print(f" {question}")
query = generate_queries(question)
print("\n📝 扩展查询:")
print(f" {query}\n")
# 创建查询引擎
query_engine = sentence_index.as_query_engine(
streaming=True,
similarity_top_k=5
)
# 执行查询
response = query_engine.query(query)
print("💭 AI回答:")
print("-" * 40)
response.print_response_stream()
print("\n")
# 显示参考文档
print("\n📚 参考依据:")
print("-" * 40)
for i, node in enumerate(response.source_nodes, 1):
print(f"\n文档片段 {i}:")
print(f"相关度得分: {node.score:.4f}")
print("-" * 30)
print(node.text)
方法二:将单一查询改写为多步骤查询
LlamaIndex 提供了两个强大的工具来实现这个功能:
-
StepDecomposeQueryTransform: 这个工具可以帮你把一个复杂问题分解成多个子问题。比如对于"张伟是哪个部门的?",它会先分解为:
-
"公司里有几个叫张伟的员工?"
-
"这些张伟分别在哪些部门?"
-
-
MultiStepQueryEngine: 这个查询引擎会按顺序处理这些子问题。它会先获取公司所有张伟的信息,然后再查询每个张伟的部门信息,最终将答案整合成一个完整的回应,告诉用户"公司有三名张伟,分别在教研部、课程开发部和IT部"
需要注意:这种方法会多次调用大模型,所以会消耗更多 token。
from llama_index.core.indices.query.query_transform.base import (
StepDecomposeQueryTransform,
)
step_decompose_transform = StepDecomposeQueryTransform(verbose=True)
# set Logging to DEBUG for more detailed outputs
from llama_index.core.query_engine import MultiStepQueryEngine
query_engine = sentence_index.as_query_engine(streaming=True,similarity_top_k=5)
query_engine = MultiStepQueryEngine(
query_engine=query_engine,
query_transform=step_decompose_transform,
index_summary="公司人员信息"
)
print(f"❓ 用户问题: {question}\n")
print("🤖 AI正在进行多步查询...")
response = query_engine.query(question)
print("\n📚 参考依据:")
print("-" * 40)
for i, node in enumerate(response.source_nodes, 1):
print(f"\n文档片段 {i}:")
print("-" * 30)
print(node.text)
方法三:用假设文档来增强检索(HyDE)
它的工作方式很有趣:
-
先让大模型基于问题编一个“假想的答案文档”
-
用这个假想文档来检索真实文档
-
最后用检索到的真实文档来生成实际答案
from llama_index.core.indices.query.query_transform.base import (
HyDEQueryTransform,
)
from llama_index.core.query_engine import TransformQueryEngine
# run query with HyDE query transform
hyde = HyDEQueryTransform(include_original=True)
query_engine = sentence_index.as_query_engine(streaming=True,similarity_top_k=5)
query_engine = TransformQueryEngine(query_engine, query_transform=hyde)
print(f"❓ 用户问题: {question}\n")
print("🤖 AI正在通过 HyDE 分析...")
streaming_response = query_engine.query(question)
print("\n💭 AI回答:")
print("-" * 40)
streaming_response.print_response_stream()
# 显示参考文档
print("\n📚 参考依据:")
print("-" * 40)
for i, node in enumerate(streaming_response.source_nodes, 1):
print(f"\n文档片段 {i}:")
print("-" * 30)
print(node.text)
提取标签增强检索
在向量检索的基础上,还可以添加标签过滤提升检索精度。这种方式类似于图书馆既有书名检索,又有分类编号系统,能让检索更精准。
标签提取有两个关键场景:
-
建立索引时,从文档切片中提取结构化标签
-
检索时,从用户问题中提取对应的标签进行过滤
import os
from openai import OpenAI
client = OpenAI(api_key=os.getenv("API_KEY"), base_url="https://xxx")
system_message = """你是一个标签提取专家。请从文本中提取结构化信息,并按要求输出标签。
---
【支持的标签类型】
- 人名
- 部门名称
- 职位名称
- 技术领域
- 产品名称
---
【输出要求】
1. 请用 JSON 格式输出,如:[{"key": "部门名称", "value": "教研部"}]
2. 如果某类标签未识别到,则不输出该类
---
待分析文本如下:
"""
def extract_tags(text):
completion = client.chat.completions.create(
model="你使用的模型",
messages=[
{'role': 'system', 'content': system_message},
{'role': 'user', 'content': text}
],
response_format={"type": "json_object"}
)
return completion.choices[0].message.content
# 示例
tech_text = """本论文提出了一种基于深度学习的图像识别算法,在医疗影像分析中取得了突破性进展。该算法已在北京协和医院的CT诊断系统中得到应用。"""
print("\n技术文档标签提取结果:")
print(extract_tags(tech_text))
我们在建立索引时,可以将这些标签与文档切片一起存储。这样在检索时,比如用户提问“张伟是哪个部门的”,我们可以:
-
从问题中提取出人名标签{"key": "人名", "value": "张伟"}
-
先用标签过滤出所有包含“张伟”的文档切片
-
再用向量相似度检索找出最相关的内容
这种“标签过滤 + 向量检索”的组合方式,能大幅提升检索的准确性。特别在处理结构化成都较高的企业文档时,这个方法效果更好。
重排序
例如可以先从向量数据库中检索召回 20 条文档切片,再进行重排序,并筛选出最相关的 3 条参考信息。
from llama_index.core.postprocessor import SimilarityPostprocessor
from chatbot.openai_like import DashScopeRerank,DashScopeRerankPostprocessor
rerank_client = DashScopeRerank(
api_key=os.getenv("API_KEY"),
model_name="你使用的模型",
)
query_engine = index.as_query_engine(
# 先设置一个较大的召回切片数量
similarity_top_k=20,
streaming=True,
node_postprocessors=[
# 在rerank 模型中选择你最终想召回的切片个数,重排模型选择gte-rerank模型
DashScopeRerankPostprocessor(rerank_client=rerank_client, top_n=3),
# 设置一个相似度阈值,低于该阈值的切片会被过滤掉
SimilarityPostprocessor(similarity_cutoff=0.2),
],
)
response = ask("张伟是哪个部门的", query_engine=query_engine)
生成答案阶段
现在,大模型会根据你的问题和检索召回的内容,生成最终的答案。然而,这个答案可能还是不及预期,可能遇到的问题有:
-
没有检索到相关信息,大模型捏造答案。
-
检索到了相关信息,但是大模型没有按照要求生成答案。
-
检索到了相关信息,大模型也给出了答案,但是希望给出更全面的答案。
为了解决这些问题,可从下列角度着手分析与解决:
-
选择合适的大模型
-
充分优化提示词模版:
-
明确要求不编造答案
-
添加内容分隔标记
-
根据问题类型调整模版
-
-
调整大模型参数:
-
如果希望大模型输出在相同的问题下,输出的内容尽可能相同,可以在每次模型调用时传入相同的 seed 值。
-
如果希望大模型在回答用户问题时不要总用重复的句子,可以适当调高 presence_penalty 值。
-
如果希望查询事实性的内容,可以适当降低 temperature 活 top_p 值;繁殖,查询创造性的内容时,可适当增加他们的值。
-
如果需要限制字数、控制成本或减少响应时间的场景,可以适当降低 max_tokens 的值,但若 max_tokens 过小,可能会导致输出截断。反之,需生成大段文本时,可以提高它的值。
-