QAnything 1.4.1 文档处理逻辑以及检索逻辑分析
2024-9-12
| 2024-9-23
Words 7574Read Time 19 min
type
Post
status
Published
date
Sep 12, 2024
slug
summary
介绍部分qanything的文档解析逻辑,如excel解析、pdf解析、ocr识别,检索逻辑以及ocr+llm做图片逻辑,大模型做reranker的思路
tags
开发
category
技术分享
icon
password
💡
以tag1.4.1 为主,项目更新频率较快,其他版本可能和下面的讲解略有出入。比如tag1.4.1用的是faiss,而最新的master分支已经换成了milvus 几周前,Qanything2.0已经发布,和本文或许略有出入,想了解2.0内容的,可以参考我的其他文章
 

文档处理逻辑

CSV文档

.xlsx结尾的excel文档也是转换成CSV文档进行处理的。
QAnything/qanything_kernel/utils/loader/csv_loader.py
每一行作为一个document,按照下面这个逻辑进行拼接的,当然我们也可以自定义拼接逻辑:
但是qanything处理合并单元格的逻辑有bug:
我们知道pd.read_excel的时候,会拆分合并单元格。处理逻辑是将第一个格子赋值为合并单元格的内容,其余的都是用pd.nan代替。那么将excel转换成csv之后这个格子仍然是nan。
然后qanything也意识到了这个问题,于是增加一个变量last_non_empty_values 用于记录上一个非空值,能在一定程度上解决合并单元格的问题。
但是看下面这张图片:
notion image
我合并单元格下面的单元格没有内容了,这时候qanything会将上一个合并单元格的内容“待还款”作为“如何登录汇联易”这一行的“分类“这一栏的内容,显然是有问题的。
解决方案:针对单元格的操作处理
 

PDF文档

pdf文档会有一些表格以及图片的处理逻辑,且看qanything是如何进行处理的
快速PDF解析
这种解析方法是不会顾及表格格式的,甚至直接忽略了图片
pdf解析走的是这个else语句:
notion image
  1. fitz按照页码解析PDF文档提取文字,每两页之间的文字用\n\n隔开
💡
fitz并没有提供解析pdf表格的方法,推荐使用pdfplumber,其images、extract_text(), extract_tables()可以很方便的处理图片、文字、表格。
  1. 首先是针对换行符、制表符的处理,处理完之后变成了一大段文字。
  1. 处理标点符号
  1. 遍历规则
现在整个text根据标点符号被拆分成了包含一个个句子的列表。现在需要对这个列表里面的每句话进行遍历。
  1. 二次处理
二次处理之前会先对步骤4中处理的短句子使用\n进行拼接,使每个句子的长度小于200。这里还是小数数字中间用\n分开了,比如4.25,在第4步拆分成了[’4’.,’25’], 在这里变成了[’4.\n25’]
二次处理使用的是langchain的RecursiveCharacterTextSplitter ,按照["\n\n", "\n", " ", ""]的顺序进行拆分。
pdf_text_splitter = RecursiveCharacterTextSplitter(chunk_size=800, chunk_overlap=200, length_function=num_tokens)
这个方法传入了三个参数,其中有两个chunk_size=800, chunk_overlap=200 ,按照我对rag pdf拆分的理解,我以为是返回每个chunk长度为800,并且和之前的chunk有两百个字重复的chunk列表,这也是目前主流的做法。有两百个字重复也是为了保证语意上的完整性,确保后续query的流程可以准确的检索到相关的chunk。
可现实是这个方法执行完和第四步的结果一摸一样。每个chunk的size还是不到200,并且chunk之间也没有overlap。如果我们自己写的话,还是推荐要有overlap。
💡
我们来总结一下这个快速pdf解析的流程: 😀1. fitz按页读取pdf文档,表格按行读取,忽略图片,页与页之间\n\n分割; 😀2. 处理换行符,变成一大段文字 text,str类型; 😀3. 根据中文标点符号断句,比如一个。后面没有”,在中文里面认为这就是一个句子了,在后面加一个\n,处理完之后还是一个str类型的text; 😀4. 根据换行符将这个text拆分成列表lst_text, 遍历这个lst_text,判断每句话的长度是不是小于100,如果是不做处理,如果还大于100,按照空格拆分,然后再遍历按照空格拆分后的列表,如果还是大于100,则按照其他符号进行处理。总之这一步就是返回一个每句话长度都<100的句子列表; 😀5. 将第四步中相邻的句子进行合并,使得每个句子的长度小于200,返回一个每句话长度都<200的句子列表; 😀6. 继续进行拼接,使得每个chunk的长度为800,后一个chunk的前200个字是前一个chunk的后200个字,有助于语意连贯性。然后针对每个chunk注入file_id,使用bce进行embedding,然后存入faiss向量数据库中并在本地进行持久化存储。存向量数据库之前需要检查一下这个kb_id对应的本地存储是不是存在,如果存在的话则先进行加载,再将新的向量存进去。
 
