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的相

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

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

1.4 chunk划分评估

子啊chunk切分过程中

如果chunk切的过大:包含语义较多,不同chunk中的语义可能过于接近

同时包含大量与拆线呢无关信息的干扰,同时会增加token使用成本,降低处理速度,重要信息被稀释

如果chunk切的过小 : 会导致关键信息被翻个,失去关联性,可能导致需要的重要信息不出现在检索出的顶部知

识块,描述性信息难以保持完整,影响对实体的全面理解,在top_K值较小时,关键信息可能完全不在返回结果中

我们来基于Ragas来实现对不同切块的评估:

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"
client=OpenAI()

def extract_text_from_pdf(pdf_path):
    with fitz.open(pdf_path) as mypdf:
        all_text=""
        for page in mypdf:
            all_text+=page.get_text("text")+" "
    return all_text.strip()

def chunk_text(text,n,overlap):
    chunks=[]
    for i in range(0,len(text),n-overlap):
        chunks.append(text[i:i+n])
    return chunks


def creat_embeddings(texts, model="text-embedding-v3"):
    batch_size = 10
    embeddings = []

    for i in range(0, len(texts), batch_size):
        batch = texts[i:i + batch_size]
        response = client.embeddings.create(
            model=model,
            input=batch
        )
        embeddings.extend([data.embedding for data in response.data])

    return embeddings

def cosine_similarity(vec1,vec2):
    return np.dot(vec1,vec2)/(np.linalg.norm(vec1)*np.linalg.norm(vec2))

def retrieve_relevant_chunks(query,text_chunks,chunk_embeddings,k=5):
    query_embedding=creat_embeddings([query])[0]
    similarities=[cosine_similarity(query_embedding,embedding) for embedding in chunk_embeddings ]
    top_k=np.argsort(similarities)[-k:][::-1]
    return [text_chunks[i] for i in top_k]


def evaluate_response(question, response, true_answer):
    """
    根据忠实性和相关性评估AI生成的回答质量。

    参数:
    question (str): 用户的原始问题。
    response (str): 正在评估的AI生成的回答。
    true_answer (str): 用作基准的真实答案。

    返回:
    Tuple[float, float]: 包含(faithfulness_score, relevancy_score)的元组。
                         每个分数为: 1.0 (完全),0.5 (部分),或 0.0 (无)。
    """
    # 格式化评估提示
    faithfulness_prompt = FAITHFULNESS_PROMPT_TEMPLATE.format(
        question=question,
        response=response,
        true_answer=true_answer,
        full="1.0",
        partial="0.5",
        none="0"
    )

    relevancy_prompt = RELEVANCY_PROMPT_TEMPLATE.format(
        question=question,
        response=response,
        full="1.0",
        partial="0.5",
        none="0"
    )

    # 请求模型进行忠实性评估
    faithfulness_response = client.chat.completions.create(
        model="qwen-plus",
        temperature=0,
        messages=[
            {"role": "system", "content": "You are an objective evaluator. Return ONLY the numerical score."},
            {"role": "user", "content": faithfulness_prompt}
        ]
    )

    # 请求模型进行相关性评估
    relevancy_response = client.chat.completions.create(
        model="qwen-plus",
        temperature=0,
        messages=[
            {"role": "system", "content": "You are an objective evaluator. Return ONLY the numerical score."},
            {"role": "user", "content": relevancy_prompt}
        ]
    )

    # 提取分数并处理潜在的解析错误
    try:
        faithfulness_score = float(faithfulness_response.choices[0].message.content.strip())
    except ValueError:
        print("Warning: Could not parse faithfulness score, defaulting to 0")
        faithfulness_score = 0.0

    try:
        relevancy_score = float(relevancy_response.choices[0].message.content.strip())
    except ValueError:
        print("Warning: Could not parse relevancy score, defaulting to 0")
        relevancy_score = 0.0

    return faithfulness_score, relevancy_score

def generate_response(query, system_prompt, retrieved_chunks, model="qwen-max"):
    """
    基于检索到的片段生成AI回复。

    参数:
    query (str): 用户查询。
    retrieved_chunks (List[str]): 检索到的文本片段列表。
    model (str): AI模型。

    返回:
    str: AI生成的回复。
    """
    # 将检索到的片段组合成单一上下文字符串
    context = "\n".join([f"Context {i+1}:\n{chunk}" for i, chunk in enumerate(retrieved_chunks)])
    # 通过结合上下文和查询创建用户提示
    user_prompt = f"{context}\n\nQuestion: {query}"

    # 使用指定模型生成AI回复
    response = client.chat.completions.create(
        model=model,
        temperature=0,
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_prompt}
        ]
    )

    # 返回AI回复的内容
    return response.choices[0].message.content

