链载Ai

标题: RAG 通过从外部知识库检索相关文档 [打印本页]

作者: 链载Ai    时间: 2025-12-2 09:52
标题: RAG 通过从外部知识库检索相关文档

一、RAG简介

大型语言模型(LLM)已经取得了显著的成功,尽管它们仍然面临重大的限制,特别是在特定领域或知识密集型任务中,尤其是在处理超出其训练数据或需要当前信息的查询时,常会产生“幻觉”现象。为了克服这些挑战,检索增强生成(RAG)通过从外部知识库检索相关文档chunk并进行语义相似度计算,增强了LLM的功能。通过引用外部知识,RAG有效地减少了生成事实不正确内容的问题。RAG目前是基于LLM系统中最受欢迎的架构,有许多产品基于RAG构建,使RAG成为推动聊天机器人发展和增强LLM在现实世界应用适用性的关键技术。

二、RAG架构

2.1 RAG实现过程

RAG在问答系统中的一个典型应用主要包括三个步骤:

2.2 RAG在线检索架构

三、RAG流程

接下来,我们将深入探讨RAG各个流程,并为RAG构建技术路线图。

3.1 索引

索引是将文本分解成可管理的chunk的过程,是组织系统的关键步骤,面临三个主要挑战:

3.1.1 Chunking

Transformer模型有固定的输入序列长度,即使输入上下文窗口很大,一个句子或几个句子的向量也比几页文本的平均向量更能代表它们的语义意义。所以我们需要对数据进行分块,将初始文档分割成一定大小的chunk,同时不丢失它们的意义(将文本分割成句子或段落,而不是将一个句子分成两部分)。

有多种文本切分策略能够完成这项任务,我们在实践中采用了以下3种策略:

chunk的大小是一个需要重点考虑的参数,它取决于我们使用的Embedding模型及其token的容量。标准的Transformer编码器模型,如基于BERT的Sentence Transformer最多处理512个token,而OpenAI的text-embedding-3-small能够处理更长的序列(8191个token)。

为了给LLM提供足够的上下文以进行推理,同时给搜索提供足够具体的文本嵌入,我们需要一些折衷策略。较大的chunk可以捕获更多的上下文,但它们也会产生更多的噪音,需要更长的处理时间和更高的成本。而较小的chunk可能无法完全传达必要的上下文,但它们的噪音较少。

以网页https://www.openim.io/en的文本内容为输入,按照上面3种策略进行文本分割。

1. 直接分段

切分后的chunk信息,总共10个chunk:

defsplit_long_section(section,max_length=1300):lines=section.split('\n')current_section=""result=[]forlineinlines:#Add1fornewlinecharacterwhencheckingthelengthiflen(current_section)+len(line)+1>max_length:ifcurrent_section:result.append(current_section)current_section=line#Startanewparagraphelse:#Ifasinglelineexceedsmaxlength,treatitasitsownparagraphresult.append(line)else:ifcurrent_section:current_section+='\n'+lineelse:current_section=line


2. 生成问答对

切分后的chunk信息,总共28个chunk,每个chunk包含一对问答:


切分后的某个chunk的问答对信息:

3. 增强信息

切分后的chunk信息,总共6个chunk,每个chunk都包含一批数据索引信息:


切分后的某个chunk的数据索引信息:


3.1.1.1 滑动窗口

平衡这些需求的一种简单方法是使用重叠的chunk。通过使用滑动窗口,可以增强语义过渡。然而,也存在一些限制,包括对上下文大小的控制不精确、有截断单词或句子的风险,以及缺乏语义考虑。

final_result=[]ast_lines=""forsectioninresult:lines=section.split('\n')last_two_lines="\n".join(lines[-2:])#Extractthelasttwolinescombined_section=last_lines+"\n"+sectioniflast_lineselsesectionfinal_result.append(combined_section)last_lines=last_two_lines
3.1.1.2 上下文丰富化

