rag构建及其优化

Rag

1.0 基于llamaindex 构建简单rag系统

import os
from llama_cloud_services import LlamaParse
from llama_index.core import Settings, StorageContext, load_index_from_storage
from llama_index.core.base.embeddings.base import similarity
from llama_index.core.node_parser import SentenceSplitter
from llama_index.core.postprocessor import SentenceTransformerRerank
from llama_index.llms.dashscope import DashScope, DashScopeGenerationModels
from llama_index.embeddings.dashscope import DashScopeEmbedding, DashScopeTextEmbeddingModels
from llama_index.core import VectorStoreIndex, SimpleDirectoryReader
from sentence_transformers import SentenceTransformer
from llama_index.core.query_pipeline import QueryPipeline
from llama_index.core.retrievers import VectorIndexRetriever
from llama_index.core.response_synthesizers import TreeSummarize

Settings.llm=DashScope(
    model_name=DashScopeGenerationModels.QWEN_PLUS,
    api_key=os.getenv("DASHSCOPE_API_KEY")
)
Settings.embed_model=DashScopeEmbedding(
    model_name=DashScopeTextEmbeddingModels.TEXT_EMBEDDING_V3,
    api_key=os.getenv("DASHSCOPE_API_KEY")
)
PERSIST_DIR="./storage"
if os.path.exists(PERSIST_DIR):
    storage_context=StorageContext.from_defaults(persist_dir=PERSIST_DIR)
    index=load_index_from_storage(storage_context)
else:
    parser=LlamaParse(result_type="markdown",
                      api_key="you_api")
    splitter=SentenceSplitter(
        chunk_size=1024,
        chunk_overlap=100,
    )
    documents=SimpleDirectoryReader(
        input_dir="./data",
        file_extractor={
            ".pdf":parser
        }
    ).load_data()
    nodes=splitter.get_nodes_from_documents(documents)
    index=VectorStoreIndex(nodes)
    index.storage_context.persist(persist_dir=PERSIST_DIR)

reranker = SentenceTransformerRerank(
    model="BAAI/bge-reranker-base",
    top_n=3  # 重排后只保留前3个最相关的
)
query_engine=index.as_query_engine(
    similarity_top_k=5,
    node_postprocessors=[reranker]
)
response=query_engine.query("介绍一下该公司的主要财务指标")
print(response)
# 方法二
# 自定义每个步骤
# retriever = VectorIndexRetriever(index=index, similarity_top_k=10)
# reranker = SentenceTransformerRerank(model="BAAI/bge-reranker-base", top_n=3)
# synthesizer = TreeSummarize(llm=Settings.llm)
#
# # 组装 Pipeline
# pipeline = QueryPipeline(verbose=True)
# pipeline.add_modules({
#     "retriever": retriever,
#     "reranker": reranker,
#     "synthesizer": synthesizer
# })
# pipeline.add_link("retriever", "reranker")
# pipeline.add_link("reranker", "synthesizer")
#
# # 执行查询
# response = pipeline.run(input="介绍一下该公司的主要财务指标")




当前使用了 文档切分策略 topk rerank

最后获得输出用了两种方式,一种是基础方式,另一种是使用pipline ,这是llamaindex中提供的类似于langchain

中LCE 的东西

pipeline最后的是响应合成器,基础方式其实也会使用默认的响应合成器,下面是不同响应合成器的介绍,

1.1. TreeSummarize (树状总结)

工作原理: 采用自底向上的树状层次化总结

from llama_index.core.response_synthesizers import TreeSummarize

synthesizer = TreeSummarize(llm=Settings.llm)

处理流程:

文档1+文档2 → [LLM] → 中间总结A
文档3+文档4 → [LLM] → 中间总结B
总结A+总结B → [LLM] → 最终答案

特点:

  • ✅ 适合处理大量文档(超过10个)

  • ✅ 减少单次 token 消耗

  • ✅ 避免上下文长度限制

  • ❌ 可能丢失部分细节信息

  • ❌ 需要多次 LLM 调用

    其中每一个箭头都是一次大模型调用

1.2 SimpleSummarize (简单总结)

工作原理: 一次性将所有文档拼接后发给 LLM

from llama_index.core.response_synthesizers import SimpleSummarize

synthesizer = SimpleSummarize(llm=Settings.llm)

处理流程:

所有文档 + 问题 → [LLM调用1次] → 最终答案

特点:

  • ✅ 最简单直接
  • ✅ 保留所有文档信息
  • ✅ LLM 调用次数最少(1次)
  • ❌ 文档过多会超出 token 限制
  • ❌ 不适合大规模检索结果

