句子窗口检索:高级RAG策略实战指南
此前我们探讨过大语言模型(LLM)中的 RAG(Retrieval Augmented Generation)技术。随着 LLM 迭代,更高级的 RAG 检索方法陆续出现。相比于基础 RAG,高级 RAG 在技术深度和搜索策略上更为精细,输出结果在准确性、相关性和信息丰富度上均有显著提升。本文聚焦其中一种——句子窗口检索。
句子窗口检索详解
在展开句子窗口检索细节前,先快速回顾基础 RAG 的操作流程:
将文档切割成固定大小的文本块
对文本块进行向量化(Embedding)后存入向量数据库
依据用户问题检索出 Embedding 相似度最高的 K 个文本块
将问题与检索结果拼接后输入 LLM 生成最终答案
基础 RAG 的典型瓶颈是:文本块过大时,检索结果常包含大量无关内容,导致 LLM 输出偏离。句子窗口检索如何破解这一难题?其流程如下:
与基础 RAG 不同,句子窗口检索的切分粒度更细——以句子为基本单元
检索时,不仅返回匹配度最高的句子,还会将该句子的前后文一同传递给 LLM
这种设计既保证了检索结果的精准度,又通过上下文窗口保留了语义的完整性,实现精准与丰富的平衡。
核心原理
句子窗口检索的实现思路清晰直接:先按句子粒度切分文档,完成向量化后存入数据库。检索阶段,系统根据问题定位到最相关的句子,但返回时额外携带该句子前后的若干句子(窗口大小可通过参数调整)。最终将整个窗口与问题一同送入 LLM 生成答案。
借助代码理解更直观。在 RAG 框架中,LlamaIndex[1] 对句子窗口检索提供了完善支持,下面通过示例演示。
from llama_index.core.node_parser import SentenceWindowNodeParser
from llama_index.core.schema import Document
node_parser = SentenceWindowNodeParser.from_defaults(
window_size=3,
window_metadata_key="window",
original_text_metadata_key="original_text",
)
text = "hello. how are you? I am fine! Thank you. And you? I am fine too. "
nodes = node_parser.get_nodes_from_documents([Document(text=text)])
使用
SentenceWindowNodeParser实例化解析器,设置window_size=3,表示窗口最多容纳 7 个句子:检索到的句子本身,加上前 3 个和后 3 个解析后的节点包含两个元数据字段:
window(窗口内容)和original_text(原始句子)window_metadata_key存储窗口内所有句子的拼接文本,original_text_metadata_key存储被检索到的单一句子最后,调用解析器对文档进行切分并生成节点列表
注意:旧版中,当 window_size=3 时,窗口仅包含检索句子后面的 2 个句子,总计 6 个句子。新版将核心逻辑迁移至 llama-index-core 后,默认改为包含后面 3 个句子,详情参见官方仓库源码[2]。
查看解析后的第一个节点:
print(nodes[0].metadata)
# 输出
{'window': 'hello.how are you?I am fine!Thank you. ', 'original_text': 'hello. '}
当检索到的句子是文本首句时,由于前面无句子,窗口仅包含自身及后 3 个句子,共 4 个。
print(nodes[3].metadata)
# 输出
{'window': 'hello.how are you?I am fine!Thank you.And you?I am fine too. ', 'original_text': 'Thank you. '}
当检索到第四个句子时,窗口应包含前 3 个、本身、后 3 个,但后面只有 2 个,因此实际包含 6 个句子。
中文场景的句子切分
句子窗口解析器默认依据英文标点(.?!)切分句子。处理中文时,该规则失效。但我们可以通过自定义切分函数适配:
import re
def sentence_splitter(text):
nodes = re.split("(?<=。)|(?<=?)|(?<=!)", text)
nodes = [node for node in nodes if node]
return nodes
node_parser = SentenceWindowNodeParser.from_defaults(
window_size=3,
window_metadata_key="window",
original_text_metadata_key="original_text",
sentence_splitter=sentence_splitter,
)
此处传入 sentence_splitter 参数,该函数以中文句号、问号、感叹号为分隔符进行切分。
text = "你好。你好吗?我很好!谢谢。你呢?我也很好。"
print(nodes[0].metadata)
print(nodes[3].metadata)
# 输出
{'window': '你好。你好吗?我很好!谢谢。', 'original_text': '你好。'}
{'window': '你好。你好吗?我很好!谢谢。你呢?我也很好。', 'original_text': '谢谢。'}
替换规则后,中文文本的解析效果与英文一致。
句子窗口检索实战
下面展示句子窗口检索在实际 RAG 项目中的应用。测试数据沿用维基百科《复仇者联盟》[3] 电影剧情。
基础 RAG 检索示例
先演示基础 RAG 的文档切分与检索效果:
from llama_index.core import SimpleDirectoryReader
from llama_index.core.node_parser import SentenceSplitter
from llama_index.core.settings import Settings
from llama_index.core import VectorStoreIndex
from llama_index.embeddings.openai import OpenAIEmbedding
documents = SimpleDirectoryReader("./data").load_data()
text_splitter = SentenceSplitter()
llm = OpenAI(model="gpt-3.5-turbo", temperature=0.1)
embed_model = OpenAIEmbedding()
Settings.llm = llm
Settings.embed_model = embed_model
Settings.node_parser = text_splitter
base_index = VectorStoreIndex.from_documents(
documents=documents,
)
base_engine = base_index.as_query_engine(
similarity_top_k=2,
)
从
data目录加载文档,使用SentenceSplitter作为解析器——它确保切分后的块包含完整句子,不会把一句话拆成两半用 OpenAI 的 Embedding 和 LLM 模型,新版 LlamaIndex 用
Setting替换了原来的ServiceContext创建查询引擎,只取相似度最高的 2 个文档作为上下文
测试结果:
question = "奥创是由哪两位复仇者联盟成员创造的?"
response = base_engine.query(question)
print(f"response: {response}")
print(f"len: {len(response.source_nodes)}")
text = response.source_nodes[0].node.text
print("------------------")
print(f"Text: {text}")
text = response.source_nodes[1].node.text
print("------------------")
print(f"Text: {text}")
# 输出
response: 奥创是由托尼·斯塔克和布鲁斯·班纳这两位复仇者联盟成员创造的。
len: 2
---------------Text: 神盾局解散后,由托尼·斯塔克、史蒂芬·罗杰斯、雷神、娜塔莎·罗曼诺夫、布鲁斯·班纳以及克林特·巴顿组成的复仇者联盟负责全力搜查九头蛇的下落……(略)Text: 奥创来到克劳位于南非的武器船厂获取所有振金,并砍断克劳的左手……(略)答案正确,因为检索到的文档确实包含了相关信息
返回了 2 个相关文档,按相似度排序
句子窗口检索示例
再来看看句子窗口检索的效果:
from llama_index.core.node_parser import SentenceWindowNodeParser
from llama_index.core.indices.postprocessor import MetadataReplacementPostProcessor
node_parser = SentenceWindowNodeParser.from_defaults(
window_size=3,
window_metadata_key="window",
original_text_metadata_key="original_text",
)
documents = SimpleDirectoryReader("./data").load_data()
llm = OpenAI(model="gpt-3.5-turbo", temperature=0.1)
embed_model = OpenAIEmbedding()
Settings.llm = llm
Settings.embed_model = embed_model
Settings.node_parser = node_parser
sentence_index = VectorStoreIndex.from_documents(
documents=documents,
)
postproc = MetadataReplacementPostProcessor(target_metadata_key="window")
sentence_window_engine = sentence_index.as_query_engine(
similarity_top_k=2, node_postprocessors=[postproc]
)
这里用了
SentenceWindowNodeParser做解析器,前面已经介绍过还多了个
MetadataReplacementPostProcessor,作用是把检索结果替换成window元数据的值——也就是替换成完整的上下文窗口
测试结果:
response = sentence_window_engine.query(question)
print(f"response: {response}")
print(f"len: {len(response.source_nodes)}")
window = response.source_nodes[0].node.metadata["window"]
sentence = response.source_nodes[0].node.metadata["original_text"]
print("------------------")
print(f"Window: {window}")
print("------------------")
print(f"Original Sentence: {sentence}")
window = response.source_nodes[1].node.metadata["window"]
sentence = response.source_nodes[1].node.metadata["original_text"]
print("------------------")
print(f"Window : {window}")
print("------------------")
print(f"Original Sentence: {sentence}")
# 输出
response: 奥创是由托尼·斯塔克和布鲁斯·班纳创造的。
len: 2
---------------Window: 神盾局解散后,由托尼·斯塔克、史蒂芬·罗杰斯、雷神、娜塔莎·罗曼诺夫、布鲁斯·班纳以及克林特·巴顿组成的复仇者联盟负责全力搜查九头蛇的下落……(略)Original Sentence: 神盾局解散后,由托尼·斯塔克、史蒂芬·罗杰斯、雷神、娜塔莎·罗曼诺夫、布鲁斯·班纳以及克林特·巴顿组成的复仇者联盟负责全力搜查九头蛇的下落……(略)Window : 合成器启动使整块陆地全速下坠,托尼与托尔联手使合成器超载……(略)Original Sentence: 其他复仇者们得到一个由弗瑞、希尔、海伦与埃里克组建的复仇者基地……(略)答案依然正确,但检索到的文档内容比普通 RAG 少——因为窗口只聚焦在匹配句子的上下文附近
窗口的句子数量和我们之前说的规则一致:包含原句前 3 句、原句本身、后 3 句(不足则取全部)
检索效果定量对比
以上两个示例均能正确回答问题,但优劣难以直观分辨。我们借助此前介绍的 LLM 评估框架 Trulens[4] 进行定量比较。
from trulens_eval import Tru, Feedback, TruLlama
from trulens_eval.feedback.provider.openai import OpenAI as Trulens_OpenAI
from trulens_eval.feedback import Groundedness
tru = Tru()
openai = Trulens_OpenAI()
def rag_evaluate(query_engine, eval_name):
grounded = Groundedness(groundedness_provider=openai)
groundedness = (
Feedback(grounded.groundedness_measure_with_cot_reasons, name="Groundedness")
.on(TruLlama.select_source_nodes().node.text)
.on_output()
.aggregate(grounded.grounded_statements_aggregator)
)
qa_relevance = Feedback(
openai.relevance_with_cot_reasons, name="Answer Relevance"
).on_input_output()
qs_relevance = (
Feedback(openai.qs_relevance_with_cot_reasons, name="Context Relevance")
.on_input()
.on(TruLlama.select_source_nodes().node.text)
)
tru_query_engine_recorder = TruLlama(
query_engine,
app_id=eval_name,
feedbacks=[
groundedness,
qa_relevance,
qs_relevance,
],
)
with tru_query_engine_recorder as recording:
query_engine.query(question)
定义了一个评估函数,接收引擎和名称两个参数
用了 Trulens 的三个指标:
groundedness(答案是否基于检索内容)、qa_relevance(答案与问题的相关性)、qs_relevance(上下文与问题的相关性)
运行评估:
tru.reset_database()
rag_evaluate(base_engine, "base_evaluation")
rag_evaluate(sentence_window_engine, "sentence_window_evaluation")
Tru().run_dashboard()
Trulens 的 Web 面板显示,句子窗口检索并非在所有指标上均优于基础 RAG,部分场景下甚至略逊。这表明仍需进一步调优——例如调节 window_size 参数,以充分发挥句子窗口检索的优势。
小结与展望
RAG 能应对 LLM 应用中的大多数挑战,但并非银弹。高级 RAG 检索同样存在局限。最终需结合实际业务需求,选取恰当策略,并通过参数调优、文档优化等方式持续改进 RAG 应用效果。