这里的概念是为了获得更好的搜索质量而检索较小的chunk,并添加周围的上下文供LLM进行推理。
有两个选项:通过在较小的检索chunk周围添加句子来扩展上下文,或者将文档递归地分成多个较大的父chunk,其中包含较小的子chunk。

句子窗口检索

在这个方案中,文档中的每个句子都被单独嵌入,这提供了查询与上下文余弦距离搜索的高准确性。
为了在获取到最相关的单个句子后更好地推理出找到的上下文,我们通过在检索到的句子之前和之后添加k个句子来扩展上下文窗口,然后将这个扩展后的上下文发送给LLM。

from llama_index import ServiceContext, VectorStoreIndex, StorageContextfrom llama_index.node_parser import SentenceWindowNodeParser
def build_sentence_window_index(document, llm, vector_store, embed_model="local:BAAI/bge-small-en-v1.5"):# create the sentence window node parser w/ default settingsnode_parser = SentenceWindowNodeParser.from_defaults(window_size=3,window_metadata_key="window",original_text_metadata_key="original_text",)sentence_context = ServiceContext.from_defaults(llm=llm,embed_model=embed_model,node_parser=node_parser)storage_context = StorageContext.from_defaults(vector_store=vector_store)sentence_index = VectorStoreIndex.from_documents([document], service_context=sentence_context, storage_context=storage_context)
return sentence_index


父文档检索器

文档被分割成一个层次结构的chunk,然后最小的叶子chunk被发送到索引中。在检索时,我们检索k个叶子chunk,如果有n个chunk引用同一个父chunk,我们将它们替换为该父chunk并将其发送给LLM进行答案生成。

关键思想是将用于检索的chunk与用于合成的chunk分开。使用较小的chunk可以提高检索的准确性,而较大的chunk可以提供更多的上下文信息。具体来说,一种方法可以涉及检索较小的chunk,然后引用父ID以返回较大的chunk。或者,可以检索单个句子,并返回该句子周围的文本窗口。

sub_chunk_sizes = [128, 256, 512]sub_node_parsers = [SimpleNodeParser.from_defaults(chunk_size=c) for c in sub_chunk_sizes]
all_nodes = []for base_node in base_nodes:for n in sub_node_parsers:sub_nodes = n.get_nodes_from_documents([base_node])sub_inodes = [IndexNode.from_text_node(sn, base_node.node_id) for sn in sub_nodes]all_nodes.extend(sub_inodes)
# also add original node to nodeoriginal_node = IndexNode.from_text_node(base_node, base_node.node_id)all_nodes.append(original_node)all_nodes_dict = {n.node_id: n for n in all_nodes}
3.1.1.3 元数据附加

可以使用元数据信息对chunk进行丰富,例如URL文件名作者时间戳摘要,或者chunk可以回答的问题。随后,可以根据这些元数据对检索进行筛选,限制搜索范围。

async def aadd_content_embedding(self, data):for item in data:documents_to_add = []# Initialize a list to hold all document parts for batch processing.timestamp = int(time.time())doc_id, url, chunk_text_vec = itempart_index = 0for part_content in chunk_text_vec:# Process each part of the content.# Construct metadata for each document part, ensuring each part has a unique ID.metadata = {"source": url, "id": f"{doc_id}-part{part_index}"}# Create a Document object with the part content and metadata.doc = Document(page_content=part_content, metadata=metadata)# Add the document part to the list for batch addition.documents_to_add.append(doc)part_index += 1
# Check if there are document parts to add.if documents_to_add:# Add all document parts to Chroma in a single batch operation.embedding_id_vec = await self.chroma_obj.aadd_documents(documents_to_add)logger.info(f"[DOC_EMBEDDING] doc_id={doc_id}, url={url} added {len(documents_to_add)} document parts to Chroma., embedding_id_vec={embedding_id_vec}")

3.1.2 向量化

在构建RAG应用程序时,“使用哪个Embedding模型”没有一个适用于所有情况的标准答案。实践中,我们选择的是OpenAI的text-embedding-3-small来计算chunk的向量。

