从本文开始,将开一个大坑,陆续介绍企业级文档问答系统构建的全流程,以及关键环节的优化手段。重点介绍算法流程。
构建一个基础版的RAG是非常简单的,甚至使用扣子、Dify等平台,熟练的情况下都用不了5分钟,即使使用Langchain、LlamaIndex等框架,搭建完整流程,代码也不会超过100行。但基础版的问答效果往往较差。
下面这张图是OpenAI介绍的RAG优化经验,这个准确率当然随不同的数据集会有不同,但基本上优化后的准确率比优化前有显著提升这个基本上是一致的。
问答系统构建完成后,总的流程是先对文档进行解析、切分,然后使用问题检索相关知识片段,最后将问题和知识片段输入LLM,生成答案。
在构建的过程中也是一样的,这三个环节是可以分别独立优化的,如下图所示:
本篇首先专注在如何获取QA数据,所谓的QA数据,就是“问题-回答”数据,理想情况下,如果包含回答所用到的文档片段是更好的。部分系统(如客服系统)是有这方面数据的,但绝大多数情况下是没有的,这时就需要首先构造一批问答数据,这是后续所有环节最重要的一步。
本系列将会使用中国银行所发布的《2024全球经济金融展望报告》作为文档,围绕针对这个文档的问答效果优化展开。
本文所介绍的方法,会使用千问官方的qwen-long模型,对《2024全球经济金融展望报告》这个文档抽取QA,这个模型足够便宜,抽取的结果质量也还不错。QA抽取包含如下3个步骤:
短文档片段QA抽取:这部分模拟日常情况下,经常会询问细节性问题的使用场景
长文档片段QA抽取:这部分模拟需要综合较多上下文才能回答的使用场景
QA质量打分:使用LLM再次对抽取的QA进行质量评估,这一步算是借鉴了微软phi-1.5模型Textbooks Are All You Need论文中的方法,就是借助模型对数据质量进行评估
整个过程花费不到1元,结果已经抽取好了,大家可以直接使用。
本文所对应代码已开源,地址在:https://github.com/Steven-Luo/MasteringRAG/blob/main/00_PDF%E8%A7%A3%E6%9E%90%E4%B8%8EQA%E6%8A%BD%E5%8F%96_v1.ipynb
代码在Google Colab环境下进行了测试,正常情况下,安装Anaconda基本上会包含大部分所用到的包,再安装如下包即可:
pip install langchain langchain_community pypdf openai
为了便于大家复现,打印所安装的版本:
, , , module (langchain, langchain_community, pypdf, openai): ()
langchain 0.2.8 langchain_community 0.2.7 pypdf 4.3.0 openai 1.35.14
设置API key
os.environ[] = os.environ[] =
PyPDFLoader
Document
RecursiveCharacterTextSplitter
uuid4
(documents, filepath, chunk_size=, chunk_overlap=, seperators=[, ], force_split=):
os.path.exists(filepath) force_split:
()
pickle.load((filepath, ))
splitter = RecursiveCharacterTextSplitter(
chunk_size=chunk_size,
chunk_overlap=chunk_overlap,
separators=seperators
)
split_docs = splitter.split_documents(documents)
chunk split_docs:
chunk.metadata[] = (uuid4())
pickle.dump(split_docs, (filepath, ))
split_docs
loader = PyPDFLoader()
documents = loader.load()
pattern =
merged_docs = [Document(page_content=.join(re.sub(pattern, , doc.page_content) doc documents))]
splitted_docs = split_docs(documents, os.path.join(output_dir, ), chunk_size=, chunk_overlap=)
splitted_docs_large = split_docs(merged_docs, os.path.join(output_dir, ), chunk_size=, chunk_overlap=)
uuid2doc = {doc.metadata[]: doc.page_content doc splitted_docs}
uuid2large_doc = {doc.metadata[]: doc.page_content doc splitted_docs_large}既然是构造QA,那最好是保留回答问题时所使用的上下文,方便后续环节的优化。
4.1 QA抽取Prompt
这一步核心的2个Prompt如下:
qa_gen_prompt_tmpl =
{{document}}
qa_gen_prompt_tmpl_large_context =
{{document}}4.2 QA抽取代码
抽取核心代码,此处使用多线程加速抽取,考虑到网络请求异常情况会比较多,因此增加失败重试机制,同时考虑到这是一个耗时操作,并保存中间结果,以确保失败或者再次运行时,已经执行过的部分不会被重复执行:
OpenAI
tqdm
client = OpenAI(
api_key=os.environ[],
base_url=os.environ[]
)
(prompt_tmpl, text):
prompt = prompt_tmpl.replace(, text).strip()
prompt
(prompt, max_retry=, debug=, top_p=, temperature=):
(prompt):
completion = client.chat.completions.create(
model=,
messages=[
{: , : prompt}
],
top_p=top_p,
temperature=temperature
)
completion.choices[].message.content
max_retry > :
:
do_chat(prompt)
e:
max_retry -=
sleep_seconds = random.randint(, )
debug:
()
time.sleep(sleep_seconds)
(splitted_docs, prompt_tmpl, qa_ckpt_filename):
qa_ckpt = {}
os.path.exists(qa_ckpt_filename):
qa_ckpt = (qa_ckpt_filename).readlines()
qa_ckpt = [json.loads(line.strip()) line qa_ckpt line.strip() != ]
qa_ckpt = {item[]: item item qa_ckpt}
()
file_lock = threading.Lock()
max_workers =
concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) executor:
futures = {doc.metadata[]: executor.submit(chat, build_qa_prompt(prompt_tmpl, doc.page_content), , ) doc splitted_docs (doc.page_content.replace(, )) >= doc.metadata[] qa_ckpt}
uuid tqdm(futures):
future = futures[uuid]
result = future.result()
result :
item = {: uuid, : result}
qa_ckpt[uuid] = item
file_lock.acquire()
:
(qa_ckpt_filename, ) f:
f.write(json.dumps(item, ensure_ascii=) + )
e:
(e)
:
file_lock.release()
qa_ckpt
detailed_qa_dict = gen_qa(splitted_docs, qa_gen_prompt_tmpl, os.path.join(output_dir, ))
large_context_qa_dict = gen_qa(splitted_docs_large, qa_gen_prompt_tmpl_large_context, os.path.join(output_dir, ))4.3 抽取样例
[
{
: ,
: ,
:
},
{
: ,
: ,
:
},
{
: ,
: ,
:
},
{
: ,
: ,
:
},
{
: ,
: ,
:
},
{
: ,
: ,
:
},
{
: ,
: ,
:
},
{
: ,
: ,
:
}
]4.4 后置处理
从上面的样例可以看出,结果是被json...包裹的,没有办法直接解析为JSON,使用正则表达式进行后置处理,提取JSON
(text):
pattern =
text = text.replace(, )
:
json.loads(text)
:
match = re.search(pattern, text, re.DOTALL)
:
matched = match.group()
json.loads(matched)
e:
()
[]
(qa_ckpt, uuid2doc_map):
data = []
key, value tqdm(qa_ckpt.items()):
text = value[]
qa_list = convert2json(text)
item qa_list:
question = item.get(, ).strip()
answer = item.get(, ).strip()
context = item.get(, ).strip()
question == answer == :
(qa_list)
data.append({
: key,
: question,
: answer,
: context,
: uuid2doc_map[key]
})
qa_df = pd.DataFrame(data)
qa_df
qa_df = build_qa_df(detailed_qa_dict, uuid2doc)
qa_df.drop_duplicates(, inplace=)
qa_df[] =
large_context_qa_df = build_qa_df(large_context_qa_dict, uuid2large_doc)
large_context_qa_df.drop_duplicates(, inplace=)
large_context_qa_df[] =
qa_df = pd.concat([qa_df, large_context_qa_df])这部分就是对qa_df中的问题-回答对,再打一次分,然后过滤低分结果,Prompt如下:
qa_check_prompt_tmpl =
{{question}}
{{answer}}总体又是一个循环,与QA抽取部分非常相似,此处不再粘贴代码,需要的朋友们请访问代码仓库。
5.1 打分结果样例
5.2 3分样例
问:报告中提到的主要经济体GDP增速变化趋势的图的名称是什么?
答:主要经济体GDP增速变化趋势
上下文:图2:主要经济体GDP增速变化趋势(%)
5.3 2分样例
问:消费者借贷能力和意愿受到什么因素的影响?
答:美国家庭债务余额拖欠率回升至3%
上下文:美国家庭债务余额拖欠率回升至3%,消费者借贷能力和意愿将有所下降。
可以看出,低分问答对,质量确实相对较低
5.4 最终数据集构建
这部分首先保留4分及以上的问答对,然后随机挑选100条数据作为后续的测试集。至此,准备工作完成。
hq_qa_df = qa_df[qa_df[] >= ] test_q = hq_qa_df.sample(, replace=)[].values.tolist() hq_qa_df[] = hq_qa_df.loc[hq_qa_df[].isin(test_q), ] = hq_qa_df.to_excel(os.path.join(output_dir, ), index=)