适用场景: 检索到的文档少(3~5个)且信息集中

1.3 Refine (迭代优化)

工作原理: 逐个文档迭代优化答案

from llama_index.core.response_synthesizers import Refine

synthesizer = Refine(llm=Settings.llm)

处理流程:

问题 + 文档1 → [LLM] → 初始答案
初始答案 + 文档2 → [LLM] → 优化答案1
优化答案1 + 文档3 → [LLM] → 优化答案2
...
最终优化答案

特点:

  • ✅ 答案质量最高,逐步精炼
  • ✅ 充分利用每个文档的信息
  • ❌ LLM 调用次数 = 文档数量(很慢)
  • ❌ 成本高,不适合实时应用

适用场景: 对答案质量要求极高,不在意响应速度

1.4 CompactAndRefine (压缩+优化,默认模式)

工作原理: 先压缩文档再迭代优化

from llama_index.core.response_synthesizers import CompactAndRefine

synthesizer = CompactAndRefine(llm=Settings.llm)

处理流程:

1. 将多个文档压缩成几个"块"(尽量填满上下文窗口)
2. 对每个块执行 Refine 流程

特点:

  • ✅ 平衡速度和质量
  • ✅ 减少 LLM 调用次数
  • ✅ 充分利用上下文窗口
  • LlamaIndex 的默认选择

适用场景: 通用场景,性价比最高

简单解释就是将当前检索出来的文档片段进行智能组合,然后迭代优化答案

文档1(1000 tokens)
文档2(1000 tokens)
文档3(1000 tokens)
文档4(1000 tokens)
文档5(1000 tokens)

# CompactAndRefine 的"压缩"操作
块1 = 文档1 + 文档2 + 文档3(3000 tokens,接近4000限制)
块2 = 文档4 + 文档5(2000 tokens)
# 第1次 LLM 调用
问题 + 块1(文档1+2+3) → [LLM] → 初始答案

# 第2次 LLM 调用
初始答案 + 块2(文档4+5) → [LLM] → 最终答案

相比Refine 更节省时间,减少了大模型调用次数


对比表格

合成器 LLM调用次数 质量 速度 Token消耗 适用场景
SimpleSummarize 1次 ★★ ★★★★★ 高(一次性) 文档少(≤5个)
TreeSummarize log(N) ★★★ ★★★ 中等 文档多(≥10个)
Refine N次 ★★★★★ 最高 极高质量需求
CompactAndRefine 2~5次 ★★★★ ★★★★ 中等 通用推荐

方法1: 通过 response_mode 指定

query_engine = index.as_query_engine(
    similarity_top_k=5,
    node_postprocessors=[reranker],
    response_mode="tree_summarize"  # 可选: refine, compact, simple_summarize
)

方法2: 在 Pipeline 中显式使用

from llama_index.core.response_synthesizers import get_response_synthesizer

synthesizer = get_response_synthesizer(
    response_mode="tree_summarize",
    llm=Settings.llm
)

pipeline = QueryPipeline(verbose=True)
pipeline.add_modules({
    "retriever": retriever,
    "reranker": reranker,
    "synthesizer": synthesizer
})

推荐选择

  • 你的当前场景(财务报告检索): 保持默认 compact 即可
  • 文档特别多(>20个): 使用 tree_summarize
  • 需要极高精度: 使用 refine(但要接受慢速度)
  • 快速原型验证: 使用 simple_summarize

如果我们使用第一种方式不指定topk和rerank,topk 默认是2,默认不会进行rerank ,使用的响应合成器是CompactAndRefine

1.2 利用oai sdk构建简单rag系统

创建pdf读取函数:

def extract_text_from_pdf(pdf_path):
    """
    从PDF文件中提取文本并打印前`num_chars`个字符。

    参数:
    pdf_path (str): PDF文件的路径。

    返回:
    str: 从PDF中提取的文本。
    """
    with fitz.open(pdf_path) as mypdf:
        result = ""
        for page in mypdf:
            result += page.get_text("text")
    return result

创建chunk分割函数:

def chunk_text(text,n,overlap):
    """
    将给定的文本分割为长度为 n 的段,并带有指定的重叠字符数。

    参数:
    text (str): 需要分割的文本。
    n (int): 每个片段的字符数量。
    overlap (int): 段与段之间的重叠字符数量。

    返回:
    List[str]: 一个包含文本片段的列表。
    """
    chunks=[]
    for i in range(0,len(text),n-overlap):
        chunks.append(text[i:i+n])
    return chunks

