使用LangChain和ChatGPT来对专业文档进行问答和解读

大家大多数情况下,是在OpenAI的 ChatGPT 官网上面对ChatGPT进行简单的问答。这时候是使用ChatGPT的训练数据来进行回答,相当于闭卷考试,而且它的知识只到2021年9月。

那怎么让ChatGPT来“开卷考试”作答呢?也就是说,如果我有一篇文档(论文、合同等),怎么能让ChatGPT参考并作答?

基本思路

基本思路很简单,就是:

从原文找到相关文本块 -> 放到prompt中让ChatGPT参考 -> ChatGPT开卷解答

当然,说得简单,实际执行起来每一步都跟“把大象放进冰箱”一样麻烦。

比如说,原文的对应段落怎么找?里面的prompt怎么写,ChatGPT更容易理解?每一个问题往深了研究,那都可以出一堆SCI文章。

不过,好就好在,有前人已经研究出了很多成果,并且开源的框架帮助把这些成功实现出来。其中之一便是LangChain。

使用LangChain具体实现

LangChain是一个比较新,且发展得很快的语言框架。
当然,发展得快的同义词是文档分分钟就落后于当前实现。LangChain也有这个问题。不过好就好在,LangChain整体文档都是比较简洁易懂的,而且很方便索引到源码,源码也有很清晰的说明和层次。有一些文档没跟上的情况就多看看源码,很容易理解。

我们用一个例子来说说怎么具体实现。比如,我有一份保险合同,我想知道什么年龄范围可以投保这个保险,合同中其中有一段话是:

投保年龄指投保时被保险人的年龄,投保年龄以周岁计算。本合同接受
的投保年龄范围为 0 周岁(须出生满 28 日)至 65 周岁,且须符合投保
当时我们的规定。

很显然答案是0 周岁(须出生满 28 日)至 65 周岁。那怎么使用LangChain和ChatGPT做到这点?下面将详细说明。

在进行下面的流程之前,建议先读 LangChain教程 Store and reference chat history ,对基本的使用和逻辑有一些概念。

从PDF中提取文本块

这一步最开始的预处理很好理解,就是把PDF的文字提取出来,后续给LLM处理。

这里我用 pymupdf ,性能比较高。使用其他的库,如 新版pypdf ,都可以。

import fitz # imports the pymupdf library

doc = fitz.open(pdf_path) # open a document
blocks = []
page_num = 0 # index starts from 1
for page in doc: # iterate the document pages
    page_num += 1
    if page_num < page_st:
        continue
    if (page_en != -1) and (page_num > page_en):
        break
    blocks.extend(page.get_text('blocks', sort=False)) # get plain text encoded as UTF-8

得到的blocks数组就是PDF的每一行,然后我们把相关行合并到一起,得到文本块数组chunks,其中的一个chunk就是:

投保年龄指投保时被保险人的年龄,投保年龄以周岁计算。本合同接受
的投保年龄范围为 0 周岁(须出生满 28 日)至 65 周岁,且须符合投保
当时我们的规定。

具体的分割和合并,不同的场景策略不一样,这里有个技巧,就是尽量按自然段放一起。

具体的库LangChain自然有,可以参考下面两个文档:

从原文找到相关文本块

我们的保险合同有几万字,几十页,而ChatGPT的GPT3.5只能传4k,最大也只能传16k,明显不能把所有的文本都放进去。

因此,我们需要剪枝,把用户提的问题(一般称为query),对应原文可能关联的文字拿出来,明显不相关的去掉。

文本块生成embedding

# read saved embedding
ebd_raw = {}
if r_embedding:
    with open(r_embedding, 'rb') as r:
        ebd_raw = pickle.load(r)

# generate embedding for each chunk
for i, chunk in enumerate(chunks):
    logtail = "i: %d" % (i)
    if chunk in ebd_raw: # easiest simple memory cache
        logger.info("chunk already exists, skip to next. chunk: <%s> %s", chunk, logtail)
        continue
    time.sleep(sleep)

    # openai python library using custom api_key and api_base
    openai.api_key = os.environ['EBD_OPENAI_API_KEY'] 
    openai.api_base = os.environ['EBD_OPENAI_API_BASE']

    # generate embedding
    ebd_res = openai.Embedding.create(input=[chunk], engine="text-embedding-ada-002")
    logger.info("chunk: <%s> ebd_res: <%s> %s", chunk, str(ebd_res).replace("\n", " "), logtail)

    ebd_raw[chunk] = ebd_res

    # write embedding to disk
    with open(w_embedding, 'wb') as w:
        pickle.dump(ebd_raw, w)