FAITHFULNESS_PROMPT_TEMPLATE = """
Evaluate the faithfulness of the AI response compared to the true answer.
User Query: {question}
AI Response: {response}
True Answer: {true_answer}

Faithfulness measures how well the AI response aligns with facts in the true answer, without hallucinations.

INSTRUCTIONS:
- Score STRICTLY using only these values:
    * {full} = Completely faithful, no contradictions with true answer
    * {partial} = Partially faithful, minor contradictions
    * {none} = Not faithful, major contradictions or hallucinations
- Return ONLY the numerical score ({full}, {partial}, or {none}) with no explanation or additional text.
"""
RELEVANCY_PROMPT_TEMPLATE = """
Evaluate the relevancy of the AI response to the user query.
User Query: {question}
AI Response: {response}

Relevancy measures how well the response addresses the user's question.

INSTRUCTIONS:
- Score STRICTLY using only these values:
    * {full} = Completely relevant, directly addresses the query
    * {partial} = Partially relevant, addresses some aspects
    * {none} = Not relevant, fails to address the query
- Return ONLY the numerical score ({full}, {partial}, or {none}) with no explanation or additional text.
"""
chunk_sizes=[256,128]
pdf_path="./data/test.pdf"
text=extract_text_from_pdf(pdf_path)

with open('data/val.json', encoding='utf-8') as f:
    data = json.load(f)

# 从验证数据中提取第一个查询
query = data[3]['question']
text_chunks_dict={size: chunk_text(text,size,int(size*0.2)) for size in chunk_sizes}
chunk_embeddings_dict={size : creat_embeddings(text_chunks_dict[size]) for size in chunk_sizes}
# 为每个chunk大小检索相关的片段
retrieved_chunks_dict = {size: retrieve_relevant_chunks(query, text_chunks_dict[size], chunk_embeddings_dict[size]) for size in chunk_sizes}
# 第一个验证数据的真实答案
true_answer = data[3]['ideal_answer']

# 为每个块大小生成AI回复
# 定义AI助手的系统提示
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.'")
ai_responses_dict = {size: generate_response(query, system_prompt, retrieved_chunks_dict[size]) for size in chunk_sizes}
faithfulness, relevancy = evaluate_response(query, ai_responses_dict[256], true_answer)
faithfulness2, relevancy2 = evaluate_response(query, ai_responses_dict[128], true_answer)

# 打印评估分数
print(f"Faithfulness Score (Chunk Size 256): {faithfulness}")
print(f"Relevancy Score (Chunk Size 256): {relevancy}")

print(f"\n")

print(f"Faithfulness Score (Chunk Size 128): {faithfulness2}")
print(f"Relevancy Score (Chunk Size 128): {relevancy2}")
# 打印大小为256的检索到的片段


基于RAGAS评估框架的核心指标:

1. Context Recall(上下文召回率)

定义

衡量检索到的上下文覆盖了回答所需的所有相关信息的比例,即检索模块能够找到多少真正相关的文档。

Context Recall = (检索到的相关上下文数量) / (生成正确回答所需的相关上下文总数)

我们先前设置了top_k 指定了检索的文档个数,按理说这里应该是指定的啊,为什么还会有变化的检索到的chunk个

数,其实在实际构建中,我们设置的topk只是最大值,就像在之前基于语义切分一样,我们也会通过某种相似度

去过滤,比如这里设置相似度过滤阈值为0.6 那相似度低于0.6的chunk就无法被召回

重要性

  • 决定系统基础能力:低召回率意味着系统无法获取足够信息
  • 影响下游生成:生成模块只能基于检索到的内容进行回答
  • 优化优先级:通常优先优化此指标,因为"没有检索到的内容,模型无法生成"

2. Context Precision(上下文精确度)

定义

衡量检索到的上下文中真正对回答有用的文档比例,即检索结果的质量而非数量。

计算公式

Context Precision = (对回答有用的上下文数量) / (检索到的总上下文数量)
  • 影响响应质量:低精确度会导致生成模块被无关信息干扰
  • 资源效率:减少需要处理的无关上下文,提升生成速度
  • 用户体验:高质量的上下文更容易生成简洁准确的回答

3. Context Relevancy(上下文相关性)

定义

衡量单个检索结果与用户查询的相关程度,不考虑对最终回答的贡献。

与Context Precision的区别

  • Context Relevancy:评估检索结果与查询的相关性
  • Context Precision:评估检索结果对最终回答的有用性

4. Faithfulness(忠实度)

定义

