前言

自从前司返聘我去负责日语内容制作已经有一段时间了,由于工作内容和被裁之前相差无几,完全处在我的舒适区,产出比较稳定,合作也比较愉快,再加上之前还有过几次顺畅的外包合作,于是在过年前的某个时段,老板又给我布置了一个新的任务——给公司做个真题库。

在上一世,我和题库还是有点渊源的。

此事早在统计和展示单词真题词频的问题什么叫做常用词?两篇博客中有记载,彼时公司希望在背单词的产品页面上展示各个单词在各个考试中出现的频次,我和当时的产品经理针对如何计算词频产生了分歧,我根据我在大学时所学的NLP和语言学知识给出了我的专业意见,但是因为各种各样的原因,我的意见没能被采纳。最终实现的产品,不仅单词词频是手动统计并且写死在单词数据里,不会随着真题数据的录入而自动更新,而且因为词频将派生词一同统计入内,甚至出现了beer词频上千次,只因be + er = beer等啼笑皆非的情况。

对于这种不尊重专业知识的行为,年轻气盛的我自然是很记仇,但是当时我已经躺平了,即便是再不合理的需求,我也保留了最后的体面,把这坨屎给做了出来。

我还记得当时我提了一个建议,既然我们需要都把这么多真题干净整齐地校对好了,而且每个词的原型数据、考的哪个释义等等数据都有了,为什么不顺势做一个题库呢?这对于教研老师和产品都会有极大的帮助,但是因为「这并不是我们要做的需求」,这个提议也是顺理成章地被+1和当时的产品给否了。是啊,这只是一个nice to have的功能,和我们手上的要做的事情又有什么关系呢?这不是在公司里工作的方式,在公司做事,最忌讳的就是没有实际需求。

但是,作为一个老师,我还是很难想象一家做单词教育的内容公司,甚至连一个自己真题数据库都没有。当时如果组内有任何一个老师希望知道xx单词在某个考试里考了多少次,ta需要让自己的实习生(第N次)去网上找到题目、校对好、发过来给我,我用我自己写的NLTK脚本“义务”给他们跑词频,整个经历充满了荒诞的感觉。

距离这件事情已经过了快4年的时间,老板(也就是我之前的+2)终于意识到了需要有个题库,不然很多事情没法做,很多产品没法推,而且公司要为每个项目组自行搜寻题目+校对题目上浪费掉很多无意义的钱,这件事情终于被重新提到了台面上,+2问我愿不愿意做这个事,既是因为我之前有过做相关事情的背景,也是因为我也懂一些技术。

我毫不犹豫地答应了,尽管这个需求并不会增加我多少收入,但是我记仇,我希望用我现在的知识和技能去把这个项目落地,这个是独属于我自己的从0到1的项目,算是完成当年一腔热血但是热脸贴到冷屁股上的我的一个夙愿。

题目大观园

由于还没有客户端需求,在题库的设计上我是有完全的自由度的。我很快和+2对齐了对于这个题库的想象,提出了一些设想:全公司、全考试、全语种。

首先这个题库要给全公司使用,以往涉及到考试真题,每个项目组各自为战,光是重复校对花费的人力物力财力难以想象,甚至会出现各个小组之间数据版本不一致的情况。如果需要打通各个产品之间的数据,必然需要一个集中管理数据的地方。这个题库做出来之后,其他项目组如果还需要加人来处理真题,+2将不会批HC和经费。

其次这个题库需要兼容所有的考试,无论是中考还是专八,它的数据结构必须相互兼容,我要设计的是一个所有项目组都可以轻松在我这提取数据的数据结构。这既需要我对于所有考试所考的题型有基本的了解,也需要我掌握一定的计算机知识。世界上的考试千千万,每一个考试都可能有自己与众不同的题型,我需要见招拆招,用一种可拓展、可兼容的结构去描述这些题目。

最后是适配全语种,公司自两年前开始推小语种项目,小语种的题目收集后也要做到对每个单词做合适的分词、词型还原、释义匹配等处理,里面也会涉及到不少语言学的细节。目前市面上鲜有针对小语种真题句子的产品,如果成功做出来,也将极具时代意义。

 

接下来聊聊我的心得,要想设计出一个兼容所有考试的json数据结构,肯定是需要先看看考试到底有哪些题型,我先从我自己熟悉的英语和日语考试入手。

