前言
前面讲的都是基于索引的优化。那么用户的查询就没有问题吗?现在有这样一个🌰:
- query:我要买机票
- LLM:😳买个der,买哪谁知道啊?你有多少钱谁知道啊?...(宕机ing)
这就是一个很直观的用户查询问题,所以 AdvancedRAG 在检索前基于query也做了一定优化。
Enrich Question-问题丰富
Enrich Question 通过将用户模糊的、口语化的自然语言查询,转化为结构清晰、信息完整的检索指令,从而大幅提升 RAG 系统的召回率。
- 本质:意图识别 + 对话式补全
- 核心流程:
- 意图识别 (Intent Recognition) :
-
目标:判断用户 query 属于哪一类问题(如“查询天气”、“搜索文档”、“计算数学”)。
-
技术:通常使用轻量级分类器(如 BERT-based Classifier)或规则匹配,将 query 映射到预定义的模板库中。
- 模板匹配 (Template Matching) :
-
目标:找到最适合当前 query 的“填空”模板。
-
技术:每个模板定义了该类问题需要的关键信息槽位(Slots)。例如,天气查询模板需要
[城市]和[日期]两个槽位。
- 槽位填充 (Slot Filling) :
-
目标:提取 query 中已有的信息,并识别缺失的信息。
-
技术:使用 NER(命名实体识别)或 LLM 提取实体。如果发现槽位缺失(如用户只说了“查天气”,没说城市),则进入交互补全阶段。
- 交互式补全 (Interactive Completion) :
-
目标:通过 LLM 生成自然、友好的引导语,引导用户补充缺失信息。
-
技术:LLM 根据缺失的槽位生成提示,如“请问您想查询哪个城市的天气?”。系统等待用户回复后,将新信息填充回槽位,直到所有必要信息完整。
- Query 重构 (Query Reconstruction) :
- 目标:将填充完整的模板转化为标准的检索 query。
- 技术:将模板中的变量替换为实际值,生成最终的检索指令,送入向量数据库或搜索引擎。
模版定义
# 首先根据用户的要求,进行意图识别,获取对应的模板
# 示例业务模板
templates = {
"订机票": ["起点", "终点", "时间", "座位等级", "座位偏好"],
"订酒店": ["城市", "入住日期", "退房日期", "房型", "人数"],
}
# 意图识别提示模板
intent_prompt = PromptTemplate(
input_variables=["user_input", "templates"],
template="根据用户输入 '{user_input}',选择最合适的业务模板。可用模板如下:{templates}。请返回模板名称。"
)
意图识别 -> 获取模板
# 创建意图识别链
intent_chain = intent_prompt | llm
# 识别意图
intent = intent_chain.invoke({"user_input": user_input, "templates": str(list(templates.keys()))}).content
print("意图:", intent)
# 获取对应模板
selected_template = templates.get(intent)
print("模板:", selected_template)
槽位补齐
# 根据用户意图已经获取对应的模板,然后判断是否需要补充信息
# 补充信息提示模板
info_prompt = f"""
请根据用户原始问题和模板,判断原始问题是否完善。如果问题缺乏需要的信息,请生成一个友好的请求,明确指出需要补充的信息。若问题完善后,返回包含所有信息的完整问题。
### 原始问题
{user_input}
### 模板
{",".join(selected_template)}
### 输出示例
{{
"isComplete": true,
"content": "`完整问题`"
}}
{{
"isComplete": false,
"content": "`友好的引导用户补充需要的信息`"
}}
"""
# 历史记录
chat_history = ChatMessageHistory()
# 聊天模版
prompt = ChatPromptTemplate.from_messages(
[
("system", "你是一个信息补充助手,任务是分析用户问题是否完整。"),
("placeholder", "{history}"), # 历史记录的占位
("human", "{input}"),
]
)
# 补充信息链
info_chain = prompt | llm
# 自动处理历史记录,将记录注入输入并在每次调用后更新它
with_message_history = RunnableWithMessageHistory(
info_chain,
lambda session_id: chat_history,
input_messages_key="input",
history_messages_key="history",
)
信息完整性判断
# 循环判断是否完整,并提交用户补充信息
while json_data.get('isComplete', False) is False:
try:
# 显示引导信息并等待用户输入,用\033[1;33m和\033[0m设置和重置文本颜色及样式(黄色加粗)
user_answer = input(f"\033[1;33m{json_data['content']}\033[0m\n请补充:")
# 提交补充信息给AI处理
info_request = with_message_history.invoke(
input={"input": user_answer},
config={"configurable": {"session_id": "unused"}}
).content
# 解析AI响应
json_data = parser.parse(info_request)
except json.JSONDecodeError:
#\033[1;31m 是 ANSI 转义字符,用于设置字体颜色为红色并加粗,\033[0m 用于恢复默认字体样式
print("\033[1;31m[错误] AI返回了无效的JSON格式,请重试\033[0m")
continue
except KeyError:
print("\033[1;31m[错误] 响应格式异常,正在终止流程\033[0m")
break
测试
info_request = with_message_history.invoke(input={"input": info_prompt},
config={"configurable": {"session_id": "unused"}}).content
parser = JsonOutputParser()
json_data = parser.parse(info_request)
print("json_data:",json_data)
运行
运行后发现,和我们上面画的流程图是一致的。
Question Decomposition-问题拆解
Question Decomposition(问题拆解)是Advanced RAG在“检索前优化”阶段的一项关键技术。
- 核心:解决复杂、多步骤或隐含多子问题的查询时,因单一查询向量与知识库匹配度低而导致的检索失败或答案质量低下的问题。
- 本质: ”让系统像人类专家一样思考“
- 核心流程:
-
问题拆解
-
输入:用户的原始复杂查询(例如:“比较iPhone 15和华为Mate 60的摄像头和电池续航”)。
-
处理:LLM根据预设的Prompt,将问题分解为多个语义独立、可检索的子问题。例如:
- 子问题A:“iPhone 15的摄像头参数和评测如何?”
- 子问题B:“华为Mate 60的摄像头参数和评测如何?”
- 子问题C:“iPhone 15的电池续航表现如何?”
-
输出:一组结构化的子查询列表,为后续检索提供明确的输入。
-
-
并行 vs. 串行:两种执行策略
-
并行策略:当子问题之间没有强依赖关系,可以独立解答时,采用并行处理。
-
串行策略:当子问题之间存在逻辑递进或依赖关系,前一个问题的答案是后一个问题的基础时,采用串行处理。
模拟文档
# 模拟文档
documents = [
Document(page_content="西红柿炒蛋的食材:\n\n- 新鲜鸡蛋:3-4个(根据人数调整)\n- 西红柿:2-3个中等大小\n- 盐:适量\n- 白糖:一小勺(可选,用于提鲜)\n- 食用油:适量\n- 葱花:少许(可选,用于增香)\n\n这些是最基本的材料,当然也可以根据个人口味添加其他调料或配料。"),
Document(page_content="西红柿炒蛋的步骤:鸡蛋打入碗中,加入少许盐,用筷子或打蛋器充分搅拌均匀;\n - 西红柿洗净后切成小块备用。\n\n3. **炒鸡蛋**:锅内倒入适量食用油加热至温热状态,然后将搅拌好的鸡蛋液缓缓倒入锅中。待鸡蛋凝固时轻轻翻动几下,让其受热均匀直至完全熟透,随后盛出备用。\n\n4. **炒西红柿**:在同一锅里留下的底油中放入切好的西红柿块,中小火慢慢翻炒至出汁,可根据个人口味加一点点白糖提鲜。\n\n5. **合炒**:当西红柿炒至软烂并开始释放大量汤汁时,再把之前炒好的鸡蛋倒回锅里,快速与西红柿混合均匀,同时加入适量的盐调味。如果喜欢的话还可以撒上一些葱花增加香气。\n\n6. **完成**:最后检查一下味道是否合适,确认无误后即可关火装盘享用美味的西红柿炒蛋啦!"),
Document(page_content="技巧与注意事项:1. **选材**:选择新鲜的鸡蛋和成熟的西红柿。新鲜的食材是做好这道菜的基础。\n2. **打蛋液**:将鸡蛋打入碗中后加入少许盐(根据个人口味调整),然后充分搅拌均匀。这样做可以让蛋更加松软且味道更佳。\n3. **处理西红柿**:西红柿最好先用开水稍微焯一下皮,然后去皮切块。这样可以去除表皮的硬质部分,让西红柿更容易入味,并且口感更好。\n4. **热锅冷油**:先用中小火把锅烧热,再倒入适量食用油,待油温五成热时下蛋液。这样的做法可以使蛋快速凝固形成漂亮的形状而不易粘锅。\n5. **分步烹饪**:通常建议先炒鸡蛋至半熟状态取出备用;接着利用剩下的底油继续翻炒西红柿至出汁,最后再将之前炒好的鸡蛋倒回锅里与西红柿混合均匀加热即可。\n6. **调味品**:除了基本的盐之外,还可以根据喜好添加少量糖来提鲜或者一点酱油增色添香。注意调味料不宜过多以免掩盖了食材本身的味道。\n7. **出锅前加葱花**:如果喜欢的话,在即将完成时撒上一些葱花不仅能增加菜品色泽还能增添香气。")
]
Prompt
- 主 Prompt:核心问题拆解的 Prompt,拆解 query,产生 sub_query
template = """
你是一名AI语言模型助理。你的任务是将输入问题分解成4个子问题,通过一个个解决这些子问题从而解决完整的问题。
子问题需要在矢量数据库中检索相关文档。通过分解用户问题生成子问题,你的目标是帮助用户克服基于距离的相似性搜索的一些局限性。
请提供这些用换行符分隔的子问题本身,不需要额外内容。
原始问题: {question}
"""
DEFAULT_QUERY_PROMPT = PromptTemplate(
input_variables=["question"],
template=template,
)
- 子 Prompt:query 和 sub_query 的关系描述。先解决sub_query。
DEFAULT_SUB_QUESTION_PROMPT = PromptTemplate(
input_variables=["question", "sub_question", "documents"],
template="""
要解决主要问题{question},需要先解决子问题{sub_question}。
以下是为支持您的推理而提供的参考文档:{documents}。请直接给出当前子问题的答案。不需要额外内容。
""",
)
DecompositionQueryRetriever
自定义一个检索器,将对子问题的生成、获得子问题的答案组合起来。
成员变量
# 向量数据库检索器
retriever: BaseRetriever
# 生成子问题链
make_sub_chain: Runnable
# 解决子问题链
resolve_sub_chain: Runnable
from_llm()
@classmethod
def from_llm(
cls,
retriever: BaseRetriever,
llm: BaseLanguageModel,
prompt: BasePromptTemplate = DEFAULT_QUERY_PROMPT,
sub_prompt: BasePromptTemplate = DEFAULT_SUB_QUESTION_PROMPT
) -> "DecompositionQueryRetriever":
output_parser = StrOutputParser()
# make_sub_chain = prompt | llm | output_parser
# resolve_sub_chain = sub_prompt | llm
return cls(
retriever=retriever,
make_sub_chain=prompt | llm | output_parser,
resolve_sub_chain=sub_prompt | llm
)
子问题生成
#生成子问题
def generate_queries(self, question: str) -> List[str]:
response = self.make_sub_chain.invoke({"question": question})
lines = str2List(response)
print(f"生成子问题: {lines}")
return lines
子问题答案
def retrieve_documents(self, query: str, sub_queries: List[str]) -> List[Document]:
sub_llm_chain = RunnableLambda(
# 传入子问题,检索文档并回答
lambda sub_query: self.resolve_sub_chain.invoke(
{
"question": query,
"sub_question": sub_query,
"documents": [doc.page_content for doc in self.retriever.invoke(sub_query)]
}
)
)
# 批量执行所有的子问题
responses = sub_llm_chain.batch(sub_queries)
# 将子问题和答案合并作为解决主问题的文档
documents = [
Document(page_content=sub_query + "\n" + response.content)
for sub_query, response in zip(sub_queries, responses)
]
return documents
_get_relevant_documents
# 核心执行函数
def _get_relevant_documents(
self,
query: str,
*,
run_manager: CallbackManagerForRetrieverRun,
) -> List[Document]:
# 生成子问题
sub_queries = self.generate_queries(query)
# 解决子问题
documents = self.retrieve_documents(query, sub_queries)
return documents
问题拆解
decompositionQueryRetriever = DecompositionQueryRetriever.from_llm(
llm=llm,
retriever=retriever
)
decomposition_docs = decompositionQueryRetriever.invoke("西红柿炒蛋怎么制作?")
print("-------------检索到的文档(拆解后)--------------")
pretty_print_docs(decomposition_docs)
具体回答
template = """请根据以下文档回答问题:
### 文档:
{context}
### 问题:
{question}
"""
# 由模板生成prompt
prompt = ChatPromptTemplate.from_template(template)
chain = prompt | llm
print("-------------回答--------------")
question = "新手如何制作西红柿炒蛋?"
response = chain.invoke({"context": [doc.page_content for doc in decomposition_docs], "question": question})
print(response.content)
Run
- 不难看出使用问题拆解后,得到的答案更具体详实,还有注意事项和技巧。
- 我们可以看出,示例分成了4个子问题,每个子问题都带着对应的答案。
今天就到这,剩下的 Multi-recall(多路召回),我们下期继续。