进行embedding

def create_embeddings(text,embedding_model_name):
    """
    使用指定的OpenAI模型为给定文本创建嵌入。

    参数:
    text (str): 需要为其创建嵌入的输入文本。
    model (str): 用于创建嵌入的模型。

    返回:
    dict: 包含嵌入结果的OpenAI API回复。
    """
    response=client.embeddings.create(
        input=text,
        model=embedding_model_name
    )
    return response

该函数传入的内容可以是单个字符串,也可以是字符串列表

当我们传入字符串列表时,返回的数据的结构如下:

{
  "object": "list",
  "data": [
    {
      "object": "embedding",
      "index": 0,
      "embedding": [0.123, -0.456, 0.789, ...]  # 向量数组
    },
    {
      "object": "embedding",
      "index": 1,
      "embedding": [0.234, -0.567, 0.890, ...]
    },
    ...
  ],
  "model": "text-embedding-v3",
  "usage": {
    "prompt_tokens": 1234,
    "total_tokens": 1234
  }
}

实现相似度查找的函数:

def cosine_similarity(vec1, vec2):
    """
    计算两个向量之间的余弦相似度。

    参数:
    vec1 (np.ndarray): 第一个向量。
    vec2 (np.ndarray): 第二个向量。

    返回:
    float: 两个向量之间的余弦相似度。
    """
    # 计算两个向量的点积,并除以它们范数的乘积
    return np.dot(vec1, vec2) / (np.linalg.norm(vec1) * np.linalg.norm(vec2))

def semantic_search(query,text_chunks,embeddings,k=5):
    """
    使用给定的查询和嵌入对文本块执行语义搜索。

    ------索引index+文本text+向量embedding-------

    参数:
    query (str): 语义搜索的查询。
    text_chunks (List[str]): 要搜索的文本块列表。
    embeddings (List[dict]): 文本块的嵌入列表。
    k (int): 返回的相关文本块数量。默认值为5。

    返回:
    List[str]: 基于查询的前k个最相关文本块列表。
    """
    query_embedding=create_embeddings(query)
    scores=[]
    for i, chunk_embedding in enumerate(embeddings):
        score=cosine_similarity(np.array(query_embedding),np.array(chunk_embedding.embedding))
        scores.append((i,score))

    scores.sort(key=lambda x:x[1],reverse=True)
    top_indices = [i for i ,_ in scores[:k]]
    return [chunks[i] for i in top_indices]

回答函数:

def generate_response(user_prompt,model="qwen-max"):
    system_prompt="""
    You are an AI assistant that strictly answers based on the given context. 
    If the answer cannot be derived directly from the provided context, respond with: 
    'I do not have enough information to answer that.'
    """
    response=client.chat.completions.create(
        model=model,
        temperature=0.7,
        messages=[
            {"role":"system","content":system_prompt},
            {"role":"user","content":user_prompt}
        ]
    )
    return response

上面这几部分代码结合起来就是一个基础的Naive rag了

完整的代码如下:

import fitz
import os
import numpy as np
import json
from openai import OpenAI
from sympy.physics.units import temperature
import json  # JSON数据处理

os.environ["OPENAI_API_KEY"] =  os.getenv("DASHSCOPE_API_KEY")
os.environ["OPENAI_BASE_URL"] = "https://dashscope.aliyuncs.com/compatible-mode/v1"
def extract_text_from_pdf(pdf_path):
    """
    从PDF文件中提取文本并打印前`num_chars`个字符。

    参数:
    pdf_path (str): PDF文件的路径。

    返回:
    str: 从PDF中提取的文本。
    """
    with fitz.open(pdf_path) as mypdf:
        result = ""
        for page in mypdf:
            result += page.get_text("text")
    return result

def chunk_text(text,n,overlap):
    """
    将给定的文本分割为长度为 n 的段,并带有指定的重叠字符数。

    参数:
    text (str): 需要分割的文本。
    n (int): 每个片段的字符数量。
    overlap (int): 段与段之间的重叠字符数量。

    返回:
    List[str]: 一个包含文本片段的列表。
    """
    chunks=[]
    for i in range(0,len(text),n-overlap):
        chunks.append(text[i:i+n])
    return chunks

def create_embeddings(text,embedding_model_name="text-embedding-v3"):
    """
    使用指定的OpenAI模型为给定文本创建嵌入。

    参数:
    text (str): 需要为其创建嵌入的输入文本。
    model (str): 用于创建嵌入的模型。

    返回:
    dict: 包含嵌入结果的OpenAI API回复。
    """
    response=client.embeddings.create(
        input=text,
        model=embedding_model_name
    )
    return response