把embedding存入向量数据库

这里我用了faiss来存向量,官方教程用的是Chroma,LangChain都做了很好的封装,因此用起来大同小异。

from langchain.vectorstores import FAISS
from langchain.embeddings import OpenAIEmbeddings

tes = []
metas = []
for i, chunk in enumerate(chunks):
    ebd_item = ebd_raw[chunk]
    ebd_data = ebd_item["data"][0]["embedding"]
    source = "[Page num %d]" % (the page num of this chunk)
    meta = {'source': source}
    tes.append((chunk, ebd_data))
    metas.append(meta)

ebd_obj = OpenAIEmbeddings(openai_api_base=os.environ['EBD_OPENAI_API_BASE'], openai_api_key=os.environ['EBD_OPENAI_API_KEY'])
faiss_obj = FAISS.from_embeddings(tes, ebd_obj, metadatas=metas)

放到prompt中让ChatGPT参考

import langchain
doc_chain = langchain.chains.qa_with_sources.load_qa_with_sources_chain(model, chain_type="stuff")

这个doc_chain,就会在执行的时候提取用户query的embedding,然后通过向量数据库搜索到最相关的文本块出来。具体load_qa_with_sources_chain的说明可以 参考这里

关于chain_type,目前有 stuffrefinemap_reducemap_rerank 四种方式,可以在 LangChain文档:Documents 中看到具体用法。

prompt模板和流程在 question_generator 里生成,比如我用 stuff 的chain_type,对应的prompt模板就在 stuff_prompt.py 这个源码里。

ChatGPT开卷解答

model = langchain.chat_models.ChatOpenAI(model_name="gpt-3.5-turbo", temperature=0) # use OPENAI_API_BASE and OPENAI_API_KEY env vars to power OpenAI
question_generator = langchain.chains.LLMChain(llm=model, prompt=langchain.chains.conversational_retrieval.prompts.CONDENSE_QUESTION_PROMPT)

from langchain.chains import ConversationalRetrievalChain
qa = ConversationalRetrievalChain(retriever=faiss_obj.as_retriever(), question_generator=question_generator, combine_docs_chain=doc_chain,)

具体ConversationalRetrievalChain的说明可以 参考这里

# see the differences in https://python.langchain.com/docs/guides/debugging
langchain.debug = True
# langchain.verbose = True

query = '什么年龄范围可以投保这个保险'
chat_history = []
logger.info("now start qa. query: <%s> %s", query, logtail)
result = qa({"question": query, "chat_history": chat_history})
logger.info("qa result: <%s> %s", str(result), logtail)

执行后,程序很准确地提供了答案,并给出了出处在11页,方便我们回溯确认:

qa result: <{'question': '什么年龄范围可以投保这个保险', 'chat_history': [], 'answer': '投保年龄范围为0周岁(须出生满28日)至65周岁。\nSOURCES: [Page num 11]'}>

到底给ChatGPT都说了些啥

很神奇,对不对?

到底我们给ChatGPT说了什么咒语,让它变得这么聪明呢?

由于我们开了debugging,所以很直观地明白发生什么事情,我们来看看给ChatGPT的最终prompt:

Human: Given the following extracted parts of a long document and a question, create a final answer with references (\"SOURCES\"). 
If you don't know the answer, just say that you don't know. Don't try to make up an answer.
ALWAYS return a \"SOURCES\" part in your answer.

(LangChain自带例子1)
(LangChain自带例子2)

QUESTION: 什么年龄范围可以投保这个保险
=========
Content: 投保年龄指投保时被保险人的年龄,投保年龄以周岁计算。本合同接受的投保年龄范围为 0 周岁(须出生满 28 日)至 65 周岁,且须符合投保当时我们的规定。
Source: [Page num 11]

Content: 为未成年人投保的人身保险,在被保险人成年之前,因被保险人身故给付的保险金总和不得超过国务院保险监督管理机构规定的限额,身故给付的保险金额总和约定也不得超过前述限额。
Source: [Page num 5]

(更多文本块)

=========
FINAL ANSWER:

这其实就是Instruction Prompting中的few-shot prompting,从faiss中找例子这个流程就是Retrieval-Augmented Generation。说白了,就是给一些例子,给一些相关的文本块,让LLM自动推理。

更细节的论文说明可以看 Prompt Engineering (by lilianweng) 这篇二手论文解读文章,里面把大量的langchain参考的原始论文的核心思想对应的论文,每篇都用几句话总结,很经典。

参考资料

Leave a comment