pdf转markdown
为什么需要将pdf转成markdown格式:
快速pdf解析中忽略了图片,对表格的格式也没有进行处理,当用户提问到和表格或者图片有关的问题时,llm的回答可能差强人意。最最主要的原因,markdown是结构化的,解析后的pdf看上去要比快速pdf解析的结果更有层次感、更有结构。
逻辑还是挺复杂的。推荐一个解析pdf的工具可以试一试:
marker
Github
marker
Owner
VikParuchuri
Updated
Dec 5, 2024
整理成了如下格式,按照大小标题+内容的格式进行了整理:
 

图片

这里引申一下,针对于图片的文字理解问题,我认为目前有两种方式:
  1. ocr+大模型 (RAG)
💡
ocr+大模型也可以替代原有的ocr+正则的形式,之前我们提取图片关键内容,ocr识别完之后采用正则提取出我们想要的内容。由于考虑的情况比较多,正则往往写的很复杂,也不一定能完全覆盖住所有场景。比如我同事之前做了一个识别支付截图支付金额+支付时间的任务。他采用了“支付成功”这个关键词作为提取的标志,结果下一张图片变成了“交易成功”,他这套规则就废了。还有有些支付时间是2024年8月26日,有的是2024-08-26,他还得写一大堆规则适配这个时间,而采用大模型,可以指定输出时间格式为YY-mm-dd HH-MM-SS。现在只要我们写一个行之有效的prompt就可以得到想要的结果,而不是一大堆破代码。
  1. 多模态模型,比如internvl、gpt4v、qwen2-vl
多模态模型
缺点:
1) 多模态模型识别文字内容不准,比如岩山科技识别成了若山科技,相反纯ocr模型就不会出现这种问题,对文字的识别精度更高。
2)小B数的模型效果很差,想要差不多的效果都得20B以上的模型,这样的话占用显存较大、对财力的要求较高。
3)相比llm来说较弱的语言理解能力,比如下面我问的是2023年的,但是图片中都是2024年的,但是多模态大模型给出了对应的发薪日期。
notion image
4)得有加速框架支持,比如swift可以加速internvl,不然推理速度超级慢,可能要几十秒
5)可能会有一定的合规问题
优点:
1)对图片中文字的位置、颜色敏感
notion image
虽然我只想要图片最下面一行红字,模型把红框和红色背景的文字也都给我了,虽然有些出入,但是能明显感觉到模型是能感知到颜色的。
2)功能更全面,可以做目标检测、场景分析等任务。
 
ocr+大模型:
优点:
1) 出色的文字理解能力,还是问2023年8月发薪是什么时候的问题,大模型可以准确的得到没有2023年发薪日期的回答。
ocr结果:
qwen72b回复:
2) 推理速度较快,ocr最慢1s也会出结果,大模型输出文字越多耗时越长,所以这里得用流式输出,首token毫秒级别就可以生成,这个时间可忽略不计
3) 减少合规问题
缺点:
1)信息丢失:丢失了图片中文字的位置信息、颜色信息等,只留下了文字信息,比如问题问图片中右上角文字是什么,图片中的绿色背景的文字是什么,这种方式就完全不会回答出想要的答案了。
notion image
2)语意差错:ocr只是按行识别,如果一张图片里面包含了多块,按行识别会导致某些语意张冠李戴。如下图实测会让牟哥账户浮盈10w+,实际应该是周姐。因为丢失了位置信息,这个是硬伤,目前应该还没有技术解决。
notion image
3)只能做图片的文字理解任务,其他类型的图片任务比如目标检测等一律做不了。
 