和大家想的不一样,即便是我们熟知的选择题和填空题,在实际考试里也会有很多不同的形式,这里我直接把我在需求文档里给的示例放出来,供大家参考,你们也可以想想,如果是你,你会怎么设计这个结构——

1. 单选题

对于选择题来说,无非就是去思考如何储存题目、选项和正确答案。

在下面这个题目里,需要选出和make efforts相同意思的选项,在考试里有的时候是划线词,有的时候是加粗词,因此我们还要想办法标记额外信息。

4. He [made efforts] to finish the marathon, even though his leg was in pain.

A. had a plan

B. made a promise

C. worked hard

D. made a decision

这种额外信息甚至有的时候是这样的一颗五角星,让你去选择放入最合适的一个选项。
Pasted image 20260502112342.png

2. 多选题

多选题其实不难处理,只需要让答案以列表的方式呈现即可。

Which TWO age groups are taking increasing numbers of holidays with BC Travel?

A. 16-30 years

B. 31-42 years

C. 43-54 years

D. 55-64 years

E. over 65 years

3. 完形填空

完形填空本质上虽然也是一种单选题,但设计上仍然需要做足区分。

首先它有原文,并且不是每一句原文都对应着一道题,因此原文句子和作为题目出现的句子需要做一个区分。

其次,由于之后可能会有产品需要从题库里挑选题目展示,我们必须把正确答案给标记好。一道题可能会挖好几个空,每个空都对应着四个选项,这个数据结构必须要能够将选项和答案绑定到它对应的填空里。

Two guitars hang on the light green wall of a living room. A chair ____ to the right, covered with colourful patterned blankets. The atmosphere is ____ and warm. Today, the living room — and the rest of the house it's attached to — is ____. It was one of at least 16,000 structures that were damaged or ____ in the Los Angeles wildfires in January. But its ____ is being kept alive by its former resident's close friend and neighbor, 27-year-old Maya Brattkus, who drew it as a gift for the homeowner to ____ wherever she lives next.

Drawing the home helped Brattkus begin to ____ her sorrow in a small way. After completing that drawing, Brattkus took her ____ a step further. She posted the image on social media platform Reddit ____ to draw more lost homes for free, and adding that she'd also draw houses that weren't lost.

1. A. burns B. sits C. refers D. belongs
2. A. comfortable B. lively C. believable D. tense
3. A. gone B. done C. clean D. open

4. 匹配题

匹配题作为选择题的一种,在考试里的形式也略微有所变化。

首先我们有一个题目集合,又有一个选项集合,题目和选项的数目不一定是一一对应的。如果我们沿用之前其他选择题的设计思路去做匹配题,每一个题目都绑定6个选项,在词频的统计上很可能就会重复。但如果你只给每个题目绑定正确选项,多余出来的未选选项怎么处理也是一个问题。

Pasted image 20260502113712.png

5. 填空题

这么一圈看下来,带选项的题目确实要掉点脑细胞才能设计出一个通用框架。脱离了选项后,填空题看着都眉清目秀了起来。

即便如此,仍然有一些填空题需要注意,有的是开放性答案——无论是填which和that都算对,在记录答案时应该以什么样的结构传递这个信息;有的是答案不止一个,那可能就需要用列表来装答案;但如果填空出现在同一句话的不同角落,怎么才能让答案对应上具体是哪个空呢……?

2. The reason _________ _________ we like the Sports Club is that it enriches our campus life.

6. 判断题

部分考试存在判断题,答案既不是填空,也不是选项,而是对与错,在雅思里甚至还有“Not Given”这个中间值,以及 True/FalseYes/No 两套写法,这个也需要纳入考虑的范围。

 4. Marie stopped doing research for several years when her children were born. 

7. 排序题

排序题可以参考PTE的阅读,在前端我们看到的就是12345总共五条文本,如何标记他们答案呢?

Pasted image 20260502143250.png

8. 作文题

作文题比较常见的情况就是,很大一部分内容是背景信息或是指示性文字,只有那么一小段话算是题目本体,需要学生仔细阅读里面的内容,因此需要区分题目和指引。

假设不久前你们学校为贫困地区的孩子们筹款举办了一次慈善行活动。
请据此在制定的位置上,以“Walk for children in poor areas”为题,为某中学生英文报写一篇报道,内容要点如下^

9. 改错题

这位更是重量级,有的需要删词,那题目本身的这个词是否需要统计词频;有的是漏掉了一个词,漏了的那个词肯定需要你掌握,怎么才可以统计得到这个词的词频呢?有的改错题是开放性修改,这种情况又要怎么标记答案呢?

