本节我们来实现一个完整的 RAG 系统,体验下如何使用垂直领域数据来补充 LLM 的能力,如何构建向量数据库,以及如何提升 RAG 检索的优化效果。

1. RAG 技术概述

1.1 大模型目前固有的局限性

大语言模型(LLM)是概率生成系统

  • 知识时效性:模型知识截止于训练数据时间点(联网搜索
  • 推理局限性:本质是概率预测而非逻辑运算,复杂数学推理易出错(DeepSeek-R1的架构有所不同
  • 专业领域盲区:缺乏垂直领域知识
  • 幻觉现象:可能生成看似合理但实际错误的内容

1.2 什么是 RAG?

RAG(Retrieval Augmented Generation)顾名思义,通过检索的方法来增强生成模型的能力。

2. RAG 工程化

2.1 RAG系统的基本搭建流程

搭建过程:

  1. 文档加载,并按一定条件切割成片段
  2. 将切割的文本片段灌入检索引擎
  3. 封装检索接口
  4. 构建调用流程:Query -> 检索 -> Prompt -> LLM -> 回复

2.2 构建索引

2.3 检索和生成

3. 项目环境准备

3.1 使用 conda 创建项目环境

# 创建环境
conda create -n tcm-ai-rag python=3.11 ipykernel

# 激活环境
conda activate tcm-ai-rag

3.2 安装项目所需依赖库

pip install jupyterlab
pip install llama-index
pip install llama-index-llms-dashscope
pip install llama-index-embeddings-dashscope

4. 构建中医临床诊疗术语证候问答系统

4.1 语料准备

我们使用由国家卫生健康委员和国家中医药管理局发布的中医临床诊疗术语:

  • 《中医临床诊疗术语第2部分:证候》(修订版).docx

这是一个word文档,我们需要先将其转换为txt文件或者markdown文件,方便后续处理。然后需要对语料进行数据预处理,如去除噪声数据、数据格式化等。

这个文件中具有类目属性的术语一般不适用于临床诊断。

注:类目属性的术语是指定义中有“泛指……一类证候”表述方式的术语。

我们来看下文档的部分内容:

如:4.1.1.2.1 气机阻滞证的描述就是噪声数据,这类数据需要删除。另外文件中的英文和 "/" 也需要删除。

4.2 使用 MinerU 进行文档预处理

使用 MinerU 对文档进行转换。

4.3 删除文中的英文

import re

def remove_english(input_file, output_file):
    """
    去除文件中所有英文字符并生成新文件
    :param input_file: 输入文件路径
    :param output_file: 输出文件路径
    """
    try:
        with open(input_file, 'r', encoding='utf-8') as f_in:
            content = f_in.read()

        # 使用正则表达式移除所有英文字母
        filtered_content = re.sub('[A-Za-z/]', '', content)

        with open(output_file, 'w', encoding='utf-8') as f_out:
            f_out.write(filtered_content)
            
        print(f"处理完成,已生成新文件:{output_file}")
        
    except Exception as e:
        print(f"处理出错:{str(e)}")

# 使用示例
remove_english('./data/demo.md', './data/demo-1.md')

5. 基于 LlamaIndex 来快速构建知识库

5.1 基于线上模型来构建

这里使用的是阿里云百炼平台的大模型,开通后有一定的免费额度,足够我们使用一阵子。由于调用的是线上模型,文档切片构建向量缩影的过程比较慢,执行这段代码需要耐心等待。

注意:大家在使用线上大模型时,为了避免产生付费,尽量使用免费模型(我这里就是因为使用text-embedding-v1模型产生了费用,而实际上 text-embedding-v4 还是有免费额度的)。模型点开后,可以查看免费额度:

使用 text-embedding-v4 有批次不能超过10 的限制,所以我们先做个 DashScopeEmbedding 的扩展类:

basic_embeddings.py

from llama_index.embeddings.dashscope import DashScopeEmbedding
from typing import List

class BatchedDashScopeEmbedding(DashScopeEmbedding):
    def get_text_embedding_batch(self, texts: List[str], show_progress: bool = False) -> List[List[float]]:
        batch_size = 10
        all_embeddings = []
        for i in range(0, len(texts), batch_size):
            batch = texts[i:i + batch_size]
            batch_embed = super().get_text_embedding_batch(batch, show_progress=show_progress)
            all_embeddings.extend(batch_embed)
        return all_embeddings


完整的 RAG 代码:

import logging
import sys
import os
from llama_index.core import PromptTemplate, Settings, SimpleDirectoryReader, VectorStoreIndex, load_index_from_storage, StorageContext, QueryBundle
from llama_index.core.schema import MetadataMode
from llama_index.core.node_parser import SentenceSplitter
from llama_index.llms.dashscope import DashScope, DashScopeGenerationModels
from llama_index.embeddings.dashscope import DashScopeEmbedding, DashScopeTextEmbeddingModels
from basic_embeddings import BatchedDashScopeEmbedding


# 0. 设置cpu参数
# os.environ['NUMEXPR_MAX_THREADS'] = '22'
# 1.日志配置
logging.basicConfig(stream=sys.stdout, level=logging.INFO)
logging.getLogger().addHandler(logging.StreamHandler(stream=sys.stdout))

# 2、指定全局llm模型和embeddings模型
Settings.llm = DashScope( model_name="qwen3-max",api_key=os.getenv("DASHSCOPE_API_KEY"))
# 使用
embed_model = BatchedDashScopeEmbedding(model_name="text-embedding-v4")
Settings.embed_model = embed_model

# 3、读取文档
documents = SimpleDirectoryReader("./data", required_exts=[".txt"]).load_data()

# 4. 对文档进行切分,将切分后的片段存储在向量数据库中,构建向量索引
index = VectorStoreIndex.from_documents(documents, transformations=[SentenceSplitter(chunk_size=256)])

# 5. 构建查询引擎
# streaming 流式输出
# similarity_top_k 检索结果的数量
query_engine = index.as_query_engine(streaming=True, similarity_top_k=5)

# 6. 测试
response = query_engine.query("不耐疲劳,口燥、咽干可能是哪些证候?")
response.print_response_stream()
print()

6. 使用LlamaIndex存储和读取embedding向量

上面的代码在对文档进行切分,将切分后的片段转化为embedding向量,构建向量索引时,会花费大量的时间。这里我们做个改进,将向量存储在本地。

向量存储

# 将embedding向量和向量索引存储到文件中
# ./doc_emb 是存储路径
index.storage_context.persist(persist_dir='./doc_emb')

找到刚才定义的persist_dir所在的路径,可以发现该路径下有以下几个文件:

  • default_vector_store.json:用于存储embedding向量
  • docstore.json:用于存储文档切分出来的片段
  • graph_store.json:用于存储知识图数据
  • image__vector_store.json:用于存储图像数据
  • index_store.json:用于存储向量索引

在上述代码中,我们只用到了纯文本文档,所以生成出来的graph_store.jsonimage__vector_store.json中没有数据。

从向量数据库检索

将embedding向量和向量索引存储到文件中后,我们就不需要重复地执行对文档进行切分,将切分后的片段转化为embedding向量,构建向量索引的操作了。

以下代码演示了如何使用LlamaIndex读取结构化文件中的embedding向量和向量索引数据:

# 从存储文件中读取embedding向量和向量索引
storage_context = StorageContext.from_defaults(persist_dir="./doc_emb")

# 根据存储的embedding向量和向量索引重新构建检索索引
index = load_index_from_storage(storage_context)

完整代码:

import logging
import sys
import os
from llama_index.core import PromptTemplate, Settings, SimpleDirectoryReader, VectorStoreIndex, load_index_from_storage, StorageContext, QueryBundle
from llama_index.core.schema import MetadataMode
from llama_index.core.node_parser import SentenceSplitter
from llama_index.llms.dashscope import DashScope, DashScopeGenerationModels
from llama_index.embeddings.dashscope import DashScopeEmbedding, DashScopeTextEmbeddingModels
from basic_embeddings import BatchedDashScopeEmbedding


# 0. 设置cpu参数
# os.environ['NUMEXPR_MAX_THREADS'] = '22'
# 1.日志配置
logging.basicConfig(stream=sys.stdout, level=logging.INFO)
logging.getLogger().addHandler(logging.StreamHandler(stream=sys.stdout))

# 2、指定全局llm模型和embeddings模型
Settings.llm = DashScope( model_name="qwen3-max",api_key=os.getenv("DASHSCOPE_API_KEY"))
# 使用
embed_model = BatchedDashScopeEmbedding(model_name="text-embedding-v4")
Settings.embed_model = embed_model

# 3、读取文档
documents = SimpleDirectoryReader("./data", required_exts=[".txt"]).load_data()

# 4. 对文档进行切分,将切分后的片段存储在向量数据库中,构建向量索引
index = VectorStoreIndex.from_documents(documents, transformations=[SentenceSplitter(chunk_size=512, chunk_overlap=128)],)

# 5. 存储向量索引
# 将 embedding 向量和向量索引存储到文件中
index.storage_context.persist("./doc_emb")

# 6. 从向量数据库检索
storage_context = StorageContext.from_defaults(persist_dir="./doc_emb")
# 6.1 根据存储的 embedding 向量和向量索引加载索引
index = load_index_from_storage(storage_context)

# 7. 构建查询引擎
# streaming 流式输出
# similarity_top_k 检索结果的数量
query_engine = index.as_query_engine(streaming=True, similarity_top_k=5)

# 8. 查询获得答案
response = query_engine.query("不耐疲劳,口燥、咽干可能是哪些证候?")
response.print_response_stream()
print()

7. 追踪哪些文档片段被检索

from llama_index.core.schema import MetadataMode
import logging
import sys
import os
from llama_index.core import PromptTemplate, Settings, SimpleDirectoryReader, VectorStoreIndex, load_index_from_storage, StorageContext, QueryBundle
from llama_index.core.schema import MetadataMode
from llama_index.core.node_parser import SentenceSplitter
from llama_index.llms.dashscope import DashScope, DashScopeGenerationModels
from llama_index.embeddings.dashscope import DashScopeEmbedding, DashScopeTextEmbeddingModels
from basic_embeddings import BatchedDashScopeEmbedding

# 1.日志配置
logging.basicConfig(stream=sys.stdout, level=logging.INFO)
logging.getLogger().addHandler(logging.StreamHandler(stream=sys.stdout))

# 2、指定全局llm模型和embeddings模型
Settings.llm = DashScope( model_name="qwen3-max",api_key=os.getenv("DASHSCOPE_API_KEY"))
# 使用
embed_model = BatchedDashScopeEmbedding(model_name="text-embedding-v4")
Settings.embed_model = embed_model

# 从存储文件中读取embedding向量和向量索引
storage_context = StorageContext.from_defaults(persist_dir="./doc_emb")

# 根据存储的embedding向量和向量索引重新构建检索索引
index = load_index_from_storage(storage_context)

# 构建查询引擎
query_engine = index.as_query_engine(similarity_top_k=5)

# 获取我们抽取出的相似度 top 5 的片段
contexts = query_engine.retrieve(QueryBundle("不耐疲劳,口燥、咽干可能是哪些证候?"))
print('-' * 10 + 'ref' + '-' * 10)
for i, context in enumerate(contexts):
    print('#' * 10 + f'chunk {i} start' + '#' * 10)
    content = context.node.get_content(metadata_mode=MetadataMode.LLM)
    print(content)
    print('#' * 10 + f'chunk {i} end' + '#' * 10)
print('-' * 10 + 'ref' + '-' * 10)

# 查询获得答案
response = query_engine.query("不耐疲劳,口燥、咽干可能是哪些证候?")
print(response)

追踪结果:

INFO:llama_index.core.indices.loading:Loading all indices.
Loading all indices.
----------ref----------
##########chunk 0 start##########
file_path: D:\code\PythonProjects\project_1_ctm_ai_rag\data\demo.txt

临床以鼻咽干涩或痛,口唇燥干,舌质红,舌苔白或燥,脉浮或微数,伴见发热、无汗,头痛或肢节酸痛等为特征的证候。

3.6.3.2
    燥干清窍证
    因气候或环境干燥,津液耗损,清窍失濡所致。临床以口鼻、咽喉干燥,两眼干涩,少泪、少涕、少津、甚则衄血,舌质瘦小 、舌苔干而少津,脉细等为特征的证候。
##########chunk 0 end##########
##########chunk 1 start##########
file_path: D:\code\PythonProjects\project_1_ctm_ai_rag\data\demo.txt

临床以口舌咽干,烦渴、喜饮,形体消瘦,毛发枯悴,肌肤甲错,舌质红而干,舌苔少或无,脉细数,伴见干咳、无痰或痰少,咳 辄牵掣胸胁疼痛,胃脘嘈杂,善食、易饥,或不饥、不食等为特征的证候。

5.6.4.5.1
    肺肾两虚证
    因肺肾阳气或阴液不足所致。
##########chunk 1 end##########
##########chunk 2 start##########
file_path: D:\code\PythonProjects\project_1_ctm_ai_rag\data\demo.txt

临床以口干、咽燥,烦渴引饮,皮肤干燥,眼眶微陷,小便短黄,大便干结,舌质红,舌苔少而干,脉细数,可伴见形瘦,盗汗等 为特征的证候。

4.3.3.1.2
    阴液涸竭证
    阴液乏竭证
    因热病邪入阴分,或剧烈吐泻,久病虚损,阴液耗竭所致。
##########chunk 2 end##########
##########chunk 3 start##########
file_path: D:\code\PythonProjects\project_1_ctm_ai_rag\data\demo.txt

临床以口干、舌燥,频饮而不解其渴,食多、善饥,夜尿频多,逐渐消瘦,舌质红,舌苔薄黄或少,脉弦细或滑数,伴见皮肤干燥 ,四肢乏力,大便干结等为特征的证候。

4.6.3.2
    津亏热结证
    液干热结证
    因津液亏虚,热邪内结所致。
##########chunk 3 end##########
##########chunk 4 start##########
file_path: D:\code\PythonProjects\project_1_ctm_ai_rag\data\demo.txt

临床以肌肤不润或干瘪,口舌鼻咽干燥,烦渴引饮,甚则口中津液全无,目涩、少泪,大便干结,阴痒涩痛,舌质红,舌苔薄黄或 少津,脉细数或涩,可伴见发热,消瘦,乏力,关节僵痛等为特征的证候。

3.6.6.1
    燥气化火证
    因气候干燥,感受燥邪,或偏嗜辛辣,燥气化火,耗伤阴津所致。
##########chunk 4 end##########
----------ref----------
不耐疲劳、口燥、咽干可能涉及以下证候:
    因气候干燥,感受燥邪,或偏嗜辛辣,燥气化火,耗伤阴津所致。
##########chunk 4 end##########
----------ref----------
不耐疲劳、口燥、咽干可能涉及以下证候:

- **阴液涸竭证**:表现为口干、咽燥,烦渴引饮,形瘦,盗汗,舌质红,舌苔少而干,脉细数。
- **津亏热结证**:可见口干、舌燥,频饮不解渴,伴皮肤干燥、四肢乏力、大便干结,舌质红,舌苔薄黄或少,脉弦细或滑数。 
- **燥气化火证**:有口舌鼻咽干燥、烦渴引饮、目涩少泪、大便干结、乏力等症状,舌质红,舌苔薄黄或少津,脉细数或涩。   
- **肺肾两虚证**:可出现口舌咽干、形体消瘦、皮肤干燥、干咳少痰等表现,舌质红而干,舌苔少或无,脉细数。

这些证候均以阴津亏虚或燥热伤津为核心病机,常伴不同程度的疲劳或乏力。
PS D:\code\PythonProjects\project_1_ctm_ai_rag>


    因气候干燥,感受燥邪,或偏嗜辛辣,燥气化火,耗伤阴津所致。
##########chunk 4 end##########
----------ref----------
不耐疲劳、口燥、咽干可能涉及以下证候:

- **阴液涸竭证**:表现为口干、咽燥,烦渴引饮,形瘦,盗汗,舌质红,舌苔少而干,脉细数。
- **津亏热结证**:可见口干、舌燥,频饮不解渴,伴皮肤干燥、四肢乏力、大便干结,舌质红,舌苔薄黄或少,脉弦细或滑数。 
- **燥气化火证**:有口舌鼻咽干燥、烦渴引饮、目涩少泪、大便干结、乏力等症状,舌质红,舌苔薄黄或少津,脉细数或涩。   
- **肺肾两虚证**:可出现口舌咽干、形体消瘦、皮肤干燥、干咳少痰等表现,舌质红而干,舌苔少或无,脉细数。

这些证候均以阴津亏虚或燥热伤津为核心病机,常伴不同程度的疲劳或乏力。
PS D:\code\PythonProjects\project_1_ctm_ai_rag>


    因气候干燥,感受燥邪,或偏嗜辛辣,燥气化火,耗伤阴津所致。
##########chunk 4 end##########
----------ref----------
不耐疲劳、口燥、咽干可能涉及以下证候:

- **阴液涸竭证**:表现为口干、咽燥,烦渴引饮,形瘦,盗汗,舌质红,舌苔少而干,脉细数。
- **津亏热结证**:可见口干、舌燥,频饮不解渴,伴皮肤干燥、四肢乏力、大便干结,舌质红,舌苔薄黄或少,脉弦细或滑数。 
- **燥气化火证**:有口舌鼻咽干燥、烦渴引饮、目涩少泪、大便干结、乏力等症状,舌质红,舌苔薄黄或少津,脉细数或涩。   
- **肺肾两虚证**:可出现口舌咽干、形体消瘦、皮肤干燥、干咳少痰等表现,舌质红而干,舌苔少或无,脉细数。

这些证候均以阴津亏虚或燥热伤津为核心病机,常伴不同程度的疲劳或乏力。
PS D:\code\PythonProjects\project_1_ctm_ai_rag>

    因气候干燥,感受燥邪,或偏嗜辛辣,燥气化火,耗伤阴津所致。
##########chunk 4 end##########
----------ref----------
不耐疲劳、口燥、咽干可能涉及以下证候:

- **阴液涸竭证**:表现为口干、咽燥,烦渴引饮,形瘦,盗汗,舌质红,舌苔少而干,脉细数。
- **津亏热结证**:可见口干、舌燥,频饮不解渴,伴皮肤干燥、四肢乏力、大便干结,舌质红,舌苔薄黄或少,脉弦细或滑数。 
- **燥气化火证**:有口舌鼻咽干燥、烦渴引饮、目涩少泪、大便干结、乏力等症状,舌质红,舌苔薄黄或少津,脉细数或涩。   
- **肺肾两虚证**:可出现口舌咽干、形体消瘦、皮肤干燥、干咳少痰等表现,舌质红而干,舌苔少或无,脉细数。

这些证候均以阴津亏虚或燥热伤津为核心病机,常伴不同程度的疲劳或乏力。
PS D:\code\PythonProjects\project_1_ctm_ai_rag>

    因气候干燥,感受燥邪,或偏嗜辛辣,燥气化火,耗伤阴津所致。
##########chunk 4 end##########
----------ref----------
不耐疲劳、口燥、咽干可能涉及以下证候:

- **阴液涸竭证**:表现为口干、咽燥,烦渴引饮,形瘦,盗汗,舌质红,舌苔少而干,脉细数。
- **津亏热结证**:可见口干、舌燥,频饮不解渴,伴皮肤干燥、四肢乏力、大便干结,舌质红,舌苔薄黄或少,脉弦细或滑数。 
- **燥气化火证**:有口舌鼻咽干燥、烦渴引饮、目涩少泪、大便干结、乏力等症状,舌质红,舌苔薄黄或少津,脉细数或涩。   
- **肺肾两虚证**:可出现口舌咽干、形体消瘦、皮肤干燥、干咳少痰等表现,舌质红而干,舌苔少或无,脉细数。

这些证候均以阴津亏虚或燥热伤津为核心病机,常伴不同程度的疲劳或乏力。
----------ref----------
不耐疲劳、口燥、咽干可能涉及以下证候:

- **阴液涸竭证**:表现为口干、咽燥,烦渴引饮,形瘦,盗汗,舌质红,舌苔少而干,脉细数。
- **津亏热结证**:可见口干、舌燥,频饮不解渴,伴皮肤干燥、四肢乏力、大便干结,舌质红,舌苔薄黄或少,脉弦细或滑数。 
- **燥气化火证**:有口舌鼻咽干燥、烦渴引饮、目涩少泪、大便干结、乏力等症状,舌质红,舌苔薄黄或少津,脉细数或涩。   
- **肺肾两虚证**:可出现口舌咽干、形体消瘦、皮肤干燥、干咳少痰等表现,舌质红而干,舌苔少或无,脉细数。


- **阴液涸竭证**:表现为口干、咽燥,烦渴引饮,形瘦,盗汗,舌质红,舌苔少而干,脉细数。
- **津亏热结证**:可见口干、舌燥,频饮不解渴,伴皮肤干燥、四肢乏力、大便干结,舌质红,舌苔薄黄或少,脉弦细或滑数。 
- **燥气化火证**:有口舌鼻咽干燥、烦渴引饮、目涩少泪、大便干结、乏力等症状,舌质红,舌苔薄黄或少津,脉细数或涩。   
- **肺肾两虚证**:可出现口舌咽干、形体消瘦、皮肤干燥、干咳少痰等表现,舌质红而干,舌苔少或无,脉细数。

- **肺肾两虚证**:可出现口舌咽干、形体消瘦、皮肤干燥、干咳少痰等表现,舌质红而干,舌苔少或无,脉细数。


这些证候均以阴津亏虚或燥热伤津为核心病机,常伴不同程度的疲劳或乏力。

  • 追踪检索片段,调整chunk_size的值,可以让embedding模型切分出的片段更合理,提高RAG系统的表现。
  • 如果想追踪更多的检索片段,可以提高 similarity_top_k 的值。
  • 如果想追踪片段具体的相似度得分(Similarity Score)的值,可以将log中的level设置为DEBUG级别。

8.  RAG 检索底层实现细节

知道了如何追踪哪些文档片段被用于检索增强生成,但我们仍不知道RAG过程中到底发生了什么,为什么大模型能够根据检索出的文档片段进行回复?

import logging
import sys
import os
from llama_index.core import PromptTemplate, Settings, StorageContext, load_index_from_storage
from llama_index.core.callbacks import LlamaDebugHandler, CallbackManager
from llama_index.llms.dashscope import DashScope, DashScopeGenerationModels
from llama_index.embeddings.dashscope import DashScopeEmbedding, DashScopeTextEmbeddingModels
from basic_embeddings import BatchedDashScopeEmbedding

# 定义日志
logging.basicConfig(stream=sys.stdout, level=logging.DEBUG)
logging.getLogger().addHandler(logging.StreamHandler(stream=sys.stdout))


# 定义system prompt
SYSTEM_PROMPT = """You are a helpful AI assistant."""
query_wrapper_prompt = PromptTemplate(
    "[INST]<<SYS>>\n" + SYSTEM_PROMPT + "<</SYS>>\n\n{query_str}[/INST] "
)

# 1. 指定全局llm与embedding模型
Settings.llm = DashScope(model_name="qwen3-max",api_key=os.getenv("DASHSCOPE_API_KEY"))
# 使用
Settings.embed_model = BatchedDashScopeEmbedding(model_name="text-embedding-v4")
# 使用LlamaDebugHandler构建事件回溯器,以追踪LlamaIndex执行过程中发生的事件
llama_debug = LlamaDebugHandler(print_trace_on_end=True)
callback_manager = CallbackManager([llama_debug])
Settings.callback_manager = callback_manager


# 从存储文件中读取embedding向量和向量索引
storage_context = StorageContext.from_defaults(persist_dir="./doc_emb")

# 根据存储的embedding向量和向量索引重新构建检索索引
index = load_index_from_storage(storage_context)

# 构建查询引擎
query_engine = index.as_query_engine(similarity_top_k=5)

# 查询获得答案
response = query_engine.query("不耐疲劳,口燥、咽干可能是哪些证候?")
print(response)

# get_llm_inputs_outputs 返回每个LLM调用的开始/结束事件
event_pairs = llama_debug.get_llm_inputs_outputs()

# print(event_pairs[0][1].payload.keys()) # 输出事件结束时所有相关的属性

# 输出 Promt 构建过程
print(event_pairs[0][1].payload["formatted_prompt"])

8.1 Query 过程分析

**********
Trace: query
    |_CBEventType.QUERY -> 8.996684 seconds
      |_CBEventType.RETRIEVE -> 1.206115 seconds
        |_CBEventType.EMBEDDING -> 1.05868 seconds
      |_CBEventType.SYNTHESIZE -> 7.789572 seconds
        |_CBEventType.TEMPLATING -> 0.0 seconds
        |_CBEventType.LLM -> 7.78084 seconds
**********

以上的输出记录了query在程序过程中经历的阶段和所用的时间,整个过程分为两个阶段:

  • 抽取(retrieve)
  • 合成(synthesize)。

合成阶段的templating步骤会将query和抽取出来的文档片段组合成模板,构成新的query,然后调用LLM,得到最终的response。

所以,只要找到templating所构建的新query,就可以知道为什么大模型能够根据我们检索出来的文档进行回复了。

8.2  formatted_prompt

线上api没有返回这个属性,我们在本地部署大模型时可以拿到这个属性。该属性可以看到原始 query 到新 query 的构建过程。

8.3 Retrieve 检索进阶

抽取(retrieve)阶段的retrievers模块规定了针对查询从知识库获取相关上下文的技术。我们之前使用的都是默认的方法,其实LlamaIndex官方为我们提供了一些其他常用的方法:

  • SimilarityPostprocessor: 使用similarity_cutoff设置阈值。移除低于某个相似度分数的节点。
  • KeywordNodePostprocessor: 使用required_keywords和exclude_keywords。根据关键字包含或排除过滤节点。
  • MetadataReplacementPostProcessor: 用其元数据中的数据替换节点内容。
  • LongContextReorder: 重新排序节点,这有利于需要大量顶级结果的情况,可以解决模型在扩展上下文中的困难。
  • SentenceEmbeddingOptimizer: 选择percentile_cutoff或threshold_cutoff作为相关性。基于嵌入删除不相关的句子。
  • CohereRerank: 使用coherence ReRank对节点重新排序,返回前N个结果。
  • SentenceTransformerRerank: 使用SentenceTransformer交叉编码器对节点重新排序,产生前N个节点。
  • LLMRerank: 使用LLM对节点重新排序,为每个节点提供相关性评分。
  • FixedRecencyPostprocessor: 返回按日期排序的节点。
  • EmbeddingRecencyPostprocessor: 按日期对节点进行排序,但也会根据嵌入相似度删除较旧的相似节点。
  • TimeWeightedPostprocessor: 对节点重新排序,偏向于最近未返回的信息。
  • PIINodePostprocessor(β): 可以利用本地LLM或NER模型删除个人身份信息。
  • PrevNextNodePostprocessor(β): 根据节点关系,按顺序检索在节点之前、之后或两者同时出现的节点

8.4 响应合成器 response synthesizer

合成(synthesize)阶段的响应合成器(response synthesizer)会引导LLM生成响应,将用户查询与检索到的文本块混合在一起,并给出一个精心设计的答案。

LlamaIndex官方为我们提供了多种响应合成器:

  • Refine: 这种方法遍历每一段文本,一点一点地精炼答案。
  • Compact: 是Refine的精简版。它将文本集中在一起,因此需要处理的步骤更少。
  • Tree Summarize: 想象一下,把许多小的答案结合起来,再总结,直到你得到一个主要的答案。
  • Simple Summarize: 只是把文本片段剪短,然后给出一个快速的总结。
  • No Text: 这个问题不会给你答案,但会告诉你它会使用哪些文本。
  • Accumulate: 为每一篇文章找一堆小答案,然后把它们粘在一起。
  • Compact Accumulate: 是“Compact”和“Accumulate”的合成词。

我们来看如何指定retriever和response synthesizer。retriever选择SimilarityPostprocessor,response synthesizer选择Refine。

# 现在,我们选择一种retriever和一种response synthesizer。retriever选择SimilarityPostprocessor,response synthesizer选择Refine。

import logging
import sys
import os
from llama_index.core import PromptTemplate, Settings, StorageContext, load_index_from_storage, get_response_synthesizer
from llama_index.core.callbacks import LlamaDebugHandler, CallbackManager
from llama_index.core.indices.vector_store import VectorIndexRetriever
from llama_index.core.postprocessor import SimilarityPostprocessor
from llama_index.core.query_engine import RetrieverQueryEngine
from llama_index.core.response_synthesizers import ResponseMode
from llama_index.llms.dashscope import DashScope, DashScopeGenerationModels
from llama_index.embeddings.dashscope import DashScopeEmbedding, DashScopeTextEmbeddingModels
from basic_embeddings import BatchedDashScopeEmbedding

# 定义日志
logging.basicConfig(stream=sys.stdout, level=logging.INFO)
logging.getLogger().addHandler(logging.StreamHandler(stream=sys.stdout))

# 定义system prompt
SYSTEM_PROMPT = """You are a helpful AI assistant."""
query_wrapper_prompt = PromptTemplate(
    "[INST]<<SYS>>\n" + SYSTEM_PROMPT + "<</SYS>>\n\n{query_str}[/INST] "
)

# 1. 指定全局llm与embedding模型
Settings.llm = DashScope( model_name="qwen3-max",api_key=os.getenv("DASHSCOPE_API_KEY"))
Settings.embed_model = BatchedDashScopeEmbedding(model_name="text-embedding-v4")

# 使用LlamaDebugHandler构建事件回溯器,以追踪LlamaIndex执行过程中发生的事件
llama_debug = LlamaDebugHandler(print_trace_on_end=True)
callback_manager = CallbackManager([llama_debug])
Settings.callback_manager = callback_manager


# 读取文档并构建索引
storage_context = StorageContext.from_defaults(persist_dir="./doc_emb")
# 6.1 根据存储的 embedding 向量和向量索引加载索引
index = load_index_from_storage(storage_context)


# 构建retriever
retriever = VectorIndexRetriever(
    index = index,
    similarity_top_k = 5,
)

# 构建response synthesizer
response_synthesizer = get_response_synthesizer(
    response_mode = ResponseMode.REFINE
)

# 构建查询引擎
query_engine = RetrieverQueryEngine(
    retriever = retriever,
    response_synthesizer = response_synthesizer,
    node_postprocessors = [SimilarityPostprocessor(similarity_cutoff=0.6)]
)

# 查询获得答案
response = query_engine.query("不耐疲劳,口燥、咽干可能是哪些证候?")
print(response)

# get_llm_inputs_outputs返回每个LLM调用的开始/结束事件
event_pairs = llama_debug.get_llm_inputs_outputs()
print(event_pairs[0][1].payload["formatted_prompt"])

**********
Trace: query
    |_CBEventType.QUERY -> 14.843155 seconds
Trace: query
    |_CBEventType.QUERY -> 14.843155 seconds
    |_CBEventType.QUERY -> 14.843155 seconds
      |_CBEventType.SYNTHESIZE -> 13.722221 seconds
        |_CBEventType.TEMPLATING -> 0.0 seconds
        |_CBEventType.LLM -> 2.470622 seconds
      |_CBEventType.SYNTHESIZE -> 13.722221 seconds
        |_CBEventType.TEMPLATING -> 0.0 seconds
        |_CBEventType.LLM -> 2.470622 seconds
        |_CBEventType.TEMPLATING -> 0.0 seconds
        |_CBEventType.LLM -> 2.032839 seconds
        |_CBEventType.TEMPLATING -> 0.0 seconds
        |_CBEventType.LLM -> 2.470622 seconds
        |_CBEventType.TEMPLATING -> 0.0 seconds
        |_CBEventType.LLM -> 2.032839 seconds
        |_CBEventType.TEMPLATING -> 0.0 seconds
        |_CBEventType.TEMPLATING -> 0.0 seconds
        |_CBEventType.LLM -> 2.032839 seconds
        |_CBEventType.TEMPLATING -> 0.0 seconds
        |_CBEventType.LLM -> 2.098601 seconds
        |_CBEventType.TEMPLATING -> 0.0 seconds
        |_CBEventType.TEMPLATING -> 0.0 seconds
        |_CBEventType.LLM -> 2.098601 seconds
        |_CBEventType.TEMPLATING -> 0.0 seconds
        |_CBEventType.LLM -> 4.90759 seconds
        |_CBEventType.TEMPLATING -> 0.0 seconds
        |_CBEventType.LLM -> 4.90759 seconds
        |_CBEventType.LLM -> 4.90759 seconds
        |_CBEventType.TEMPLATING -> 0.0 seconds
        |_CBEventType.TEMPLATING -> 0.0 seconds
        |_CBEventType.LLM -> 2.195603 seconds
**********

可以看出,将response synthesizer由默认的Compact替换为Refine之后,query在程序过程中经历的阶段发生了变化,REFINE模式会进行更多次的templating和LLM调用。

实际开发中可以自由组合不同的retriever和response synthesizer,以完成我们的需求。当LlamaIndex提供的retriever和response synthesizer不能满足我们的需求的时候,我们还可以自定义retriever和response synthesizer。

9. 自定义 Prompt

LlamaIndex中提供的prompt template都是英文的,该如何使用中文的prompt template呢?

import logging
import sys
import os
from llama_index.core import PromptTemplate, Settings, SimpleDirectoryReader, VectorStoreIndex, load_index_from_storage, StorageContext, QueryBundle

from llama_index.core.callbacks import LlamaDebugHandler, CallbackManager
from llama_index.core.indices.vector_store import VectorIndexRetriever
from llama_index.core.postprocessor import SimilarityPostprocessor
from llama_index.core.query_engine import RetrieverQueryEngine
from llama_index.core.response_synthesizers import ResponseMode
from llama_index.llms.dashscope import DashScope, DashScopeGenerationModels
from llama_index.embeddings.dashscope import DashScopeEmbedding, DashScopeTextEmbeddingModels
from basic_embeddings import BatchedDashScopeEmbedding

# 定义日志
logging.basicConfig(stream=sys.stdout, level=logging.INFO)
logging.getLogger().addHandler(logging.StreamHandler(stream=sys.stdout))

# 1. 指定全局llm与embedding模型
Settings.llm = DashScope( model_name="qwen3-max",api_key=os.getenv("DASHSCOPE_API_KEY"))
Settings.embed_model = BatchedDashScopeEmbedding(model_name="text-embedding-v4")


# 定义system prompt
SYSTEM_PROMPT = """你是一个医疗人工智能助手。"""
query_wrapper_prompt = PromptTemplate(
    "[INST]<<SYS>>\n" + SYSTEM_PROMPT + "<</SYS>>\n\n{query_str}[/INST] "
)

# 定义qa prompt
qa_prompt_tmpl_str = (
    "上下文信息如下。\n"
    "---------------------\n"
    "{context_str}\n"
    "---------------------\n"
    "请根据上下文信息而不是先验知识来回答以下的查询。"
    "作为一个医疗人工智能助手,你的回答要尽可能严谨。\n"
    "Query: {query_str}\n"
    "Answer: "
)
qa_prompt_tmpl = PromptTemplate(qa_prompt_tmpl_str)

# 定义refine prompt
refine_prompt_tmpl_str = (
    "原始查询如下:{query_str}"
    "我们提供了现有答案:{existing_answer}"
    "我们有机会通过下面的更多上下文来完善现有答案(仅在需要时)。"
    "------------"
    "{context_msg}"
    "------------"
    "考虑到新的上下文,优化原始答案以更好地回答查询。 如果上下文没有用,请返回原始答案。"
    "Refined Answer:"
)
refine_prompt_tmpl = PromptTemplate(refine_prompt_tmpl_str)


# 使用LlamaDebugHandler构建事件回溯器,以追踪LlamaIndex执行过程中发生的事件
llama_debug = LlamaDebugHandler(print_trace_on_end=True)
callback_manager = CallbackManager([llama_debug])
Settings.callback_manager = callback_manager


# 从存储文件中读取embedding向量和向量索引
storage_context = StorageContext.from_defaults(persist_dir="./doc_emb")
index = load_index_from_storage(storage_context)

# 构建查询引擎
query_engine = index.as_query_engine(similarity_top_k=5)

# 输出查询引擎中所有的prompt类型
prompts_dict = query_engine.get_prompts()
print(list(prompts_dict.keys()))

# 更新查询引擎中的prompt template
query_engine.update_prompts(
    {
        "response_synthesizer:text_qa_template": qa_prompt_tmpl,
        "response_synthesizer:refine_template": refine_prompt_tmpl
    }
)

# 查询获得答案
response = query_engine.query("不耐疲劳,口燥、咽干可能是哪些证候?")
print(response)

# 输出formatted_prompt
event_pairs = llama_debug.get_llm_inputs_outputs()
print(event_pairs[0][1].payload["formatted_prompt"])

10. RAG 效果评估

最后一步我们使用 Ragas 对系统进行评估,并给出评估指标。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