def cosine_similarity(vec1, vec2):
    """
    计算两个向量之间的余弦相似度。

    参数:
    vec1 (np.ndarray): 第一个向量。
    vec2 (np.ndarray): 第二个向量。

    返回:
    float: 两个向量之间的余弦相似度。
    """
    # 计算两个向量的点积,并除以它们范数的乘积
    return np.dot(vec1, vec2) / (np.linalg.norm(vec1) * np.linalg.norm(vec2))

def semantic_search(query,text_chunks,embeddings,k=5):
    """
    使用给定的查询和嵌入对文本块执行语义搜索。

    ------索引index+文本text+向量embedding-------

    参数:
    query (str): 语义搜索的查询。
    text_chunks (List[str]): 要搜索的文本块列表。
    embeddings (List[dict]): 文本块的嵌入列表。
    k (int): 返回的相关文本块数量。默认值为5。

    返回:
    List[str]: 基于查询的前k个最相关文本块列表。
    """
    query_embedding=create_embeddings(query)
    scores=[]
    for i, chunk_embedding in enumerate(embeddings):
        score=cosine_similarity(np.array(query_embedding.data[0].embedding),np.array(chunk_embedding.embedding))
        scores.append((i,score))

    scores.sort(key=lambda x:x[1],reverse=True)
    top_indices = [i for i ,_ in scores[:k]]
    return [chunks[i] for i in top_indices]

def generate_response(user_prompt,model="qwen-max"):
    system_prompt="""
    You are an AI assistant that strictly answers based on the given context. 
    If the answer cannot be derived directly from the provided context, respond with: 
    'I do not have enough information to answer that.'
    """
    response=client.chat.completions.create(
        model=model,
        temperature=0.7,
        messages=[
            {"role":"system","content":system_prompt},
            {"role":"user","content":user_prompt}
        ]
    )
    return response
pdf_path="./data/test.pdf"
text=extract_text_from_pdf(pdf_path)
chunks=chunk_text(text,1000,200)
client=OpenAI()
embeddings=create_embeddings(chunks)
data=embeddings.data
query="这个公司今年营收如何"
context=semantic_search(query,chunks,data,3)
user_prompt=f"""
    上下文:{''.join(context)}
    用户问题:{query}
"""
response=generate_response(user_prompt,"qwen-max")
print(response)

1.3 语义分块

我们采用根据语义检测的方式来分块

import fitz
import os
import numpy as np
import json

from oauthlib.uri_validate import query
from openai import OpenAI
from sympy.physics.units import temperature
import json  # JSON数据处理

os.environ["OPENAI_API_KEY"] =  os.getenv("DASHSCOPE_API_KEY")
os.environ["OPENAI_BASE_URL"] = "https://dashscope.aliyuncs.com/compatible-mode/v1"
def extract_text_from_pdf(pdf_path):
    """
    从PDF文件中提取文本并打印前`num_chars`个字符。

    参数:
    pdf_path (str): PDF文件的路径。

    返回:
    str: 从PDF中提取的文本。
    """
    with fitz.open(pdf_path) as mypdf:
        result = ""
        for page in mypdf:
            result += page.get_text("text")+" "
    return result.strip()


def get_embedding(text,embedding_model_name="text-embedding-v3"):
    """
    使用指定的OpenAI模型为给定文本创建嵌入。

    参数:
    text (str): 需要为其创建嵌入的输入文本。
    model (str): 用于创建嵌入的模型。

    返回:
    list: 传入文本的向量表示,为一个list
    """
    response=client.embeddings.create(
        input=text,
        model=embedding_model_name
    )
    return response.data[0].embedding
def cosine_similarity(vec1, vec2):
    """
    计算两个向量之间的余弦相似度。

    参数:
    vec1 (np.ndarray): 第一个向量。
    vec2 (np.ndarray): 第二个向量。

    返回:
    float: 两个向量之间的余弦相似度。
    """
    # 计算两个向量的点积,并除以它们范数的乘积
    return np.dot(vec1, vec2) / (np.linalg.norm(vec1) * np.linalg.norm(vec2))