from langchain_openai import OpenAIEmbeddings
OPENAI_EMBEDDING_MODEL_NAME = os.getenv('OPENAI_EMBEDDING_MODEL_NAME', 'text-embedding-3-small')
# Initialize OpenAI embeddings with the specified modelg_embeddings = OpenAIEmbeddings(model=OPENAI_EMBEDDING_MODEL_NAME,openai_api_key=OPENAI_API_KEY)

3.1.3 搜索索引

3.1.3.1 向量存储索引

RAG流程中的关键部分是搜索索引,存储chunk的向量化内容。最简单的实现使用平面索引,在查询向量和所有chunk向量之间进行距离计算。

一个优秀的搜索索引,需要确保在大规模元素上的检索效率,一般使用某种近似最近邻实现,如聚类HNSW算法

NOTE:

如果使用Chroma作为向量存储DB,需要留意hnsw:space的默认值是l2,实践中建议调整为cosine

修改Chrome的距离函数

create_collection接受一个可选的metadata参数,该参数可以用来通过设置hnsw:space 的值来自定义嵌入空间的距离方法。

class DocumentEmbedder:
def __init__(self, collection_name, embedding_function, persist_directory):logger.info(f"[DOC_EMBEDDING] init, collection_name:'{collection_name}', persist_directory:{persist_directory}")collection_metadata = {"hnsw:space": "cosine"}self.chroma_obj = Chroma(collection_name=collection_name,embedding_function=embedding_function,persist_directory=persist_directory,collection_metadata=collection_metadata)

除了向量索引,还可以考虑支持其它更简单的索引实现,如列表索引树索引关键词表索引

3.1.3.2 层级索引

如果需要从许多文档中检索信息,我们需要能够高效地在其中搜索,找到相关信息并将其合成一个带有来源引用的单一答案。对于大型数据库,一种有效的方法是创建两个索引,一个由摘要组成,另一个由文档chunk组成,并进行两步搜索,首先通过摘要筛选出相关文档,然后仅在这个相关组内进行搜索。

层级索引的应用实例:

3.2 Preprocess Query

RAG的一个主要挑战是它直接依赖用户的原始查询作为检索的基础。制定一个精确和清晰的问题很难,而轻率的查询结果会导致检索效果不佳。

这一阶段的主要挑战包括:

3.2.1 Query Expansion

将单一查询扩展为多个查询可以丰富查询的内容,提供更多的上下文来解决缺乏特定细微差别的问题,从而确保生成答案的最佳相关性。

3.2.2 Query Transformation

查询转换是一系列技术,使用LLM作为推理引擎修改用户输入以提高检索质量。有几种不同的方法可以实现这一点。

如果查询很复杂,LLM可以将其分解为几个子查询。例如,如果你问:

这些查询将并行执行,然后将检索到的上下文合并在一个提示中,供LLM合成对初始查询的最终答案。

  1. 退后式提示,使用LLM生成一个更一般的查询,为其检索我们获得的更一般或高层次的上下文,有助于支撑我们对原始查询的回答。也会对原始查询执行检索,两种上下文都会在最终答案生成步骤中输入到LLM中。

  2. 查询重写,原始查询并不总是最适合LLM检索的,特别是在现实世界的场景中。因此,我们可以提示LLM重写查询。

OpenIM文档网站使用rag-gpt搭建了网站智能客服,可以快速验证查询重写策略的效果。

defpreprocess_query(query,site_title):#Converttolowercaseforcase-insensitivecomparisonquery_lower=query.lower()site_title_lower=site_title.lower()#Checkifthesitetitleisalreadyincludedinthequeryifsite_title_lowernotinquery_lower:adjust_query=f"{query}\t{site_title}"logger.warning(f"adjust_query:'{adjust_query}'")returnadjust_queryreturnquery

3.2.3 Query Construction

将用户查询转换成其他查询语言以访问替代数据源。常见的方法包括:

