一、为什么要提取QA对
我们选择基于国产开源通用大模型ChatGLM开发《周易》大模型的应用,这需要我们从业务侧积累的问题、资料、文档中提取出一些指令-问答对,“喂”给通用大模型并进行参数的微调训练,从而形成专业的智能体。
因此提取QA对是很有必要的,这是前期做语料准备和数据预处理的重要步骤之一。
二、 QA对数据来源
本日志记录的QA对数据来自于山东大学周易研究中心研究人员自《易学百科全书》的概论卷与词汇卷整理得到的文档资料。存储格式为docx(word文档)格式。结构如下。

而概论卷中,仅包含了一个名词、概念解释的文档;而这样的文档在词汇卷中有8个(剩下两个是文档的说明,不是真实数据)。
![]()

其中的文本内容,基本形如:名词/概念后紧接着一段解释的形式。如下:

三、 提取QA对
3.1. 需求分析
这里需要做的事情就是把这种无结构的文本,转换成csv/xlsx文档,其中第一列是所有问题,第二列是所有问题对应的回答。由于数据量很大,无法手动完成,需要借助脚本程序完成。
3.2. 解决方案
(1)读入文档
docx不同于txt文件,它是微软旗下的Open XML文件格式,这样的文件需要一定的第三方组件来协助读取。我个人对Java比较熟悉,而Apache对这样的文件提供了一系列API,帮助开发者操作。
这里我选择建立一个新项目,专门用来存放处理数据的一些常用脚本的代码,以后遇到类似的需求亦可以复用程序。首先先导入Apache提供的第三方组件poi:
<dependencies>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.2.3</version>
</dependency>
</dependencies>
我们可以通过docx文件的输入流来建立一个XWPFDocument对象,这个对象包括了docx内的所有文本、样式、格式、页眉、页脚、脚注等内容。换句话说,就是把整个docx文档实例化了。
而遍历文本内容,就通过其提供的迭代器方法,对每个段落进行迭代(段落指脱离之前的文本,另起一行,就叫做一个新的段落):
try (FileInputStream fis = new FileInputStream(filePath)) {
XWPFDocument document = new XWPFDocument(fis);
Iterator<XWPFParagraph> iterator = document.getParagraphsIterator();
while (iterator.hasNext()) {
XWPFParagraph paragraph = iterator.next();
随后我们就可以对这些段落文本进行处理了。
(2) 文档分割
由之前的分析,我们知道一个文档由大量的问题-回答对组成,而我们需要把每个QA对拆出来,作为一行塞入excel文件中。那么最重要的问题就是,我们怎么区分每个QA对,并且,对于每个QA对来说,如何区分问题和回答?
对于第一个问题,答案藏在文档的结构中。既然这个文档只包含QA对,那么就代表如果我们能界定一个问题,那么后面紧接着的就是这个问题的回答;如果我们能界定一个回答,那么其后面紧接着的一定是下一个问题。因此我们需要做的就是界定问题和回答。
而这个问题对于概论卷的文档来说是很困难的,因为概论卷的问题和回答的格式都是正文格式,区分不开。

不过对于词汇卷的文档来说,就比较简单了。词汇卷的每个问题的第一个字符处,都有*和+这两个特殊符号,而回答的第一个字符一定不是这两个符号。这不是巧合,星号代表卦,加号代表文辞。

所以对于不同的文本,应该有不同的分割策略。例如对于词汇卷来说,我们就可以查每个段落的第一个字符是否是*号或+号,如果是,代表是一个问题,否则,就是回答的一部分。(注意,一个问题的回答可能涉及多个段落,如下图,涉及了三个段落,但是属于同一个回答)

而对于概论卷来说,我选择了这样一种方式,将问题的样式设置为标题一,而回答的样式设置为正文,这样我就能通过查看样式,来区分问题和回答了。如下图:

这样我们分割文档的方式就有至少两种了。但是我又想把分割文档的代码和读取文档段落的部分解耦,应该如何做呢?最终我选择了策略模式,这个放到后面的设计与编码实现的部分讲。
(3) 问答对的数据结构
假设我们拿到了一个字符串的列表list,这个列表的下标为0~n-1,对于任意下标i:
- 若i%2==0,那么这个字符串是一个问题
- 若i%2!=0,那么这个字符串是list[i-1]的回答(注意这个回答可能是由多个段落拼接而成的)
最终我们想把这个列表拆分成一个个问答对,写到excel文件中。如何做到?
这里我才用了列式存储的思路,最终的excel文件有两列,第一列列名叫做“问题”,第二列列名叫做“回答”,每一列列头下面就是这一列的内容,例如,第一列下第一行是“问题一”,第二行是“问题二”......那么这最适合用什么样的数据结构存储呢?
最终我选择了哈希表(散列表),也即把这两列以及其对应内容组合成一个key-value对,存入哈希表中。举个例子,这个哈希表应该长这样:
| key | value |
| “问题” | [ "问题一","问题二",...] |
| ”回答" | [ “回答一","回答二",...] |
也就是key是列名的字符串,value是这个列对应所有内容的列表。形式化地,用java语言描述,即:
Map< String , List<String> >
这里我为了让问题更加像一个问题,还给问题的前或者后拼接了形如”解释一下“,”介绍一下“这样更接近人类提问的语句,其中concat是个二维字符串数组,第0行是前缀拼接,第1行是后缀拼接:
static final String[][] concat = new String[][]{
{"如何理解","怎么看待","什么是","如何解读"},
{"的定义是什么","应该怎样理解","应该怎样解读"}
};
for (int i = 0; i < l.size(); i++) {
String text = l.get(i);
if(i%2==0){
int p = random.nextInt(concat.length);
int q = random.nextInt(concat[p].length);
if(p==0){
text = concat[p][q]+"\""+text+"\"";
}else{
text = "\""+text+"\""+concat[p][q];
}
qs.add(text);
}else{
as.add(text);
}
}
(4)将问答对写入excel
现在我拿到这个哈希表了,应该怎样将其写入excel呢?首先调用poi的API,建立起这个excel文件,然后根据哈希表的所有key,建立起表头,比如我们有两个key,”问题“与”回答“,那么表头那一行就有两列:
try (Workbook workbook = new XSSFWorkbook();
FileOutputStream outputStream = new FileOutputStream(targetPath)) {
Sheet sheet = workbook.createSheet(sheetName);
// 创建表头
Row headerRow = sheet.createRow(0);
随后,由于每一个key对应的value都是一个列表,因此我们可以顺手保存下这个list的迭代器,以便后续填充数据时使用。假设有k个列名,那么我们就要保存k个迭代器,这个迭代器的容器,我也选择了一个列表,这样我们就得到了一个列表的迭代器的列表。
Set<String> keys = cols.keySet();
List<Iterator<String>> iterators = new ArrayList<>();
int i = 0;
for (String key : keys) {
Cell headerCell = headerRow.createCell(i++);
headerCell.setCellValue(key);
iterators.add(cols.get(key).iterator());
}
随后在插入数据时,对于每一行,我们遍历这个迭代器的列表,并对每个单元格循环写入,这里为了防止文档损坏,缺失了问题或回答,我还进行了判空处理。
// 填充数据
i = 1;
int j;
boolean flag;
do {
flag = false;
j = 0;
Row row = sheet.createRow(i++);
for (Iterator<String> iterator : iterators) {
if (iterator.hasNext()){
flag = true;
Cell cell = row.createCell(j);
cell.setCellValue(iterator.next());
}
j++;
}
}while(flag);
这里的j就是每一行的列索引。从0开始,i是行索引,从1开始是因为作为表头的列名,占据了第0行。
最终我们通过文件的输出流,把数据写入这个文件即可。这样我们就得到了最终的产物。
(5) 最重要的部分:文档分割策略
以上过程都是最简单的部分,最重要的是上述提到过的,分割文档的策略不同,造成我们很有必要做好读取文档的代码和分割文档的代码之间的解耦。这里我选择了策略模式,既然分割文档的策略不同,我就通过注入不同的策略实例,来控制分割的逻辑。
首先我们回顾一下读取文档的代码要做什么事情:遍历文档的每一个段落,然后作为字符串保存在一个列表中。
然后回顾下分割文档的代码要做什么事情:把属于同一个问题的段落合并起来,作为字符串放到列表中,再将紧接着的属于同一个回答的段落合并起来,作为字符串放到同一个列表中。然后我们只需要判断这个列表元素的索引的奇偶性,就能知道这个字符串是问题还是回答了。
而属于同一个问题/回答的段落这件事情,就涉及到文档分割的策略,其中一个是概论卷的按文本的样式分割,如果是标题一那么就是问题,如果不是就是回答;另一个是词汇卷的按文本的前缀字符分割,如果第一个字符是*或者+,那么是问题,否则是回答。
这样我们就要编写两个策略类,我这里选择现为策略类编写要给合适的接口:
package Reader.DocxReader.Rule.Interface;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import java.util.List;
public interface ReadRule {
// 根据具体需要,决定当前段落的处理方式
void addParagraph(XWPFParagraph paragraph, List<String> res);
}
这个接口中仅有一个方法,就是根据具体需要,决定某个段落的处理方式。这里我先展示一个最最trivial的实现类,DummyRule。这个类实现这个方法的方式就是不管当前段落是怎样的,就直接放到列表中,没有分割策略,也算是一种策略嘛:
package Reader.DocxReader.Rule.impl;
import Reader.DocxReader.Rule.Interface.ReadRule;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import java.util.List;
public class DummyRule implements ReadRule {
@Override
public void addParagraph(XWPFParagraph paragraph, List<String> res) {
res.add(paragraph.getText());
}
}
随后我们来看概论卷中的,按照文本样式分割的策略类:
package Reader.DocxReader.Rule.impl;
import Reader.DocxReader.Rule.Interface.ReadRule;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import java.util.List;
public class SummaryPartRule implements ReadRule {
String splitStyle;
public SummaryPartRule(String split){
this.splitStyle = split;
}
@Override
public void addParagraph(XWPFParagraph paragraph, List<String> res) {
String style = paragraph.getStyle();
// System.out.println(style+" "+paragraph.getText());
if(paragraph.getText().isEmpty()){
return;
}
if(style!=null&&style.equals(splitStyle)){
res.add(paragraph.getText());
res.add("");
}else{
res.set(res.size()-1,res.get(res.size()-1)+paragraph.getText());
}
}
}
既然都按照样式分割了,那么一定是知道进行分割的定界符是怎样的,例如我知道我这个文本的样式如果是标题一,那么代表问题,否则代表答案。而标题一,在getStyle()(Poi获取样式的API)中的返回值是字符串"2"。那么我就可以为这个策略类注入一个指定的样式splitStyle,如果遇到了这个样式,就在字符串列表里创建两个位置,一个存放这个问题,另一个存放紧接着的回答。然后如果不是标题一的样式或者样式为null,就在字符串列表的最后一个位置上拼接当前的段落文本,直到下一个拥有标题一样式的地方出现。
简言之,遇到问题,创造新的位置;遇到回答,不断在目前最后一个位置上拼接段落。
接下来看基于文本前缀的方式进行分割的实现类:
package Reader.DocxReader.Rule.impl;
import Reader.DocxReader.Rule.Interface.ReadRule;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import java.util.List;
public class VocabularyPartRule implements ReadRule {
private final String[] splits = new String[]{"*","+"};
@Override
public void addParagraph(XWPFParagraph paragraph, List<String> res) {
String text = paragraph.getText();
if(text.isEmpty()){
return;
}
if(isQuestion(text)){
res.add(text.substring(1));
res.add("");
}else{
res.set(res.size()-1,res.get(res.size()-1)+text);
}
}
private boolean isQuestion(String text){
for (String split : splits) {
if(text.startsWith(split)){
return true;
}
}
return false;
}
}
这里也很简单,创建一个合法前缀的数组,对于每个段落,查看第一个字符是否是合法前缀,如果是,创建位置;如果不是,拼接回答。
这样我们就可以在读取文档的代码中,统一调用addParagraph方法来进行文本分割了。
/**
* 根据路径读docx文档
* @param filePath 本地文件路径
* @return 段落的列表(每个段落是一个字符串)
*/
public synchronized void readDoc(String filePath,List<String> res){
System.out.println(filePath);
try (FileInputStream fis = new FileInputStream(filePath)) {
XWPFDocument document = new XWPFDocument(fis);
Iterator<XWPFParagraph> iterator = document.getParagraphsIterator();
while (iterator.hasNext()) {
XWPFParagraph paragraph = iterator.next();
rule.addParagraph(paragraph,res);
}
document.close();
} catch (IOException e) {
e.printStackTrace();
}
}
3.3. 最终产物
两个问答对的excel表格:

四、 开源成果
https://github.com/Liyanhao1209/ZhouYiLLM.git
java_scripts分支。团队内规定:如果提交一些处理数据之类的脚本,可以根据自己使用的语言建立分支,如果已有该语言的脚本分支,比如我要用python做某项工作,而另一个成员已经建立了python_scripts这个分支,就把这个分支拉下来,在这个分支的基础上添加自己的代码,随后推到仓库,其他人也可以复用。


被折叠的 条评论
为什么被折叠?