衡量生成的回答是否严格基于检索到的上下文,不包含幻觉(hallucinations)或未在上下文中支持的信息。

评估方法

  • 声明分解:将回答分解为独立声明
  • 上下文验证:检查每个声明是否能在上下文中找到支持
  • 幻觉检测:识别无上下文支持的声明
Faithfulness = (有上下文支持的声明数量) / (回答中的总声明数量)

5. Answer Relevancy(回答相关性)

定义

衡量生成的回答与用户查询的直接相关程度,评估回答是否精准解决了用户的问题。

评估维度

  • 问题覆盖度:回答是否覆盖了查询的所有方面
  • 意图匹配度:回答是否符合用户的潜在意图
  • 信息密度:回答是否包含多余的无关信息

6. Answer Correctness(回答正确性)

定义

衡量生成的回答与标准答案(ground truth)的匹配程度,评估回答的事实准确性。

评估方法

  • 事实验证:验证回答中的关键事实是否正确
  • 语义相似度:计算与标准答案的语义相似度
  • 多维度评分:准确性、完整性、清晰度等综合评分

7. Answer Semantic Similarity(回答语义相似度)

定义

使用嵌入模型计算生成回答与标准答案的语义相似度,不依赖精确的文本匹配。

技术实现

  • 使用预训练的嵌入模型(如text-embedding-ada-002)
  • 计算余弦相似度或欧氏距离
  • 通常结合多个嵌入模型的结果

优势

  • 语义理解:能够识别不同表述但相同含义的回答
  • 语言灵活性:不受措辞差异影响
  • 多语言支持:适用于跨语言评估

1.5 上下文增强检索的rag

传统的rag检索返回的是孤立的片段,可能导致语义不完整,我们通过使用上下文增强检索,来获得在检索出来的 片段上一个chunk 和下一个chunk,将其拼接到检索的chunk中,来起到优化作用

与单纯增加chunk长度的本质区别

维度 上下文增强检索 增加chunk长度
针对性 仅对检索到的相关片段添加上下文 全局性改变所有chunk
信息密度 保持高相关性,精准补充缺失上下文 可能稀释关键信息,引入无关内容
索引效率 保持原始索引结构,不影响检索精度 更大的chunk通常降低检索精度
灵活性 可动态调整上下文范围(前N后M) 固定不变,无法针对查询调整
语义边界 尊重文档的自然语义边界 仍可能在关键语义处被切断
import fitz
import os
import numpy as np
from openai import OpenAI
import json  # JSON数据处理



def extract_text_from_pdf(pdf_path):
    with fitz.open(pdf_path) as mypdf:
        result = ""
        for page in mypdf:
            result += page.get_text("text")+" "
    return result.strip()


def chunk_text(text, n, overlap):
    chunks = []  # 初始化一个空列表用于存储片段

    # 使用 (n - overlap) 的步长遍历文本
    for i in range(0, len(text), n - overlap):
        # 将从索引 i 到 i + n 的文本片段追加到 chunks 列表中
        chunks.append(text[i:i + n])

    return chunks  # 返回包含文本片段的列表
def cosine_similarity(vec1, vec2):

    # 计算两个向量的点积并除以其范数的乘积
    return np.dot(vec1, vec2) / (np.linalg.norm(vec1) * np.linalg.norm(vec2))

def creat_embeddings(texts, model="text-embedding-v3"):
    batch_size = 10
    embeddings = []

    for i in range(0, len(texts), batch_size):
        batch = texts[i:i + batch_size]
        response = client.embeddings.create(
            model=model,
            input=batch
        )
        embeddings.extend([data.embedding for data in response.data])

    return embeddings
def context_enriched_search(query,text_chunks,chunk_embeddings,k=1,context_size=1):
    query_embedding=creat_embeddings([query])[0]
    similarities=[cosine_similarity(query_embedding,embedding) for embedding in chunk_embeddings ]
    mark=np.argsort(similarities)[-k:][::-1]
    chunks_list=[]
    for e in mark:
        start = max(0, e - context_size)
        end = min(len(text_chunks), e + context_size)
        chunk=".".join(text_chunks[start:end+1])
        chunks_list.append(chunk)
    return chunks_list

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

os.environ["OPENAI_API_KEY"] =  os.getenv("DASHSCOPE_API_KEY")
os.environ["OPENAI_BASE_URL"] = "https://dashscope.aliyuncs.com/compatible-mode/v1"
client=OpenAI()
pdf_path="./data/test.pdf"
input_text=extract_text_from_pdf(pdf_path)
chunks=chunk_text(input_text,256,30)[10]
embeddings=creat_embeddings(chunks)
query="介绍一下ai大模型发展背景"
retriever=context_enriched_search(query, chunks, embeddings)
user_prompt=f"""
    上下文:{''.join(retriever)}
    用户问题:{query}
"""
response=generate_response(user_prompt,"qwen-max")
print(response)