def compute_breakpoints(similarities, method="percentile", threshold=90):
    """
    根据相似度下降计算分块断点。

    参数:
    similarities (List[float]): 句子之间的相似度分数列表。
    method (str): 'percentile', 'standard_deviation' 或 'interquartile'。
    threshold (float): 阈值(对于 'percentile' 是百分位数,对于 'standard_deviation' 是标准差的数量)。

    返回:
    List[int]: 应该发生分块分裂的索引位置列表。
    """
    if method == "percentile":
        threshold_value=np.percentile(similarities,threshold)
    elif method == "standard_deviation":
        # 计算相似度分数的平均值和标准差
        mean = np.mean(similarities)
        std_dev = np.std(similarities)
        # 将阈值设置为平均值减去X个标准差
        threshold_value = mean - (threshold * std_dev)
    elif method == "interquartile":
        # 计算第一和第三四分位数(Q1 和 Q3)
        q1, q3 = np.percentile(similarities, [25, 75])
        # 使用IQR规则设置阈值以检测异常值
        threshold_value = q1 - 1.5 * (q3 - q1)
    else:
        # 如果提供了无效方法,则引发错误
        raise ValueError("Invalid method. Choose 'percentile', 'standard_deviation', or 'interquartile'.")
    return [i for i ,sim in enumerate(similarities) if sim<threshold_value ]

def split_into_chunks(sentences,breakpoints):
    """
    将句子分割为语义块。

    参数:
    sentences (List[str]): 句子列表。
    breakpoints (List[int]): 应该发生分割的索引位置。

    返回:
    List[str]: 文本块列表。
    """
    chunks=[]
    start=0
    for bp in breakpoints :
        chunks.append(".".join(sentences[start:bp+1]) )
        start=bp+1
    # 注意,当我们切分完毕后,加入的只是最后一个切分点之前的chunk,需要将最后一个加入
    chunks.append(".".join(sentences[start:]))
    return chunks

def create_embeddings(text_chunks):
    """
    为每个文本块创建嵌入。

    参数:
    text_chunks (List[str]): 文本块的列表。

    返回:
    List[np.ndarray]: 嵌入向量的列表。
    """
    # 使用 get_embedding 函数为每个文本块生成嵌入
    return [get_embedding(chunk) for chunk in text_chunks]

def semantic_search(query,text_chunks,embeddings,k=5):
    """
    使用给定的查询和嵌入对文本块执行语义搜索。

    ------索引index+文本text+向量embedding-------

    参数:
    query (str): 语义搜索的查询。
    text_chunks (List[str]): 要搜索的文本块列表。
    embeddings (List[dict]): 文本块的嵌入列表。
    k (int): 返回的相关文本块数量。默认值为5。

    返回:
    List[str]: 基于查询的前k个最相关文本块列表。
    """
    query_embedding=get_embedding(query)
    scores=[]
    for i, chunk_embedding in enumerate(embeddings):
        score=cosine_similarity(np.array(query_embedding),np.array(chunk_embedding))
        scores.append((i,score))

    scores.sort(key=lambda x:x[1],reverse=True)
    top_indices = [i for i ,_ in scores[:k]]
    return [text_chunks[i] for i in top_indices]
def generate_response(user_prompt,model="qwen-max"):
    system_prompt="""
    You are an AI assistant that strictly answers based on the given context. 
    If the answer cannot be derived directly from the provided context, respond with: 
    'I do not have enough information to answer that.'
    """
    response=client.chat.completions.create(
        model=model,
        temperature=0.7,
        messages=[
            {"role":"system","content":system_prompt},
            {"role":"user","content":user_prompt}
        ]
    )
    return response

client=OpenAI()
pdf_path="./data/test.pdf"
input_text=extract_text_from_pdf(pdf_path)
sentences=input_text.split(".")
embeddings=create_embeddings(sentences)
similarities=[cosine_similarity(np.array(embeddings[i]),np.array(embeddings[i+1])) for i in range(len(embeddings)-1)]
bp=compute_breakpoints(similarities,"percentile")
chunks=split_into_chunks(sentences,bp)
chunks_embeddings=create_embeddings(chunks)
query="根据当前文本,介绍一下什么是人工智能"
retriever=semantic_search(query, chunks, chunks_embeddings)
user_prompt=f"""
    上下文:{''.join(retriever)}
    用户问题:{query}
"""
response=generate_response(user_prompt,"qwen-max")
print(response)




其中分块时依据的阈值需要去理解,现在是通过传入一个分数,默认是90% 获得的阈值的含义是有百分之90的相

似度小于阈值的这个相似度,将阈值作为边界,小于阈值的两个句子的那个分割点作为切分点去切

但是基于语义的分块方式开销非常大,每个句子之间都要去判断相似度,实际上是开销非常大的

posted @ 2025-11-01 19:35  折翼的小鸟先生  阅读(2)  评论(0)    收藏  举报