在许多场景中,结构化查询语言(例如,SQL、Cypher)常与语义信息和元数据结合使用,以构建更复杂的查询。

3.2.4 Query Routing

查询路由是一个基于LLM的决策制定步骤,针对用户的查询决定接下来要做什么。通常的选项包括进行总结、对某些数据索引执行搜索,或尝试多种不同的路由然后将它们的输出合成一个答案。

查询路由器也用于选择一个索引,或者更广泛地说,一个数据存储位置,来发送用户查询。无论是你拥有多个数据源,例如经典的向量存储、图数据库或关系数据库,还是你拥有一个索引层级。对于多文档存储来说,一个典型的案例可能是一个摘要索引和另一个文档chunk向量索引。

定义查询路由器包括设置它可以做出的选择。路由选项的选择是通过LLM调用执行的,其结果以预定义格式返回,用于将查询路由到给定的索引,或者,如果我们谈论的是族群行为,路由到子链或甚至如下所示的多文档代理方案中的其他代理。

from langchain.utils.math import cosine_similarityfrom langchain_core.output_parsers import StrOutputParserfrom langchain_core.prompts import PromptTemplatefrom langchain_core.runnables import RunnableLambda, RunnablePassthroughfrom langchain_openai import OpenAIEmbeddings
physics_template = f"""You are a very smart physics professor.You are great at answering questions about physics in a concise and easy to understand manner.When you don't know the answer to a question you admit that you don't know.
Here is a question:{query}"""
math_template = f"""You are a very good mathematician. You are great at answering math questions.You are so good because you are able to break down hard problems into their component parts,answer the component parts, and then put them together to answer the broader question.
Here is a question:{query}"""
embeddings = OpenAIEmbeddings()prompt_templates = [physics_template, math_template]prompt_embeddings = embeddings.embed_documents(prompt_templates)
def prompt_router(input):query_embedding = embeddings.embed_query(input["query"])similarity = cosine_similarity([query_embedding], prompt_embeddings)[0]most_similar = prompt_templates[similarity.argmax()]print("Using MATH" if most_similar == math_template else "Using PHYSICS")return PromptTemplate.from_template(most_similar)
chain = ({"query": RunnablePassthrough()}| RunnableLambda(prompt_router)| ChatAnthropic(model_name="claude-3-haiku-20240307")| StrOutputParser())

3.3 检索

在RAG中,检索过程扮演着至关重要的角色。利用预训练语言模型可以有效地表示查询和文本在潜在空间中,从而促进问题和文档之间的语义相似性建立,以支持检索。

需要考虑以下三个主要因素:

3.3.1 稀疏检索器

虽然稀疏编码模型可能被认为是一种有些过时的技术,通常基于统计方法,如词频统计,但由于其更高的编码效率和稳定性,它们仍然占有一席之地。常见的系数编码模型包括BM25TF-IDF

3.3.2 密集检索器

基于神经网络的密集编码模型包括几种类型:

3.3.3 混合检索

两种嵌入方法捕获不同的相关性特征,并且通过利用互补的相关性信息,可以相互受益。例如,稀疏检索模型可以用来提供训练密集检索模型的初步搜索结果。此外,预训练语言模型可以用来学习术语权重以增强稀疏检索。具体来说,它还表明稀疏检索模型可以增强密集检索模型的零样本检索能力,并帮助密集检索器处理包含罕见实体的查询,从而提高鲁棒性。

3.4 Post-Retrieval

直接检索整个文档chunk并将它们直接输入到LLM的上下文环境中并非最佳选择。后处理文档可以帮助LLM更好地利用上下文信息。

主要挑战包括:

3.4.1 Reranking

不改变内容或长度的情况下,对检索到的文档chunk进行重新排序,以增强对LLM更为关键的文档chunk的可见性。具体来说:

  1. 基于规则的重新排序

    根据特定规则,计算度量来重新排序chunk。常见的度量包括:






欢迎光临 链载Ai (https://www.lianzai.com/) Powered by Discuz! X3.5