这是一个完整的ocr+大模型的处理逻辑
ocr一行一行的将文字识别出来,识别成一个列表的形式,针对下面这张图片,ocr识别的结果为:
notion image
['00:35', '持仓', '三', '97 ** 20', '浮动盈亏[元]', '更新时间14:52:00', '-322945.45', '理财持仓', '账户资产', '总市值', '仓位)', '601417.53', '483588.00', '80.41%', '可用114402.52', '可取396.62', '名称/市值', '持仓/可用', '现价/成本', '浮动盈号', '中华企业', '4000', '2.460', '2931.74', '9840.000', '4000', '3.193', '-22.96%', '中工国际', '2000', '6.610', '-2957.57', '13220.000', '2000', '8.089', '-18.28%', '岩山科技', '3000', '2.400', '3001.39', '7200.000', '3000', '3.400', '-29.41%', '宇晶股份', '800', '18.060', '-3947.89', '14448.000', '800', '22.995', '-21.46%', '天海防务', '5000', '3.620', '-4134.31', '18100.000', '5000', '4.447', '-18.60%', '华银电力', '4000', '3.200', '4357.10', '12800.000', '4000', '4.289', '-25.39%', '国新能源', '2800', '2.390', '5583.62', '6692.000', '2800', '4.384', '-45.48%', '万安科技', '1000', '12.180', '-6098.09', '12180.000', '1000', '18.278', '-33.36%', '恒顺醋业', '1500', '6.780', '-6162.16', '10170.000', '1500', '10.888', '-37.73%', '新城控股沪', '3000', '8.620', '-10250.94', '三', '口', '<']
然后将识别的结果、以及query和PROMPT_TEMPLATE组装成prompt提问大模型:
对于图片理解类问题:
什么样的场景选择什么样的技术路线:
针对特定图片的理解选择1,针对很多张图片或者文档组成的知识库选择技术路线2
  1. 针对一张图片定向问一些问题,一张图片作为一个chunk即可,适用于实时聊天说图解画等场景。比如对于一张客户加入赚钱的聊天记录图片,我需要让大模型根据这张图片帮我写一段营销话术; 或者将图片信息整理成表格;或者这张图片描述了个什么场景等等。这种情况就不需要看qanything的源码了,是针对特定图片的理解,不需要在知识库中进行检索,直接按照上面代码的处理逻辑来。
  1. 有很多图片组成的一个知识库,提前ocr存到向量数据库中,因为库中存放了很多张图片的信息,来了一个问题之后,肯定是先检索top-k,再reranker,最后组好prompt后提问大模型。这个时候我们来看看qanything的处理逻辑。
回到qanything ocr本身
图片处理走的是这个elif语句,核心方法还是loader.load_and_split()
notion image
UnstructuredPaddleImageLoader 没有load_and_split方法,那么肯定就是继承了父类的,一层一层向上找,最终在base.py中找到了这个方法。先load文档,再split文档。
notion image
  1. 解析docs = self.load()
    1. 先load文档。这个会调用UnstructuredPaddleImageLoader 下面的_get_elements 方法得到图片ocr内容识别结果。ocr识别的逻辑是:按照图片中文字所在的位置,按行进行识别,识别的文字信息返回一个list,这里你也可以部署自己的ocr服务,识别接口改成自己的。
    2. 这里比如一张图片,识别到了21行内容,经过这步处理会变成21个doc,前提是源码中的mode=“elements”的值没有改变。注意这里的mode有三种模式,不同的模式产生的最终的doc的数量是不一样的,下面会讲到。 loader = UnstructuredPaddleImageLoader(self.file_path, ocr_engine, mode="elements")
  1. 解析_text_splitter.split_documents(docs)
    1. 再split这21个文档。按照快速pdf解析的逻辑(略有出入但总体大差不差),将每个doc拆分成<100长度多个doc。
      _text_splitterChineseTextSplitter 的类对象,但是该类没有split_documents 方法,同样还是继承了父类的,一层一层向上找,最终在text_splitter.py中找到了。然后split_documents 方法内部调用了ChineseTextSplitter 类下的split_text 方法。 上面的21个doc经过这步可能会变成26个doc。
      notion image
  1. 相邻的文字进行合并,总长度<200,doc数量又变少了。
  1. 存faiss向量数据库。
到这里整个处理图片的逻辑就讲完了。
 
💡
说一下问题: 一张图片里面文字如果多的话,会拆分成多个chunk,然后用户提问进行检索的时候很有可能只检索到了图片中的某几个chunk是最相似的。然后llm在进行回答的时候会缺少一些图片信息。我认为合理的方式应该是将图片的一整个ocr内容作为一个整体的chunk进行向量化存储,图片或者表格的内容就不要进行拆分了。
这个就是elements模式造成的结果,事实上看源码我知道了mode有三种模式,分别是elementspagedsingle,其中single模式是将整个ocr的内容作为一个doc。ocr识别的图片结果行与行之间用“\n\n”分开。 loader = UnstructuredWordDocumentLoader(self.file_path, mode="elements")
notion image
注意设置mode=‘single’只是影响了docs = self.load() 这一步,最终只产生一个doc,这个doc包含了这张图片的整个ocr内容。但是后面还有一步split的过程,还会将这个doc拆分成多个,我们要的是最终只产生一个doc,因为只有这样,才可以将整个ocr的结果送给大模型理解。
 

