大模型并非万能。它的训练数据有截止日期,此后的事它不知道;它的参数量有限,无法容纳所有知识。也就是说,大模型在实时性和专业性上都有所欠缺。
如何让大模型的知识变得实时且专业呢?最省力的方法是“打小抄”。知识库 就像大模型的“小抄”。在回答问题之前,先瞅一眼小抄,看有没有与问题相关的内容。如果有,就从知识库中取回相应的文本片段,结合大模型的推理能力,生成最终答案。
这里「打小抄」的动作,就是 RAG(Retrieval-Augmented Generation, 检索增强生成)。
一、提示词模板¶
RAG 做的事情并不复杂,就是从知识库中召回与用户问题有关的文本片段,作为上下文注入到 提示词模板 (Prompt Template) 中。
下面是一个提示词模板:
{context}
---
基于上面给出的上下文,回答问题。
问题:{question}
回答: 使用该模板时,请将召回文本填入 {context},将用户问题填入 {question}。然后把填好的提示词模板交给大模型推理。
二、向量检索¶
完成「从知识库召回与问题有关的文本片段」这件事,需要用到检索器。实现检索器的方式有 很多 ,比如基于关键词检索的 BM25 算法。但本节主要介绍基于 Embedding 的检索方法:向量检索。
1)从文本向量化说起¶
Embedding 是一种将文本转为向量的技术。它的输入是一段文本,输出是一个定长的向量。
"好喜欢你" --> [0.190012, 0.123555, .... ]将文本转为向量的目的是把语义相近的词分配到同一片向量空间。可以推知,近义词的向量会更近一些。比如,足球和篮球在向量空间中的距离更近,而足球和戴森球的距离更远。Embedding 的本质是压缩。从编码角度讲,自然语言存在太多冗余信息。Embedding 相当于对自然语言重编码,用最少的 token 表达最多的语义。
Embedding 在多语言场景下也有优势。经过充分地训练的 Embedding 模型,会将多语言内容在语义层面进行对齐。也就是说,我们可以用一个向量,在多语言环境下表达同一语义。这就让以 Embedding 为基座的大模型得以兼容并包。即使加入多语言材料,也不会因为字面上的词不同,而产生“理解”上的混乱。
2)向量检索原理¶
由于 Embedding 模型具有将相似语义的词训练成距离相近的向量的特性,我们可以把「用户问题」与「知识库内容」都转成 Embedding 向量。然后计算向量之间的距离。向量之间的距离越小,则语料之间的相似度越高。最后返回知识库中与问题向量距离最小的 TOP K 份语料即可。
我们用一个简单的例子验证这个想法。下面计算知识库中的每一条内容与用户问题之间的相似度,看看语义相近的内容是否具有更高的余弦相似度(余弦相似度大 -> 两个单位向量夹角小 -> 向量挨得更近)。
from dotenv import load_dotenv
from langchain_community.embeddings import DashScopeEmbeddings
from sklearn.metrics.pairwise import cosine_similarity
# 加载环境变量
_ = load_dotenv()# 用户问题
query = "过年要给不熟的亲戚发红包么?"
# 知识库
docs = [
"不来往的人就不要给他钱",
"海胆豆腐真好吃下次还吃",
"半熟牛排淋上不熟的芝士",
]
# 初始化向量生成器
embeddings = DashScopeEmbeddings()
# 生成向量
qv = embeddings.embed_query(query)
dv = embeddings.embed_documents(docs)
# 计算余弦相似度
similarities = cosine_similarity([qv], dv)[0]
results = list(enumerate(similarities))
by_sim = sorted(results, key=lambda r: r[1], reverse=True)
print("按余弦相似度排序:")
for i, s in by_sim:
print('-', docs[i], s)按余弦相似度排序:
- 不来往的人就不要给他钱 0.346204651165491
- 半熟牛排淋上不熟的芝士 0.11805922713791331
- 海胆豆腐真好吃下次还吃 0.09085072283609508
三、向量检索流程¶
上述代码虽然已经可以计算知识库内容与用户问题的相似度,但在工程化过程中还会遇到一些问题:
问题一:Embedding 模型对输入文本总长有限制,且输入文本过长会影响向量表达
问题二:在 Embedding 数量较多的情况下,难以快速召回 TOP K 最近邻向量
为了解决 问题一,我们需要做文本切块:将知识库中的文本切成大小均匀的文本片段。然后使用 Embedding 模型将这些文本片段转成 Embedding 向量。为了确保文本片段不会因截断而产生语义缺失,还要让相邻两个文本片段之间有一定的 overlap。问题二 一般通过引入 向量引擎 解决,向量引擎有成熟的 ANN 算法实现,可以帮助我们快速召回最近邻向量。
为了解决上述两个问题,我们的检索流程变得更复杂了一些。下面是一个典型的 向量检索流程:
* 圆框代表数据,方框代表组件。
这已经是一个最简的配置了。即使开发一个最简单的向量检索流程,也需要实现上面全部组件。由于 LangChain 使用模块化的编写方式,因此每个组件都是可替换的。下面粗体部分列出了图中包含的组件,右边是它们的一些变体:
Document Loader(文档加载器):
TextLoader,PyMuPDFLoader,WebBaseLoaderDocument Splitter(文档分割器):
RecursiveCharacterTextSplitterEmbedding Generation(向量生成器):
DashScopeEmbeddings,HuggingFaceEmbeddingsVector Store(向量存储):
Chroma,Milvus,FAISSRetriever(检索器):
EnsembleRetriever,BM25RetrieverLLM(大语言模型):
ChatOpenAI
在下一个小节中,我将实现一个完整的向量检索流程,它包含上述全部组件。
四、基于向量检索的 RAG¶
☝️🤓 我们来实现一个基于向量检索的 RAG。
import os
import bs4
from dotenv import load_dotenv
from langchain_openai import ChatOpenAI
from langchain_community.document_loaders import WebBaseLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_community.embeddings import DashScopeEmbeddings
from langchain_core.vectorstores import InMemoryVectorStore
from langchain.tools import tool
from langchain.agents import create_agent
# 加载模型配置
_ = load_dotenv()
# 加载模型
llm = ChatOpenAI(
model="qwen3-max",
api_key=os.getenv("DASHSCOPE_API_KEY"),
base_url=os.getenv("DASHSCOPE_BASE_URL"),
)USER_AGENT environment variable not set, consider setting it to identify your requests.
1)加载文档¶
使用 WebBaseLoader 加载 《阿里发布新版 Quick BI,聊聊 ChatBI 的底层架构、交互设计和云计算生态》 这篇文章的内容。
# 加载文章内容
bs4_strainer = bs4.SoupStrainer(class_=("post"))
loader = WebBaseLoader(
web_paths=("https://luochang212.github.io/posts/quick_bi_intro/",),
bs_kwargs={"parse_only": bs4_strainer},
requests_kwargs={"headers": {"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36"}},
)
docs = loader.load()
assert len(docs) == 1
print(f"Total characters: {len(docs[0].page_content)}")
print(docs[0].page_content[:248])Total characters: 4222
8月28日,阿里云发布了数据分析工具 Quick BI 的全新版本。它是大模型应用在 BI 行业的最新实践。Quick BI 在云计算基础设施之上,搭建了一个 ChatBI 应用。阿里为这个应用起了一个拟人化的名字:智能小Q。智能小Q允许用户以对话的形式探索数据。无需写 SQL,只需与小Q对话,即可获得想要的统计信息。
智能小Q其实是一个多智能体系统(multi-agent system),包含多个 Agent:
报告 Agent
问数 Agent
搭建 Agent
解读 Agent
2)分割文档¶
使用 RecursiveCharacterTextSplitter 将文本分块,以便后续计算文本块的 Embedding。
# 文本分块
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=1000, # chunk size (characters)
chunk_overlap=200, # chunk overlap (characters)
add_start_index=True, # track index in original document
)
all_splits = text_splitter.split_documents(docs)
print(f"Split blog post into {len(all_splits)} sub-documents.")Split blog post into 7 sub-documents.
3)向量生成¶
注意,用户问题 和 知识库 必须使用同一个 Embedding 模型来生成向量。
# 初始化向量生成器
embeddings = DashScopeEmbeddings()4)向量存储¶
这里仅用 InMemoryVectorStore 做演示。正式项目请用 Chroma、Milvus 等向量数据库。
# 初始化内存向量存储
vector_store = InMemoryVectorStore(embedding=embeddings)
# 将文档添加到向量存储
document_ids = vector_store.add_documents(documents=all_splits)
print(document_ids[:2])['da824289-f1ee-4b02-a3d9-7a65a0eee308', 'd7fbe9af-a00a-45e8-a70b-7a8085af45ad']
5)创建工具¶
创建可被 Agent 调用的工具。该工具从向量存储中召回 k=2 个与 query 最相似的文本片段。
# 创建上下文检索工具
@tool(response_format="content_and_artifact")
def retrieve_context(query: str):
"""Retrieve information to help answer a query."""
retrieved_docs = vector_store.similarity_search(query, k=2)
serialized = "\n\n".join(
(f"Source: {doc.metadata}\nContent: {doc.page_content}")
for doc in retrieved_docs
)
return serialized, retrieved_docs6)召回文本¶
使用 Agent 调用检索工具,召回与问题有关的上下文。
# 创建 ReAct Agent
agent = create_agent(
llm,
tools=[retrieve_context],
system_prompt=(
# If desired, specify custom instructions
"You have access to a tool that retrieves context from a blog post. "
"Use the tool to help answer user queries."
)
)
# 调用 Agent
response = agent.invoke({
"messages": [{"role": "user", "content": "当前的 Agent 能力有哪些局限性?"}]
})
# # 获取 Agent 的完整回复
# for message in result["messages"]:
# message.pretty_print()
# 获取 Agent 的最终回复
response['messages'][-1].pretty_print()================================== Ai Message ==================================
当前的 Agent 能力存在以下几个主要局限性:
1. **长期记忆能力不足**:
Agent 难以有效地区分和保留有用的历史对话信息,同时遗忘无用内容。这限制了其在跨会话场景中持续提升问答质量的能力。
2. **缺乏验证(Verification)能力**:
即使拥有记忆,Agent 也难以自主判断信息的正确性或可靠性。这种验证能力的缺失会影响其推理和决策的准确性。
3. **知识体系构建困难**:
在具备验证能力的基础上,Agent 还需要将经过验证的记忆进一步加工为结构化的知识体系。目前这一过程仍无法有效实现。
这些限制是当前基于 Agent 技术的产品(如 TRAE、Quick BI 等)普遍面临的挑战。未来随着 Agent 技术的突破,相关应用(例如 ChatBI)的能力也有望显著提升。
五、关键词检索¶
BM25 是一种基于词频的排序算法,它可以估计文档与给定查询的相关性。给定一个包含关键词 的查询 ,文档 的 BM25 分数是:
其中:
:关键词 在文档 中出现的次数
:文档 的词数
:文档集合的平均文档长度
:可调参数,用于控制词频饱和度,通常选择为
:可调参数,用于控制文档归一化程度,通常选择为
:关键词 的 IDF(逆文档频率)权重,用于衡量一个词的普遍程度,越常见的词值越低
对于中文关键词检索,需要安装支持分词的 Python 包:
# !pip install jieba1)创建检索器¶
我们使用 LangChain 提供的 BM25Retriever 创建检索器,并将 jieba 作为它的分词器。
import jieba
from langchain_community.retrievers import BM25Retriever
from langchain_core.documents import Documentdef chinese_tokenize(text: str) -> list[str]:
"""中文分词函数"""
tokens = jieba.lcut(text)
return [token for token in tokens if token.strip()]
# 1. 使用文本创建中文检索器
text_retriever = BM25Retriever.from_texts(
[
"何意味",
"那很坏了",
"这点小事也无所谓吧",
"我替她原谅你了",
],
k=2,
preprocess_func=chinese_tokenize,
)
# 2. 使用文档创建中文检索器
doc_retriever = BM25Retriever.from_documents(
[
Document(page_content="辣椒炒肉拌面"),
Document(page_content="肉蛋葱鸡"),
Document(page_content="这下不熟了"),
Document(page_content="铁串子"),
],
k=2,
preprocess_func=chinese_tokenize,
)Building prefix dict from the default dictionary ...
Loading model from cache /var/folders/71/g9q2ppqn1_3gxwzt_nm1_sjw0000gn/T/jieba.cache
Loading model cost 0.213 seconds.
Prefix dict has been built successfully.
2)使用检索器¶
# 检索文本
text_retriever.invoke("一件小事")[Document(metadata={}, page_content='这点小事也无所谓吧'),
Document(metadata={}, page_content='我替她原谅你了')]# 检索文档
doc_retriever.invoke("拌面")[Document(metadata={}, page_content='辣椒炒肉拌面'),
Document(metadata={}, page_content='铁串子')]六、混合检索¶
1)RRF 分数¶
你可以使用 RRF(Reciprocal Rank Fusion, 倒数排序融合)集成多个检索器的分数,以计算文档 的最终排名。
RRF 的计算公式为:
其中:
:第 个检索器的权重,默认值为 1.0
:平滑参数,默认值为 60
:文档在第 个检索器中的排名
基于 RRF 分数的混合检索可以通过向量引擎实现,详情参见文档:
2)Agentic Hybrid Search¶
根据第一性原理,既然可以直接用 LLM 判断召回文本的质量,何必计算 RRF 分数。
# 这是用户 query
query = "盘点海獭的黑历史"
# 这是 向量检索 召回的文本片段
dense_texts = [
"某海洋生物会乱扔垃圾",
"海獭太可爱了",
"海獭臭臭的",
]
# 这是 关键词检索 召回的文本片段
sparse_texts = [
"海獭臭臭的",
"雪鸮的黑历史",
]
# 返回最多 limit 个文本片段
def get_related_text(query: str,
dense_texts: list,
sparse_texts: list,
limit: int = 3):
# 创建上下文
texts = dense_texts + sparse_texts
context = '\n\n'.join(texts)
prompt = (
f"{context}\n"
"---\n"
"上面是RAG召回的文本片段。"
f"请返回最多{limit}条与用户问题有关的文本片段(允许少于{limit}条)。\n\n"
"输出的文本片段必须有助于回答用户问题!"
"若有多个文本片段相同,只需要保留其中一条。"
"注意不要输出除文本片段以外的任何东西。\n\n"
f"用户问题:{query}\n\n"
"文本片段:"
)
# 调用 llm
response = llm.invoke([
{"role": "system", "content": "你是一个RAG召回文本相关性检查助手"},
{
"role": "user",
"content": prompt
}
])
return response.content# 调用文本相关性检查助手
res = get_related_text(
query,
dense_texts,
sparse_texts,
)
print(res)海獭太可爱了
海獭臭臭的