本章内容包括:
- 查询重写技术
- 高级文本嵌入策略
- 实现父文档检索
在本书第2章中,您学习了文本嵌入和向量相似度搜索的基础知识。通过将文本转换为数值向量,您了解了机器如何理解内容的语义含义。结合文本嵌入和向量相似度搜索技术,可以从海量文档中优化并准确地检索相关的无结构文本,从而在RAG应用中实现更准确、更新及时的回答。
假设您已经按照第2章的描述实现并部署了RAG应用。经过一段测试,您和应用用户发现生成答案的准确性不足,原因是检索到的文档信息不完整或无关。于是,您被指派提升检索系统,以改善答案的准确性。
如同任何技术一样,文本嵌入和向量相似度搜索的基础实现可能会导致检索准确率和召回率不足。用户查询生成的嵌入向量可能与包含关键信息的文档向量不够接近,这通常是因为术语或语境差异。结果是一些与查询意图高度相关的文档被遗漏,因为查询的嵌入表示未能准确捕捉所需信息的本质。
提升检索准确率和召回率的一种策略是对用于查找相关文档的查询进行重写。查询重写旨在通过重新表述查询,使其更符合目标文档的语言和语境,弥合用户查询与信息丰富文档之间的差距。这种查询优化提高了找到包含相关信息文档的几率,从而提升对原始查询的回答准确性。查询重写策略的示例包括假设文档检索器(Gao 等,2022)和回退式提示(step-back prompting,Zheng 等,2023)。回退式提示策略在图3.1中有可视化展示。
图3.1概述了一个将用户查询进行转化以提升文档检索效果的过程,这种技术被称为“回退式提示”(step-back prompting)。在图示场景中,用户提出了关于Estella Leopold特定时间段教育经历的详细问题。该初始问题随后由具备查询重写能力的语言模型(如GPT-4)处理,重新表述为一个更为宽泛的关于Estella Leopold教育背景的询问。此步骤的目的是在搜索过程中拓宽检索范围,因为重写后的查询更有可能匹配包含所需信息的多种文档。
提升检索准确率的另一种方法是调整文档嵌入策略。在上一章中,您对一段文本进行了嵌入,检索出该文本,并将其作为输入传给LLM生成答案。然而,向量检索系统具有灵活性,您并不局限于对计划检索的原文进行嵌入。相反,您可以嵌入更能代表文档含义的内容,例如与上下文更相关的片段、合成问题或释义版本。这些替代内容能更好地捕捉关键信息和主题,从而带来更准确、更相关的检索结果。图3.2展示了两种高级嵌入策略的示例。
图3.2左侧展示了假设性问题(hypothetical question)策略。采用该策略时,需要确定文档中包含的信息能回答哪些问题。例如,可以用大型语言模型(LLM)生成假设性问题,或者利用聊天机器人的对话历史推断文档能回答的相关问题。核心思想是,不直接对原始文档本身进行嵌入,而是对文档可以回答的问题进行嵌入。比如图中“Leopold在加州大学学习了什么?”这条问题被编码为向量[1,2,3,0,5]。当用户提出问题时,系统先计算查询的嵌入向量,然后在预先计算好的问题嵌入中搜索最相近的邻居。目标是找到与用户问题语义高度匹配的假设问题,并检索包含这些问题答案的文档。简言之,该策略通过嵌入文档可能回答的问题,用这些问题向量来匹配和检索相关文档。
图3.2右侧展示了父文档(parent document)嵌入策略。此方法中,原始文档(父文档)被拆分成多个更小的子块(child chunks),通常按固定的token数量划分。与将整个父文档作为单一实体进行嵌入不同,这里为每个子块单独计算嵌入向量。例如,子块“Leopold获得了植物学硕士学位”可能被嵌入为向量[1,0,3,0,1]。用户查询时,系统会将查询与这些子块嵌入进行比对,找出最相关的匹配项。但返回给用户的不是单个子块,而是该子块对应的完整父文档。这样,语言模型可以基于完整上下文进行推理,提高生成准确且完整答案的概率。
该策略解决了长文档嵌入时常见的问题:整体嵌入向量会通过平均化模糊多条信息,使得针对具体查询的匹配效果变差。拆分成小块后,匹配更精准,同时系统仍可返回全文上下文。
提升检索准确率的其他策略:
- 微调文本嵌入模型——通过在领域特定数据上微调嵌入模型,提高其捕捉用户查询语境的能力,从而更好地语义匹配相关文档。微调通常需要较大计算资源和基础设施,且模型更新后需重新计算所有文档嵌入,成本较高。
- 重排序策略——初步检索出文档后,采用重排序算法根据用户意图对结果重新排序,通常使用更复杂模型或评分启发式方法,提升最相关内容的排名。
- 基于元数据的上下文过滤——许多文档带有结构化元数据,如作者、发布日期、主题标签、来源类型等。基于元数据过滤(手动或自动)能显著缩小候选文档范围,提升精准率。例如,查询近期政策更新时,可以限制仅检索最近一年的文档。
- 混合检索(关键词+密集向量搜索)——结合基于关键词的稀疏检索和基于语义的密集向量检索,兼顾精准匹配和语义覆盖。混合系统整合并重排两类结果,最大化召回和精准率。
以上策略虽均可提升检索质量,但详细实现超出本书范围,唯一例外是第2章介绍的混合检索。
本章剩余部分将从概念转向代码,实现逐步讲解。跟进本章需准备一个空白、可用的Neo4j实例,支持本地安装或云端托管,确保实例为空。相关实现可参阅本书配套Jupyter笔记本:github.com/tomasonjo/k…。
假设您已实现第2章的基础RAG系统,但检索准确率仍不理想,生成回答缺乏相关性或遗漏重要上下文,怀疑系统未能检索到最有用的文档支持高质量答案。为此,您决定通过添加回退式提示步骤(step-back prompting)来提升查询质量,并将基础检索器替换为父文档检索策略。该方法通过基于更小子块进行匹配实现更细粒度和更精准的信息检索,同时仍提供完整父文档作为上下文。
这些改进旨在提升检索内容的相关性及生成答案的整体准确性。
3.1 回退式提示(Step-back prompting)
如前所述,回退式提示是一种查询重写技术,旨在提升向量检索的准确性。原始论文(Zheng 等,2023)中的示例演示了该过程:将具体查询“Thierry Audel在2007至2008年期间效力于哪支球队?”拓展为“Thierry Audel职业生涯中效力过哪些球队?”,以提高向量搜索的精度,进而提升生成答案的准确性。通过将详细问题转化为更广泛的高层次查询,回退式提示简化了向量搜索的复杂度。其核心思想是,更宽泛的查询通常涵盖更全面的信息范围,使模型更容易识别相关事实,而不被细节困扰。
论文作者使用大型语言模型(LLM)执行查询重写任务,如图3.3所示。
大型语言模型(LLM)非常适合执行查询重写任务,因为它们在自然语言理解和生成方面表现出色。您无需为每个任务单独训练或微调新模型,而是可以直接在输入提示中提供任务指令。
回退式提示论文的作者使用了如下示例中的系统提示(system prompt),用以指导LLM如何重写输入查询。
代码示例3.1 用于生成回退式问题的LLM系统提示
stepback_system_message = f"""
You are an expert at world knowledge. Your task is to step back #1
and paraphrase a question to a more generic step-back question, which
is easier to answer. Here are a few examples
"input": "Could the members of The Police perform lawful arrests?" #2
"output": "what can the members of The Police do?"
"input": "Jan Sindel’s was born in what country?"
"output": "what is Jan Sindel’s personal history?"
"""
#1 查询重写指令
#2 少量示例
示例3.1中的系统提示首先给LLM一个简单指令,将用户的问题重写成更通用、更宽泛的版本。仅有此类指令时称为零样本提示(zero-shot prompting),完全依赖LLM自身对任务的理解和能力,无需示例。为了更有效地引导模型并确保输出一致,作者选择加入几个示例来展示期望的重写方式,这被称为少样本提示(few-shot prompting),通常在提示中加入2到5个示例。少样本提示通过具体实例帮助LLM更好理解预期转换,提升输出质量和稳定性。
实现查询重写,只需将示例3.1中的系统提示与用户问题一起发送给LLM即可。完成该任务的具体函数如下。
代码示例3.2 生成回退式问题的函数
def generate_stepback(question: str):
user_message = f"""{question}"""
step_back_question = chat(
messages=[
{"role": "system", "content": stepback_system_message},
{"role": "user", "content": user_message},
]
)
return step_back_question
您可以运行如下代码测试回退式提示生成。
代码示例3.3 执行回退式提示函数
question = "Which team did Thierry Audel play for from 2007 to 2008?"
step_back_question = generate_stepback(question)
print(f"Stepback results: {step_back_question}")
# Stepback results: What is the career history of Thierry Audel?
示例3.3中的结果展示了回退式提示生成函数的成功执行。通过将关于Thierry Audel在2007至2008年效力球队的具体问题,转换为其整个职业生涯历史的宽泛问题,该函数有效拓宽了上下文,有望提升检索准确率和召回率。
练习3.1
为探索回退式提示生成的效果,尝试将其应用于不同问题,观察其如何拓宽上下文。您也可以修改系统提示,观察对输出的影响。
3.2 父文档检索器
父文档检索器策略包括将大型文档拆分成更小的章节,为每个章节计算嵌入向量,而不是对整个文档进行嵌入,利用这些嵌入更准确地匹配用户查询,最终检索出整个文档以提供丰富的上下文回答。然而,由于无法将整个PDF直接输入到大型语言模型(LLM)中,首先需要将PDF拆分成父文档(parent documents),然后再将这些父文档进一步拆分成子文档(child documents)进行嵌入和检索。父文档与子文档的图形表示如图3.4所示。
图3.4展示了基于图的文档存储与组织方法,用于父文档检索策略。图中最顶部的PDF节点代表整个文档,带有标题和标识符。该节点连接多个父文档节点。在本示例中,将PDF按2000字符的限制拆分为多个父文档节点。每个父文档节点又连接若干子文档节点,每个子节点包含对应父文档文本的500字符片段。子节点拥有嵌入向量,用于检索匹配。
本例仍使用第2章中的文本,即Asis Kumar Chaudhuri撰写的论文《Einstein’s Patents and Inventions》(arxiv.org/abs/1709.00…)。另外,在对文档拆分成更小部分进行处理时,最好从结构元素入手,如按段落或章节拆分,这样有助于保持内容的连贯性和上下文完整性,因为段落或章节通常包含完整的思想或主题。因此,示例中首先将PDF文本拆分为多个章节。
代码示例3.4 使用正则表达式拆分文本为章节
import re
def split_text_by_titles(text):
# 匹配以数字开头、可选大写字母、点号、空格,后接最多60个字符的章节标题
title_pattern = re.compile(r"(\n\d+[A-Z]?. {1,3}.{0,60}\n)", re.DOTALL)
titles = title_pattern.findall(text)
# 按标题拆分文本
sections = re.split(title_pattern, text)
sections_with_titles = []
# 添加第一个分段
sections_with_titles.append(sections[0])
# 迭代余下分段,将标题与内容拼接
for i in range(1, len(titles) + 1):
section_text = sections[i * 2 - 1].strip() + "\n" + sections[i * 2].strip()
sections_with_titles.append(section_text)
return sections_with_titles
sections = split_text_by_titles(text)
print(f"Number of sections: {len(sections)}")
# 输出章节数量:9
split_text_by_titles函数利用正则表达式按照章节拆分文本。正则表达式基于文本中章节按编号排列的事实,每个章节以数字及可选字符开头,后接点号和章节标题。函数返回9个章节。若查看PDF,主章节有4个,但有4个子章节(3A-3D)描述部分专利,加上引言摘要共计9个章节。
继续父文档检索前,统计每个章节的token数以了解长度,使用OpenAI开发的tiktoken包进行计数。
代码示例3.5 统计章节token数
def num_tokens_from_string(string: str, model: str = "gpt-4") -> int:
"""返回文本字符串的token数量"""
encoding = tiktoken.encoding_for_model(model)
num_tokens = len(encoding.encode(string))
return num_tokens
for s in sections:
print(num_tokens_from_string(s))
# 示例输出:154, 254, 4186, 570, 2703, 1441, 194, 600
大多数章节长度较小,token数不超过600,适合大多数LLM上下文窗口。但第三章节超过4000个token,可能导致LLM生成时超出token限制。因此需将章节拆分为父文档,每个父文档最多2000字符,使用上一章的chunk_text函数完成拆分。
代码示例3.6 将章节拆分为最大2000字符的父文档
parent_chunks = []
for s in sections:
parent_chunks.extend(chunk_text(s, 2000, 40))
**练习3.2 ** 使用num_tokens_from_string函数统计每个父文档的token数。token数可辅助判断预处理是否需要额外操作,例如超过合理长度的长文档应继续拆分,而极短(20个token以下)片段应考虑删除,以避免无效信息干扰。
无需先拆分子文档再导入,你将把拆分与导入合并为一步完成,避免使用更复杂的数据结构存储中间结果。导入图数据前,需定义Cypher导入语句。父文档结构的导入语句相对简单。
代码示例3.7 导入父文档策略图的Cypher查询
cypher_import_query = """ #1
MERGE (pdf:PDF {id:$pdf_id}) #2
MERGE (p:Parent {id:$pdf_id + '-' + $id})
SET p.text = $parent
MERGE (pdf)-[:HAS_PARENT]->(p) #3
WITH p, $children AS children, $embeddings as embeddings
UNWIND range(0, size(children) - 1) AS child_index
MERGE (c:Child {id: $pdf_id + '-' + $id + '-' + toString(child_index)})
SET c.text = children[child_index], c.embedding = embeddings[child_index]
MERGE (p)-[:HAS_CHILD]->(c);
"""
#1 基于id合并PDF节点
#2 合并Parent节点并设置文本属性
#3 合并多个Child节点并关联到Parent节点
示例3.7的Cypher语句先合并PDF节点,然后用唯一ID合并Parent节点。Parent节点通过HAS_PARENT关系连接PDF节点,设置文本属性。随后遍历子文档列表,为每个元素创建Child节点,设置文本及嵌入属性,并通过HAS_CHILD关系关联对应Parent节点。
准备就绪后,即可将父文档结构导入图数据库。
代码示例3.8 导入父文档数据到图数据库
for i, chunk in enumerate(parent_chunks):
child_chunks = chunk_text(chunk, 500, 20) #1
embeddings = embed(child_chunks) #2
# 导入Neo4j
neo4j_driver.execute_query( #3
cypher_import_query,
id=str(i),
pdf_id='1709.00666',
parent=chunk,
children=child_chunks,
embeddings=embeddings,
)
注释
#1 将父文档拆分为子文档
#2 计算子文档的文本嵌入
#3 导入到Neo4j
示例3.8代码遍历父文档块,使用chunk_text拆分成多个子文档块,调用embed计算子文档嵌入,最后调用execute_query将数据导入Neo4j图数据库。
您可以通过Neo4j Browser运行以下Cypher语句查看生成的图结构。
代码示例3.9 查询PDF与父子节点关系图
MATCH p=(pdf:PDF)-[:HAS_PARENT]->()-[:HAS_CHILD]->()
RETURN p LIMIT 25
示例3.9语句生成的图如图3.5所示,图中中心PDF节点连接多个父节点,体现文档与章节的层级关系。每个父节点进一步连接多个子节点,表明章节被细分为更小的文档块。
为确保文档嵌入高效比对,接下来创建向量索引。
代码示例3.10 在子节点上创建向量索引
driver.execute_query("""CREATE VECTOR INDEX parent IF NOT EXISTS
FOR (c:Child)
ON c.embedding""")
示例3.10中创建的向量索引与第2章中使用的相同,这里是针对Child节点的embedding属性创建索引。
3.2.1 检索父文档策略数据
在导入数据并定义向量索引后,就可以专注于实现检索部分。为了从图数据库中检索相关文档,需定义如下示例中的检索Cypher语句。
代码示例3.11 父文档检索的Cypher语句
retrieval_query = """
CALL db.index.vector.queryNodes($index_name, $k * 4, $question_embedding) #1
YIELD node, score #2
MATCH (node)<-[:HAS_CHILD]-(parent) #3
WITH parent, max(score) AS score
RETURN parent.text AS text, score
ORDER BY score DESC #4
LIMIT toInteger($k)
"""
#1 向量索引搜索
#2 产出节点与分数
#3 关联至父文档节点
#4 按分数排序并限制返回数量
示例3.11中的Cypher语句首先在图数据库中基于向量执行搜索,识别与指定问题嵌入向量最匹配的子节点。初始向量搜索中返回的文档数为k乘以4。采用k * 4的原因是考虑到多个相似的子节点可能属于同一个父文档,因此需要对父文档进行去重。如果不去重,结果集中同一父文档可能出现多条记录,分别对应该父文档的不同子节点。为确保最终返回k个唯一的父文档,初始搜索扩大到k * 4个子节点,起到缓冲作用。语句末尾通过LIMIT限制最终返回数量为k。
下面的函数利用示例3.11中的Cypher语句,从数据库中检索父文档列表。
代码示例3.12 父文档检索函数
def parent_retrieval(question: str, k: int = 4) -> List[str]:
question_embedding = embed([question])[0]
similar_records, _, _ = neo4j_driver.execute_query(
retrieval_query,
question_embedding=question_embedding,
k=k,
index_name=index_name,
)
return [record["text"] for record in similar_records]
示例3.12中的parent_retrieval函数先为给定问题生成文本嵌入向量,然后调用上述Cypher语句,从数据库检索最相关的文档列表。
3.3 完整的RAG流水线
流水线的最后一环是生成答案的函数。
代码示例3.13 使用LLM生成答案
system_message = "You're an Einstein expert, but can only use the provided documents to respond to the questions."
def generate_answer(question: str, documents: List[str]) -> str:
user_message = f"""
Use the following documents to answer the question that will follow:
{documents}
---
The question to answer using information only from the above documents: {question}
"""
result = chat(
messages=[
{"role": "system", "content": system_message},
{"role": "user", "content": user_message},
]
)
print("Response:", result)
示例3.13的代码与第2章中完全相同。您将问题和相关文档传给LLM,提示其生成答案。
完成回退式提示和父文档检索后,即可将这些步骤整合到一个函数中。
代码示例3.14 结合回退式提示的完整父文档检索RAG流水线
def rag_pipeline(question: str) -> str:
stepback_prompt = generate_stepback(question)
print(f"Stepback prompt: {stepback_prompt}")
documents = parent_retrieval(stepback_prompt)
answer = generate_answer(question, documents)
return answer
示例3.14中的rag_pipeline函数接收一个问题作为输入,生成回退式提示,基于该提示检索相关文档,并将文档和原始问题一起传给LLM生成最终答案。
您现在可以测试rag_pipeline的实现。
代码示例3.15 测试完整的带回退式提示的父文档检索RAG流水线
rag_pipeline("When was Einstein granted the patent for his blouse design?")
# Stepback prompt: What are some notable achievements in Einstein's life?
# Response: Einstein was granted the patent for his blouse design on October 27, 1936.
练习3.3 通过提出PDF中关于爱因斯坦生平的其他问题,评估rag_pipeline实现的表现。您也可以去掉回退式提示步骤,比较其对结果的影响。
恭喜您!您已成功实现了结合查询重写和父文档检索的高级向量搜索检索策略。
总结:
- 查询重写可以提升文档检索的准确率,使用户查询更贴近目标文档的语言和语境。
- 假设文档检索器和回退式提示等技术有效弥合用户意图与文档内容的差距,减少遗漏相关信息的概率。
- 检索系统的效果可通过嵌入不仅限于原文,还包含上下文相关摘要或释义来增强,捕捉文档核心内容。
- 实施假设性问题嵌入和父文档检索策略,有助于实现查询与文档的更精准匹配,提升检索相关性和准确性。
- 将文档拆分为更小、更易管理的块进行嵌入,能够更细粒度地进行信息检索,确保具体查询找到最相关的文档片段。