检索逻辑

  1. 根据query,从faiss向量数据库中检索得分最高的40个chunk
  1. 对这40个chunk按照file_id、chunk_id升序排序
  1. 遍历这40个chunk,进行chunk合并
    1. 看当前chunk和上一个chunk的chunk_id是不是递增的,如果是递增的并且合并后的chunk的encode(用tiktoken做了个编码)总长度小于等于800,则进行合并,否则不进行合并。
      理论上每个chunk都有一个分数(L2距离)的,你合并了这个分数怎么计算,源码中是取了第一个chunk_id的score。
      PS:所以你为啥不向量化800个token长度的chunk,不同的chunk之间有个overlap呢。看之前的pdf文档解析逻辑,我们知道每个chunk的长度是小于200的,不同的chunk之间没有overlap。qanything不是在文档解析的时候合并chunk,而是在检索的时候进行chunk的合并,有点不太理解。
  1. (可选)将得分超过某个阈值的chunk筛掉,因为用的是L2距离,得分越高说明相似程度越低
  1. 筛选后的chunk去重
  1. reranker
    1. 常规reranker模型,比如bge-reranker,bce-reranker
    2. 大模型做reranker,有人说大模型做reranker太慢了,其实不是的呀,大模型的速度是和输出的token的长度有关的,只让大模型输出和query最匹配的序号就可以了。用的qwen-72b,几百ms内能得到相应结果。
    3. 💡
      根据你自己的项目需求选择合适的reranker方式,下面我会给一个我根据实际项目选择大模型reranker的例子
      这里在引申一下
      RAG做reranker基本上采用的是常规的reranker模型,但是依据不同的项目可以选择不同的reranker方式。我在这里介绍一下我的项目里面是怎么用大模型做reranker的,而且效果比bce-reranker更好。
      项目背景是这个样子的:我们公司有很多规章制度以及福利政策等等,很多员工其实对具体的细节包括在什么平台操作都是不清楚的。比如怎么走报销流程、访客怎么预约、公司的产假、婚假、事假、病假、年假怎么请等等二百多个常见问题,整理了一个excel表格。
      一开始的想法很好,将这二百多个问题使用bce向量化,然后用户来了一个问题之后,先retrieve再reranker,将得分最高的那个问题对应的答案返回给用户。
      这就有两个问题:
      1) 没数据测试
      2)用户的问法千变万化,这两百多个问题一定hold不住
      so解决:
      大模型泛化问题后人工进行审核
      这样一个问题泛化10条左右的问题,总共产生了2k+个问题。答案还是那两百多个答案,但是问题的问法变多了,这样上线后的用户问答会更精准一些。
      notion image
      这样讲一个答案对应的问题的80%用bce向量化存储,用20%的数据用于测试。
      测试结果:
      1)只retrieve 准确率:90.12%
      retrieve过程中会返回query和chunk的L2距离,取top-k=1,也就是得分最低的那个问题。看了看匹配错的问题,比如用户问了个“产假怎么请?”,结果匹配到了向量数据库里面存的“事假怎么请?”,就差一个字,但是表达的意思完全不一样。
      2)retrieve+bce reranker 准确率:90.26%
      加上reranker之后,虽然事假、产假可以分清楚了,但是福利年假和法定年假分不清楚了
      3) retrieve+ llm reranker 准确率:95.83%
      大模型,对语意更敏感,分类效果更好,大模型reranker的时候让其说了一下排序的理由,能明显看出大模型对一些细小差别的词语更较好的分辨。然后比较了一下和bce-reranker的区别,基本上大模型匹配错的,bce-reranker匹配的结果也是错的,但是bce-reranker匹配错的,大模型好多都是匹配正确的。
      分享一下我的prompt
      然后正则解析一下llm_resp就好,如果没有正确的解析到序号,我只遇到了一条,可以选择L2距离最低的那个兜底。当然如果用户问了个天气怎么样,知识库里面完全没有这个问题,但句子和句子之间总归会有个分数的,只是这个分数(L2距离)比较高而已,所以应该设置一个阈值,返回一个“无法回答此类问题”的兜底话术,当然这是工程问题了,就不在这里细细阐述了。
  1. 删除掉reranker分数低于阈值0.35的文档
  1. 取reranker top-k,默认是7。到这里检索到了和query最相似的7个相关chunk
  1. 到这里我们有一个query,和7个相关的chunk,可以组装prompt去提问大模型了。
    1. qanything给出了prompt模板:
 
  • 开发
  • OCR识别并提取关键内容:用大模型替换掉那繁琐的正则表达式吧huggingface模型文件的正确下载方式
    Loading...