AI 文摘

也谈langchain大模型外挂知识库问答系统核心部件:如何更好地解析、分割复杂非结构化文本





作者: 老刘说NLP  来源: [老刘说NLP](https://mp.weixin.qq.com/s/rOWfCQuUPohatMF_dU2nIA)

与昨日的热浪不同,2023年6月28日的北京,终于在一场不大不小的阴雨中迎来了满满的凉意。

那么,我们着来看看领域大模型问答的标准系统langchain。

具体的,我们先看看langchain进行行业文档问答时的一些核心步骤,包括用PPstructure进行文档分析,用latex进行文档表示的方法的介绍,以及如何进行文本切分,这些更为细节的事情,有助于更好的理解和使用它。供大家一起参考并思考。

一、先从Langchain的基本流程说起

在前面的文章中,我们已经说了多次,知识外挂是解决垂直行业领域问答很好的一种途径,用于解决大型语言模型能够回答LLM无法知道的问题。

虽然注入领域知识从方法论上,可以分成领域微调[增量预训以及领域微调]与上下文注入[外挂知识库] 两类方法。

其中:

微调Fine-Tuning是指使用其他数据训练现有语言模型,以针对特定任务对其进行优化。使用BERT或LLama等预训练模型,然后通过添加特定于用例的训练数据来适应特定任务的需求,而不是从头开始训练语言模型。但是,由于模型已经对大量的通用语言数据进行了训练,而特定域数据通常不足以覆盖模型已经学到的内容,这就是我们所说的学不进去的情况。

当上下文注入,即不修改LLM,专注于提示本身,并将相关上下文注入到提示中,让模型参考这个提示进行作答,但是其问题在于如何为提示提供正确的信息。目前我们所能看到的就是相关性召回,其有个假设,即问题的答案在召回的最相似的文档里。

但是,我们在实际操作的时候,是使用问题和文本来计算相似度。属于QA匹配,问题和答案匹配,这与我们之前所理解的QQ匹配不同。

langchain实现了上下文注入的工程架构和解决方案,“LangChain是一个用于开发由语言模型驱动的应用程序的框架。”(Langchain,2023)

如上图所示:

Langchain先使用索引模块中的文档加载器和文本拆分器加载和处理非结构化数据。然后使用提示模块将找到的内容,这个过程使用的是相似度计算召回。

之后,注入到提示模板中,并使用模型的模块将提示发送到模型完成生成。

因此,我们可以看到,如何处理非结构内容跟进行文本拆分十分重要,尤其是针对复杂文本的场景。

二、如何利用ppstructrue进行文档版面分析以及文本表示

因此,我们再顺着来看看经常遇到一些复杂文档的情况,这些文档中可能有表格,有图片,有单双栏等情况。

尤其是对于一些扫描版本的文档时候,则需要将文档转换成可以编辑的文档,这就变成了版面还原的问题。

具体的,可以利用ppstructrue进行文档版面分析,在具体实现路线上,图像首先经过版面分析模型,将图像划分为文本、表格、图像等不同区域,随后对这些区域分别进行识别。

例如,将表格区域送入表格识别模块进行结构化识别,将文本区域送入OCR引擎进行文字识别,最后使用版面恢复模块将其恢复为与原始图像布局一致的word或者pdf格式的文件。

地址:

https://github.com/PaddlePaddle/PaddleOCR/blob/release/2.6/ppstructure/README_ch.md

其中的,版面恢复将输入的图片、pdf内容仍然像原文档那样排列着,段落不变、顺序不变的输出到word文档中等。

提供了2种版面恢复方法,可根据输入PDF的格式进行选择,可以分别对于纯解析以及加入OCR的解析两种情况:

首先,标准PDF解析(输入须为标准PDF),其思想在于,基于Python的pdf转word库pdf2docx进行优化,该方法通过PyMuPDF获取页面元素,然后利用规则解析章节、段落、表格等布局及样式,最后通过python-docx将解析的内容元素重建到word文档中。

其次,图片格式PDF解析(输入可为标准PDF或图片格式PDF),其思想在于结合版面分析、表格识别技术,从而更好地恢复图片、表格、标题等内容,支持中、英文pdf文档、文档图片格式的输入文件。效果如下。

此外,在我们完成版面解析之后,我们接下来的工作就是将内容进行组织。其中涉及到图片、文本、表格三个不同的要素。

其中,对于图片,其无法直接放在文本中,但是可以对图片所在文档的位置进进行标记,如用图片名称作为占位符。

另外,如果遇到复杂的表格、公式等如何表示,一般可以使用markdown,html进行表示,但最合适的,写过论文的都知道,可以使用latex进行表示,关于这一块,我们在前面的文章中有过介绍,可以看看。

三、再看langchain如何将文档拆分为文本片段

在标准化文本之后,则需要对文本进行切分,即将文本划分为称为文本块的较小部分,一般而言,文本分割如果按照字符长度进行分割,这是最简单的方式,但会带来很多问题。

例如,如果文本是一段代码,一个函数被分割到两段之后就成了没有意义的字符。

因此,我们也通常会使用特定的分隔符进行切分,如句号,换行符,问号等。

我们来看看langchain中都有哪些文本拆分的方案。可以参考的链接是源代码:https://github.com/hwchase17/langchain/blob/master/langchain/text_splitter.py

langchain的内置文本拆分模块设定了几个参数:

chunk_size:文本块的大小,即文本块的最大尺寸;

chunk_overlap:表示两个切分文本之间的重合度,文本块之间的最大重叠量,保留一些重叠可以保持文本块之间的连续性,可以使用滑动窗口进行构造,这个比较重要。

length_function:用于计算文本块长度的方法,默认为简单的计算字符数;

以下图为例,使用CharacterTextSplitter方式进行切分对切分的定义如下:

from langchain.text_splitter import CharacterTextSplitter  
## 后期版本换成CharacterTextSplitter  
article_text = content_div.get_text()  
text_splitter = CharacterTextSplitter(  
    # Set a really small chunk size, just to show.  
    chunk_size = 100,  
    chunk_overlap  = 20,  
    length_function = len,  
)  

其中,我们可以更清楚的看到,langchain的text_splitter是怎么工作的。LangChain中最基本的文本分割器是CharacterTextSplitter,它按照指定的分隔符(默认“\n\n”)进行分割,并且考虑文本片段的最大长度,此外还有如下分割器:

1、LatexTextSplitter

沿着Latex标题、标题、枚举等分割文本,如下面的分割符涉及的多个符号,如chapter,section,subsection等。

if language == Language.LATEX:  
      return [  
          # First, try to split along Latex sections  
          "\n\\\chapter{",  
          "\n\\\section{",  
          "\n\\\subsection{",  
          "\n\\\subsubsection{",  
          # Now split by environments  
          "\n\\\begin{enumerate}",  
          "\n\\\begin{itemize}",  
          "\n\\\begin{description}",  
          "\n\\\begin{list}",  
          "\n\\\begin{quote}",  
          "\n\\\begin{quotation}",  
          "\n\\\begin{verse}",  
          "\n\\\begin{verbatim}",  
          # Now split by math environments  
          "\n\\\begin{align}",  
          "$$",  
          "$",  
          # Now split by the normal type of lines  
          " ",  
          "",  
      ]  
  
class LatexTextSplitter(RecursiveCharacterTextSplitter):  
    """Attempts to split the text along Latex-formatted layout elements."""  
  
    def __init__(self, **kwargs: Any) -> None:  
        """Initialize a LatexTextSplitter."""  
        separators = self.get_separators_for_language(Language.LATEX)  
        super().__init__(separators=separators, **kwargs)  

2、MarkdownTextSplitter

沿着Markdown的标题、代码块或水平规则来分割文本,根据markdown规则定义了一级、二级项目的切分规则。

if language == Language.MARKDOWN:  
    return [  
        # First, try to split along Markdown headings (starting with level 2)  
        "\n#{1,6} ",  
        # Note the alternative syntax for headings (below) is not handled here  
        # Heading level 2  
        # ---------------  
        # End of code block  
        "```\n",  
        # Horizontal lines  
        "\n\*\*\*+\n",  
        "\n---+\n",  
        "\n___+\n",  
        # Note that this splitter doesn't handle horizontal lines defined  
        # by *three or more* of ***, ---, or ___, but this is not handled  
        "\n\n",  
        "\n",  
        " ",  
        "",  
    ]  
      
 class MarkdownTextSplitter(RecursiveCharacterTextSplitter):  
    """Attempts to split the text along Markdown-formatted headings."""  
  
    def __init__(self, **kwargs: Any) -> None:  
        """Initialize a MarkdownTextSplitter."""  
        separators = self.get_separators_for_language(Language.MARKDOWN)  
        super().__init__(separators=separators, **kwargs)  
     
      

3、NLTKTextSplitter

使用NLTK的分割器,NLTK内置了切分函数。

# Standard sentence tokenizer.  
def sent_tokenize(text, language="english"):  
    """  
    Return a sentence-tokenized copy of *text*,  
    using NLTK's recommended sentence tokenizer  
    (currently :class:`.PunktSentenceTokenizer`  
    for the specified language).  
    :param text: text to split into sentences  
    :param language: the model name in the Punkt corpus  
    """  
    tokenizer = load(f"tokenizers/punkt/{language}.pickle")  
    return tokenizer.tokenize(text)  
      
## NLTKTextSplitter  
class NLTKTextSplitter(TextSplitter):  
    """Implementation of splitting text that looks at sentences using NLTK."""  
  
    def __init__(self, separator: str = "\n\n", **kwargs: Any) -> None:  
        """Initialize the NLTK splitter."""  
        super().__init__(**kwargs)  
        try:  
            from nltk.tokenize import sent_tokenize  
            self._tokenizer = sent_tokenize  
        except ImportError:  
            raise ImportError(  
                "NLTK is not installed, please install it with `pip install nltk`."  
            )  
        self._separator = separator  
  
    def split_text(self, text: str) -> List[str]:  
        """Split incoming text and return chunks."""  
        # First we naively split the large input into a bunch of smaller ones.  
        splits = self._tokenizer(text)  
        return self._merge_splits(splits, self._separator)  

4、PythonCodeTextSplitter

沿着Python类和方法的定义分割文本,这个可以针对代码类的文本进行切分,可以得到保留较好的完整性。

  elif language == Language.PYTHON:  
    return [  
        # First, try to split along class definitions  
        "\nclass ",  
        "\ndef ",  
        "\n\tdef ",  
        # Now split by the normal type of lines  
        "\n\n",  
        "\n",  
        " ",  
        "",  
    ]  
  
class PythonCodeTextSplitter(RecursiveCharacterTextSplitter):  
    """Attempts to split the text along Python syntax."""  
  
    def __init__(self, **kwargs: Any) -> None:  
        """Initialize a PythonCodeTextSplitter."""  
        separators = self.get_separators_for_language(Language.PYTHON)  
        super().__init__(separators=separators, **kwargs)  
          
          

5、SpacyTextSplitter

使用Spacy的分割器,spacy有内置的分割器模块。

class SpacyTextSplitter(TextSplitter):  
    """Implementation of splitting text that looks at sentences using Spacy."""  
  
    def __init__(  
        self, separator: str = "\n\n", pipeline: str = "en_core_web_sm", **kwargs: Any  
    ) -> None:  
        """Initialize the spacy text splitter."""  
        super().__init__(**kwargs)  
        try:  
            import spacy  
        except ImportError:  
            raise ImportError(  
                "Spacy is not installed, please install it with `pip install spacy`."  
            )  
        self._tokenizer = spacy.load(pipeline)  
        self._separator = separator  
  
    def split_text(self, text: str) -> List[str]:  
        """Split incoming text and return chunks."""  
        splits = (str(s) for s in self._tokenizer(text).sents)  
        return self._merge_splits(splits, self._separator)  

6、RecursiveCharacterTextSplitter

用于通用文本的分割器。它以一个字符列表为参数,尽可能地把所有的段落(然后是句子,然后是单词)放在一起

class RecursiveCharacterTextSplitter(TextSplitter):  
    def __init__(  
        self,  
        separators: Optional[List[str]] = None,  
        keep_separator: bool = True,  
        **kwargs: Any,  
    ) -> None:  
        """Create a new TextSplitter."""  
        super().__init__(keep_separator=keep_separator, **kwargs)  
        ## 可以看到切分符号是["\n\n", "\n", " ", ""]  
        self._separators = separators or ["\n\n", "\n", " ", ""]   
  
    def _split_text(self, text: str, separators: List[str]) -> List[str]:  
        """将输入的文本切分成chunks块"""  
        final_chunks = []  
        # Get appropriate separator to use  
        separator = separators[-1]  
        new_separators = []  
        for i, _s in enumerate(separators):  
            if _s == "":  
                separator = _s  
                break  
            if re.search(_s, text):  
                separator = _s  
                new_separators = separators[i + 1 :]  
                break  
                  
        splits = _split_text_with_regex(text, separator, self._keep_separator)  
        # Now go merging things, recursively splitting longer texts.  
        _good_splits = []  
        _separator = "" if self._keep_separator else separator  
        for s in splits:  
            if self._length_function(s) < self._chunk_size:  
                _good_splits.append(s)  
            else:  
                if _good_splits:  
                    merged_text = self._merge_splits(_good_splits, _separator)  
                    final_chunks.extend(merged_text)  
                    _good_splits = []  
                if not new_separators:  
                    final_chunks.append(s)  
                else:  
                    other_info = self._split_text(s, new_separators)  
                    final_chunks.extend(other_info)  
        if _good_splits:  
            merged_text = self._merge_splits(_good_splits, _separator)  
            final_chunks.extend(merged_text)  
        return final_chunks  
  
    def split_text(self, text: str) -> List[str]:  
        return self._split_text(text, self._separators)  

总结

本文介绍了langchain进行行业文档问答时的一些核心步骤,包括如何用PPstructure进行文档分析,用latex进行文档表示,以及如何进行文本切分。

这些与向量化表示一起构成了整个方案的核心。我当然,本文做的只是一些简单的介绍,大家感兴趣的可以自行尝试并在参考文献中延伸阅读。

参考文献

1、https://towardsdatascience.com/all-you-need-to-know-to-build-your-first-llm-app-eb982c78ffac

2、https://github.com/PaddlePaddle/PaddleOCR/blob/release/2.6/ppstructure/README_ch.md

关于我们

老刘,刘焕勇,NLP开源爱好者与践行者,主页:https://liuhuanyong.github.io。

老刘说NLP,将定期发布语言资源、工程实践、技术总结等内容,欢迎关注。

对于想加入更优质的知识图谱、事件图谱实践、相关分享的,可关注公众号,在后台菜单栏中点击会员社区->会员入群加入。

可关注我们的公众号:每天AI新工具