1.6 上下文片段标题提取

检索增强生成(RAG)通过在生成回复之前检索相关的外部知识来提高语言模型的事实准确性。然而,标准的分块

方法经常丢失重要上下文,从而使检索效果降低。

上下文片段标题(CCH)通过在嵌入每个片段之前为其添加高级上下文(如文档标题或章节标题)来增强RAG。这

提高了检索质量并防止了脱离上下文的回复

我们这里采用总结生成Chunk标题的方式,提高语义的凝练程度,从而提高检索精度

import fitz
import os
import numpy as np
from openai import OpenAI
import json  # JSON数据处理



def extract_text_from_pdf(pdf_path):
    with fitz.open(pdf_path) as mypdf:
        result = ""
        for page in mypdf:
            result += page.get_text("text")+" "
    return result.strip()


def chunk_text_with_header(text, n, overlap):
    chunks = []  # 初始化一个空列表用于存储片段

    # 使用 (n - overlap) 的步长遍历文本
    for i in range(0, len(text), n - overlap):
        # 将从索引 i 到 i + n 的文本片段追加到 chunks 列表中
        chunk=text[i:i + n]
        header=generate_summary(chunk)
        chunks.append({"header":header,"content":chunk})
    return chunks  # 返回包含文本片段的列表
def cosine_similarity(vec1, vec2):

    # 计算两个向量的点积并除以其范数的乘积
    return np.dot(vec1, vec2) / (np.linalg.norm(vec1) * np.linalg.norm(vec2))

def creat_embeddings(texts, model="text-embedding-v3"):
    batch_size = 10
    embeddings = []

    for i in range(0, len(texts), batch_size):
        batch = texts[i:i + batch_size]
        response = client.embeddings.create(
            model=model,
            input=batch
        )
        embeddings.extend([data.embedding for data in response.data])

    return embeddings
def context_enriched_search(query,chunks,chunks_embeddings,k=1):
    query_embedding=creat_embeddings([query])[0]
    similarity=[]
    for dict in chunks_embeddings:
        sim_text=cosine_similarity(np.array(dict["content_embeddings"][0]),np.array(query_embedding))
        sim_header=cosine_similarity(np.array(dict["header_embeddings"][0]),np.array(query_embedding))
        avg_sim=(sim_text+sim_header)/2
        similarity.append(avg_sim)
    mark = np.argsort(similarity)[-k:][::-1]
    return [chunks[index]["content"] for index in mark ]





    #
    # mark=np.argsort(similarities)[-k:][::-1]
    # chunks_list=[]
    # for e in mark:
    #     start = max(0, e - context_size)
    #     end = min(len(text_chunks), e + context_size)
    #     chunk=".".join(text_chunks[start:end+1])
    #     chunks_list.append(chunk)
    # return chunks_list

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

def generate_summary(text,model="qwen-max"):
    system_prompt="为用户传入的内容生成一个简短的总结"
    response=client.chat.completions.create(
        model=model,
        messages=[{"role":"system","content":system_prompt},
                  {"role":"user","content":text}
                  ]
    )
    return response.choices[0].message.content.strip()
def get_embeddings(chunks):
    embeddings=[]
    for chunk in chunks:
        header=chunk["header"]
        content=chunk["content"]
        header_embeddings=creat_embeddings([header])
        content_embeddings = creat_embeddings([content])
        embeddings.append({"header_embeddings":header_embeddings,"content_embeddings":content_embeddings})
    return embeddings


os.environ["OPENAI_API_KEY"] =  os.getenv("DASHSCOPE_API_KEY")
os.environ["OPENAI_BASE_URL"] = "https://dashscope.aliyuncs.com/compatible-mode/v1"
client=OpenAI()
pdf_path="./data/test.pdf"
input_text=extract_text_from_pdf(pdf_path)
chunks=chunk_text_with_header(input_text,256,30)
embeddings=get_embeddings(chunks)
query="介绍一下ai大模型发展背景"
retriever=context_enriched_search(query, chunks, embeddings)
user_prompt=f"""
    上下文:{''.join(retriever)}
    用户问题:{query}
"""
response=generate_response(user_prompt,"qwen-max")
print("检索出的结果如下:")
print("-"*50)
for e in retriever:
    print(e)
print("最终回复如下:")
print("-"*50)
print(response)
posted @ 2025-11-01 19:35  折翼的小鸟先生  阅读(18)  评论(0)    收藏  举报