Pasted image 20260502144045.png

10. 翻译题

有的题目是中译英,有的是英译中。

Pasted image 20260502144229.png

11. 带图、带音频的题

还有一些题型,题目本身就是一张图,甚至选项本身也可能是图片,这些都需要考虑并兼容。如果我们做的题目是听力题,那需要在当前题目的层级上再加一层音频,以此保证音频能和题目一一对应。

Pasted image 20260502114801.png

我设计的数据结构

基本结构

"data":[
{"sectionType": "阅读理解",
"section": [
	[{
	"contentType": "body",
		"content": [
		{"w": "Reading", "bf": "reading", "i": 0},
		{"w": " ", "bf": "", "i": 0},
		{"w": "passage", "bf": "passage", "i": 0}
		],
	"audio": "",
	"trans": "",
	"image": ""}],
	
	[{
	"contentType": "question",
	"content": [
		{"w": "Which", "bf": "which", "i": 0},
		{"w": " ", "bf": "", "i": 0},
		{"w": "answer", "bf": "answer", "i": 0},
		{"w": " ", "bf": "", "i": 0},
		{"w": "is", "bf": "be", "i": 0},
		{"w": " ", "bf": "", "i": 0},
		{"w": "correct", "bf": "correct", "i": 0},
		{"w": "?", "bf": "", "i": 0}
		],
	"audio": "",
	"trans": "",
	"image": "",
	"options": [
		{
		"content": [{"w": "A", "bf": "A", "i": 0}],
		"audio": "",
		"trans": "",
		"image": "",
		"correct": false
		},
		{
		"content": [{"w": "B", "bf": "B", "i": 0}],
		"audio": "",
		"trans": "",
		"image": "",
		"correct": true}]
	}]
]

我们一个个来看看里面的字段——

首先,试卷上的每一个大题都有自己的一个section,我们用sectionType来标记它是什么题型。在这个section里面,可以同时存在很多不同的content,比如一篇阅读理解可能就同时包含正文body,题目question

每一个content就是一句话,每句话都会被tokenized,其中w是这个单词的surface form(表面形式);bf是这个单词的basic form(基本形式),对应的就是这个单词的词型还原后的lemma;i是这个单词在我们内部系统的释义id,每个释义id是全局唯一的,它会告诉我们这个单词用的是哪个释义。

每一个content都有audiotransimage,分别对应该内容音频、翻译和插图。有的题目只有音频,甚至是一张图片,如果是这种情况我们就可以让content是空的,只留audioimage,写上我们内部的cdn地址即可。

section里,每一个列表就是一个自然段,每一个自然段里面可能有很多body,每一个body就是这一段的一句话。这样既兼顾了数据储存,试卷渲染起来也很方便。

选择类题目

如果question是选择类的题目,且是单选题、多选题、完形填空等每道题都有自己专属选项的,它将会有options作为答案。

如果一个question对应多道题目,题目与选项之间用使用refId关联。


"data":
[
  {
    "sectionType": "完形填空",
    "section":
    [
      {
        "contentType": "body",
        "content": [{"w": "Two", "bf": "two", "i": 0}, {"w": " ", "bf": "", "i": 0}, {"w": "guitars", "bf": "guitar", "i": 0}, {"w": " ", "bf": "", "i": 0}, {"w": "on", "bf": "on", "i": 0}],
        "audio": "",
        "trans": "两把吉他挂在客厅绿色的墙上。",
        "image": ""
      },

      {
        "contentType": "question",
        "content": [{"w": "A", "bf": "a", "i": 0}, {"w": " ", "bf": "", "i": 0}, {"w": "chair", "bf": "chair", "i": 0}, {"w": " ", "bf": "", "i": 0}, {"w": "____", "bf": "", "i": 0, "b": true, "refId": 1}, {"w": " ", "bf": "", "i": 0}, {"w": "to", "bf": "to", "i": 0}, {"w": " ", "bf": "", "i": 0}, {"w": "the", "bf": "the", "i": 0}, {"w": " ", "bf": "", "i": 0}, {"w": "right", "bf": "right", "i": 0}, {"w": ",", "bf": "", "i": 0}],
        "audio": "",
        "trans": "一把椅子在右边,披着彩色条纹毯子。",
        "image": "",
        "options":
        [
          {
            "content": [{"w": "burns", "bf": "", "i": 0}],
            "refId": 1,
            "audio": "",
            "trans": "",
            "image": "",
            "correct": false
          },

          {
            "content": [{"w": "sits", "bf": "", "i": 0}],
            "refId": 1,
            "audio": "",
            "trans": "",
            "image": "",
            "correct": true
          },

          {
            "content": [{"w": "refers", "bf": "", "i": 0}],
            "refId": 1,
            "audio": "",
            "trans": "",
            "image": "",
            "correct": false
          },

          {
            "content": [{"w": "belongs", "bf": "", "i": 0}],
            "refId": 1,
            "audio": "",
            "trans": "",
            "image": "",
            "correct": false
          }
        ]
      },

      {
        "contentType": "question",
        "content": [{"w": "The", "bf": "the", "i": 0}, {"w": " ", "bf": "", "i": 0}, {"w": "atmosphere", "bf": "atmosphere", "i": 0}, {"w": " ", "bf": "", "i": 0}, {"w": "is", "bf": "be", "i": 0}, {"w": " ", "bf": "", "i": 0}, {"w": "____", "bf": "", "i": 0, "b": true, "refId": 2}, {"w": " ", "bf": "", "i": 0}, {"w": "____", "bf": "", "i": 0, "b": true, "refId": 3}],
        "audio": "",
        "trans": "气氛温暖又舒适。",
        "image": "",
        "options":
        [
          {
            "content": [{"w": "comfortable", "bf": "", "i": 0}],
            "refId": 2,
            "audio": "",
            "trans": "",
            "image": "",
            "correct": true
          },

          {
            "content": [{"w": "lively", "bf": "", "i": 0}],
            "refId": 2,
            "audio": "",
            "trans": "",
            "image": "",
            "correct": false
          },

          {
            "content": [{"w": "believable", "bf": "", "i": 0}],
            "refId": 2,
            "audio": "",
            "trans": "",
            "image": "",
            "correct": false

          },

          {
            "content": [{"w": "tense", "bf": "", "i": 0}],
            "refId": 2,
            "audio": "",
            "trans": "",
            "image": "",
            "correct": false
          },

          {
            "content": [{"w": "warm", "bf": "", "i": 0}],
            "refId": 3,
            "audio": "",
            "trans": "",
            "image": "",
            "correct": true
          },

          {
            "content": [{"w": "green", "bf": "", "i": 0}],
            "refId": 3,
            "audio": "",
            "trans": "",
            "image": "",
            "correct": false
          },

          {
            "content": [{"w": "fantastic", "bf": "", "i": 0}],
            "refId": 3,
            "audio": "",
            "trans": "",
            "image": "",
            "correct": false
          },

          {
            "content": [{"w": "dreamy", "bf": "", "i": 0}],
            "refId": 3,
            "audio": "",
            "trans": "",
            "image": "",
            "correct": false
          }
        ]
      }
    ]
  }
]

但如果是选词填空、匹配题等从共享的一个选项池里做选择的,它会单独使用一个contentType: option来存放答案


"data": [
  {
    "sectionType": "选词填空",
    "section": [
      [
        {
          "contentType": "question",
          "content": [
            {"w": "This", "bf": "this", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "is", "bf": "be", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "because", "bf": "because", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "the", "bf": "the", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "number", "bf": "number", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "of", "bf": "of", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "women", "bf": "woman", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "involved", "bf": "involve", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "in", "bf": "in", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "legislative", "bf": "legislative", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "decisions", "bf": "decision", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "has", "bf": "have", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "significant", "bf": "significant", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "______", "bf": "", "i": 0, "b": true, "refId": 26},
            {"w": " ", "bf": "", "i": 0},
            {"w": "for", "bf": "for", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "all", "bf": "all", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "the", "bf": "the", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "policies", "bf": "policy", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "that", "bf": "that", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "governments", "bf": "government", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "______", "bf": "", "i": 0, "b": true, "refId": 27},
            {"w": ".", "bf": "", "i": 0}
          ],
          "audio": "",
          "trans": "",
          "image": ""
        },

        {
          "contentType": "question",
          "content": [
            {"w": "Female", "bf": "female", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "legislators", "bf": "legislator", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "are", "bf": "be", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "more", "bf": "more", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "likely", "bf": "likely", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "than", "bf": "than", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "men", "bf": "man", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "to", "bf": "to", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "introduce", "bf": "introduce", "i": 0},
            {"w": ",", "bf": "", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "speak", "bf": "speak", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "about", "bf": "about", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "and", "bf": "and", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "work", "bf": "work", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "to", "bf": "to", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "pass", "bf": "pass", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "policies", "bf": "policy", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "that", "bf": "that", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "disproportionately", "bf": "disproportionately", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "affect", "bf": "affect", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "women", "bf": "woman", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "and", "bf": "and", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "girls", "bf": "girl", "i": 0},
            {"w": ",", "bf": "", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "such", "bf": "such", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "as", "bf": "as", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "paid", "bf": "paid", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "family", "bf": "family", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "leave", "bf": "leave", "i": 0},
            {"w": ",", "bf": "", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "pay", "bf": "pay", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "______", "bf": "", "i": 0, "b": true, "refId": 28},
            {"w": " ", "bf": "", "i": 0},
            {"w": "and", "bf": "and", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "gender-based", "bf": "gender-based", "i": 0},
            {"w": " ", "bf": "", "i": 0},
            {"w": "violence", "bf": "violence", "i": 0},
            {"w": ".", "bf": "", "i": 0}
          ],
          "audio": "",
          "trans": "",
          "image": ""
        }
      ],

      [
        {
          "contentType": "option",
          "content": [{"w": "bolsters", "bf": "bolster", "i": 0}],
          "audio": "",
          "trans": "",
          "image": "",
          "correct": false
        },

        {

          "contentType": "option",
          "content": [{"w": "consequences", "bf": "consequence", "i": 0}],
          "refId": 26,
          "audio": "",
          "trans": "",
          "image": "",
          "correct": true
        },

        {

          "contentType": "option",
          "content": [{"w": "credentials", "bf": "credential", "i": 0}],
          "audio": "",
          "trans": "",
          "image": "",
          "correct": false
        },

        {
          "contentType": "option",
          "content": [{"w": "dramatically", "bf": "dramatically", "i": 0}],
          "audio": "",
          "trans": "",
          "image": "",
          "correct": false
        },

        {

          "contentType": "option",
          "content": [{"w": "enact", "bf": "enact", "i": 0}],
          "refId": 27,
          "audio": "",
          "trans": "",
          "image": "",
          "correct": true
        },

        {
          "contentType": "option",
          "content": [{"w": "equity", "bf": "equity", "i": 0}],
          "refId": 28,
          "audio": "",
          "trans": "",
          "image": "",
          "correct": true
        },

        {
          "contentType": "option",
          "content": [{"w": "especially", "bf": "especially", "i": 0}],
          "audio": "",
          "trans": "",
          "image": "",
          "correct": false
        },

        {

          "contentType": "option",
          "content": [{"w": "evasively", "bf": "evasively", "i": 0}],
          "audio": "",
          "trans": "",
          "image": "",
          "correct": false
        },

        {
          "contentType": "option",
          "content": [{"w": "formidable", "bf": "formidable", "i": 0}],
          "audio": "",
          "trans": "",
          "image": "",
          "correct": false
        },

        {

          "contentType": "option",
          "content": [{"w": "impetus", "bf": "impetus", "i": 0}],
          "audio": "",
          "trans": "",
          "image": "",
          "correct": false

        },

        {
          "contentType": "option",
          "content": [{"w": "lavish", "bf": "lavish", "i": 0}],
          "audio": "",
          "trans": "",
          "image": "",
          "correct": false
        },

        {
          "contentType": "option",
          "content": [{"w": "prioritize", "bf": "prioritize", "i": 0}],
          "audio": "",
          "trans": "",
          "image": "",
          "correct": false
        },

        {
          "contentType": "option",
          "content": [{"w": "suffices", "bf": "suffice", "i": 0}],
          "audio": "",
          "trans": "",
          "image": "",
          "correct": false
        },

        {
          "contentType": "option",
          "content": [{"w": "sustained", "bf": "sustain", "i": 0}],
          "audio": "",
          "trans": "",
          "image": "",
          "correct": false
        },
        
        {
          "contentType": "option",
          "content": [{"w": "tenured", "bf": "tenure", "i": 0}],
          "audio": "",
          "trans": "",
          "image": "",
          "correct": false
        }
      ]
    ]
  }
]

非选择类题目

如果question是非选择类的题目,它会有answers作为答案,主要是填空题和固定答案的问答题。对于没有标准答案的作文题、改错题、翻译题,我们就不放答案了。


"data":
[
  {
    "sectionType": "填空题",
    "section":
    [
      {
        "contentType": "question",
        "content": [
          {"w": "The", "bf": "the", "i": 2},
          {"w": " ", "bf": "", "i": 0},
          {"w": "reason", "bf": "reason", "i": 85},
          {"w": " ", "bf": "", "i": 0},
          {"w": "_________", "bf": "", "i": 0, "b": true, "refId": 1},
          {"w": " ", "bf": "", "i": 0},
          {"w": "_________", "bf": "", "i": 0, "b": true, "refId": 2},
          {"w": " ", "bf": "", "i": 0},
          {"w": "we", "bf": "we", "i": 85},
          {"w": " ", "bf": "", "i": 0},
          {"w": "like", "bf": "like", "i": 0}
        ],
        "audio": "",
        "trans": "我们喜欢运动俱乐部的原因是它丰富了我们的校园生活。",
        "image": "",
        "answers":
        [
          {
            "refId": 1,
            "content": [{"w": "for", "bf": "for", "i": 0}]
          },

          {
            "refId": 2,
            "content": [{"w": "which", "bf": "which", "i": 0}]
          }
        ]
      }
    ]
  }
]

量产过程

这么多考试、这么多套题目,要怎么才可以完成量产呢?

现在已经是2026年了,肯定是直接用大模型做最省力,其实豆包这样的“廉价”模型就已经能够很好胜任将txt转成json格式的任务,使用ChatGPT等外国模型甚至有些大炮打蚊子。

我们会把pdf或者word试卷转成txt,然后给各个section做好标记,接下来再使用脚本将各个section切分成一个个小的txt文件,方便单独调用API跑提示词。每个提示词我们会做几件事情:

接下来我们再把所有token在系统里的释义数据跑下来,再用另外一份提示词让大模型参考该词所在的这句话 + 在系统里的释义,综合判断应该使用哪个释义,最后回填到json里面即可。如果无法判断的就留空,之后人工检查。

就这样,两个月左右的时间,我和一名实习生和一位技术老师合力把中考、高考、考研、四级、六级、专四、专八近五年一百多份考试的试卷都量产完了。我为了让实习生编辑更好地校对和补i,还vibe了一个小小的html网页。

现实

就在我觉得我已经稳了,带着极大的成就感即将完成大部分的工作之时,现实还是给我上了一课。

首先到来的是我们第一个客户端需求,她们需要在背单词页面展示真题例句,需要根据当前所背的单词去对应的考试里提取出对应的句子,并且按要求展示。在前期的需求评审会上,我感觉我的这套体系并无任何问题,无论是计算词频、计算释义考频、句子音频、句子翻译都能够很好地支持和兼容。

按照我的设想,只要她们接入我的“数据库“,这个需求就可以做到只要有考试更新数据库也能随时更新、自动更新,不需要再像之前那样人工录入每个单词的词频了。但是万万没想到,现实中各种功能的实现方式就和我想象中的不太一样。

就拿最简单的词频来说,在客户端展示的时候必不可能实时拉取、实时计算,因为json文件的数量级很大,每次背到一个单词都要在N个json里去搜寻和计算特定单词是否出现和频率计算量极大,即便能够实时计算,对于没网、网络慢等情况也是没有办法支持的。

还有一个问题,在产品需求里,对应单词所展示的真题句子会被分成4种样式——1选择、2听力、3默认、4填空。在展示上,上述十余种题型将会按照映射关系分配到这四种样式中的一种。在综合考虑教学效果、产品展示、用户体验等问题后,部分题目的映射和本身的题目的分类又有不同。

例如,阅读理解的选择题按理来说使用选择样式,但是因为有的题目无论是题干还是选项都巨长无比,因此我们可能考虑把它们做成3默认样式;又比如选词填空,有时题目需要你从10个选项里选择合适的词填入,这也会不可避免地让真题板块过于长,因此我们可能考虑把它做成4填空样式。

这次工作我学到最深刻的一课就是——即便是有了一个这样one-for-all的题库,在实际做需求的时候二次开发也是不可避免的。所以,在五一到来前的很长一段时间,我其实并没有在维护题库,而是根据项目组的需求对数据进行二次加工,确保能加工到真题例句所需要的数据格式。不过有了干净的原始数据之后,再去做量产确实能够快很多。

这就是过去的两个多月时间里,做题库这件事情的一些心得和体会,完全是自己的热爱和自驱力在推着我探索把这件事情落地,也是给刚毕业时郁郁不得志的我一个交代了吧。