面试题库结构化实战:SQLite FTS5检索、组卷与SimHash去重 简介《通用面试题库.doc》是一份面向招聘面试场景的综合性提问参考适合面试官、HR及需要组织结构化面试的团队使用也可帮助求职者了解常见考察维度。文档围绕语言表达与仪表、工作经验、应聘动机与期望、进取心与自信心、工作态度与纪律性、分析判断、应变能力、自知与自控等模块展开每道题均配有面试要点和参考观察方向便于现场抽题、追问与横向比较。包内仅含1个doc文件压缩包约101KB轻量易取可直接编辑或打印使用。已有74人学习下载。使用时可按岗位抽取46题关键人才适当增加并依据候选人回答继续深挖。整体题库结构清晰、分类明确能帮助面试官快速建立评估框架减少临场提问的随意性同时为复盘候选人表现提供统一参考。1. 通用面试题库.doc 的尴尬能背不能查面试开始前十分钟面试官打开那份传了三年的《通用面试题库.doc》想找一道「索引失效」的题。CtrlF 搜「索引」命中七十多处有题干有答案里的顺带提及还有上一位面试官留下的批注。翻到第三页「MySQL 事务隔离级别」出现了两遍答案一版写四种、一版写三种最后几页的表格因为合并单元格复制出来全挤成一行。这份文档能读却不能查、不能组卷、不能去重更没法统计某类题今年考过几次。把它变成一套能用的题库系统核心是四件事拆出题干、答案、标签、难度这些结构化字段给中文字段建一个真能用的全文索引按标签和难度做带约束的抽题把近重复题和过期题筛出来。下面按这四步推进每一步都能单独跑通适合正在维护团队题库的面试官和工程效率岗。2. 把通用面试题库.doc 拆成结构化 JSON解析管道与字段设计拿到文件的第一步不是写正则而是确认它到底是什么格式。很多团队手里的「.doc」其实是 .docx 改了后缀也有的是 WPS 存的 .wps直接套 python-docx 会在第一行就报错。2.1 先分清 .doc 与 .docx选对转换路径.doc 是 OLE2 复合文档文件头是D0 CF 11 E0.docx 是 ZIP 容器文件头是50 4B 03 04。python-docx 只能读后者喂给它 .doc 会抛PackageNotFoundError。# 看文件真实类型别看后缀 file 通用面试题库.doc # 通用面试题库.doc: Composite Document File V2 Document, Little Endian # 看文件头四个字节OLE2 是 d0cf11e0docx 是 504b0304 head -c 4 通用面试题库.doc | xxd转换用 LibreOffice 的无界面模式比 antiword、catdoc 之类的纯文本抽取工具稳表格结构能保住soffice --headless --norestore --convert-to docx \ --outdir ./converted ./raw/通用面试题库.doc--headless不弹界面服务器上能跑--norestore跳过崩溃恢复对话框批量转换时不会卡在弹窗上--outdir指定输出目录输出文件同名、后缀变 .docx。需要提醒的是 soffice 同一时刻只允许一个实例批量要串行真要并行给每个进程单独指定-env:UserInstallationfile:///tmp/lo_$i隔离配置目录否则后启动的进程会直接退出。输入格式判定方式推荐解析路径.docOLE2文件头D0 CF 11 E0LibreOffice 转 .docx 后解析.docxOOXML文件头50 4B 03 04python-docx 直接读.wps视版本而定先用 WPS 另存为 .docx.pdf%PDF不推荐表格与编号层级会丢2.2 按文档流顺序抽题干别只遍历 paragraphspython-docx 的doc.paragraphs只返回段落doc.tables单独返回表格两者的先后关系丢了。题库文档里「题干—选项表格—答案」是交替出现的顺序一错答案就会挂到别的题上这是最常见也最隐蔽的坑。按 body 的子元素顺序遍历才能还原真实文档流import re from docx import Document from docx.table import Table from docx.text.paragraph import Paragraph # 题号形态1. / 1、 / 第1题 / 1 Q_RE re.compile(r^\s*(?:第)?\s*(\d{1,4})\s*[、.)]\s*(.)$) # 文档里常见的标签写法【Java】【中等】 TAG_RE re.compile(r[【\[]([^】\]]{1,20})[】\]]) ANS_RE re.compile(r^(答|答案|参考答案)\s*[:]) def iter_blocks(doc): 按 body 真实顺序产出 (p, Paragraph) 或 (t, Table) for child in doc.element.body.iterchildren(): if child.tag.endswith(}p): yield p, Paragraph(child, doc) elif child.tag.endswith(}tbl): yield t, Table(child, doc) def parse(path): items, cur [], None for kind, blk in iter_blocks(Document(path)): if kind t: text \n.join( | .join(c.text.strip() for c in r.cells) for r in blk.rows) else: text blk.text.strip() if not text: continue m Q_RE.match(text[:60]) # 题号只可能出现在行首 if m: cur {no: int(m.group(1)), title: m.group(2).strip(), body: , answer: , tags: TAG_RE.findall(text), difficulty: medium} items.append(cur) elif cur: if ANS_RE.match(text): cur[answer] ANS_RE.sub(, text).strip() \n else: cur[body] text \n return items逻辑说明iter_blocks靠 XML 元素顺序还原文档流是解决「答案串题」的关键Q_RE只截取前 60 个字符做匹配避免答案里带数字的行被误判成新题cur为空时直接丢弃内容顺带把封面、目录、修订记录挡在外面。参数说明(\d{1,4})限长是防「2023.5」这类日期被当成题号[、.)]覆盖中英文句点和括号[^】\]]的字符集不含右括号遇到嵌套括号不会贪婪吞掉整行。2.3 字段设计先定必填再谈扩展解析出来的字典要落成稳定 schema否则后面建索引、组卷、质检时每人都按自己的理解加字段很快就失控。字段类型必填说明idTEXT是内容哈希前 12 位跨库合并不会撞号noINTEGER否原文档题号仅用于回溯定位titleTEXT是题干主干一句话建议不超过 120 字bodyTEXT否场景描述、代码片段、选项answerTEXT是参考答案空答案的题不入库tagsTEXT是逗号分隔从【】标记继承difficultyTEXT是easy / medium / hard缺失时默认 mediumstatusTEXT是active / archived淘汰题改状态不删行sourceTEXT否来源文档名 章节estimate_minINTEGER否预计作答分钟数组卷算时长用2.4 清洗规则把随手写的格式统一题库是多人协作产物同一份文档里全角半角、中英标点混用是常态。与其在正则里穷举不如先做一遍归一化。原始形态处理方式原因【Java】、[中等]标签归一化成小写英文键后面要按 tag 建索引和分组全角括号、全角冒号统一转半角后再匹配正则写两套容易漏表格合并单元格按行列去重后拼字符串合并后同一 cell 对象会重复出现章节重新编号主键用内容哈希no 只做展示题号不唯一不能当主键答案为空打statusincomplete入待补队列空答案题组卷时必然露馅提示归一化之后一定要留一份raw_text原文快照人工核对标签打错时能直接比对不用重新解析原始 doc。3. 给面试题库装检索SQLite FTS5 中文全文搜索落地题量在几千到几万这个量级上 Elasticsearch 属于杀鸡用牛刀多一个服务、多一份运维、多一次数据同步。SQLite 的 FTS5 扩展配上合适的 tokenizer在单机就能撑住查询十几毫秒返回还能和主表放同一个文件里。3.1 主表、标签表与 FTS5 虚表怎么摆PRAGMA journal_mode WAL; CREATE TABLE questions ( qid INTEGER PRIMARY KEY, -- rowid 别名供 FTS5 引用 id TEXT NOT NULL UNIQUE, -- 内容哈希 no INTEGER, title TEXT NOT NULL, body TEXT DEFAULT , answer TEXT NOT NULL, difficulty TEXT NOT NULL DEFAULT medium CHECK (difficulty IN (easy,medium,hard)), estimate_min INTEGER DEFAULT 10, status TEXT NOT NULL DEFAULT active, source TEXT, created_at TEXT NOT NULL ); CREATE TABLE question_tags ( question_id TEXT NOT NULL REFERENCES questions(id) ON DELETE CASCADE, tag TEXT NOT NULL, PRIMARY KEY (question_id, tag) ); CREATE INDEX idx_tag ON question_tags(tag); -- 外部内容表正文仍在 questions 里FTS5 只保存倒排索引 CREATE VIRTUAL TABLE q_fts USING fts5( title, body, answer, contentquestions, content_rowidqid, tokenizetrigram );标签单独立表而不是塞进 questions 的一个逗号串是为了组卷时能直接WHERE tag ?走索引如果标签存在字符串里每次都要LIKE %x%题量一上来就全表扫。3.2 FTS5 中文分词的三条路方案建表写法优点局限unicode61 默认tokenizeunicode61零依赖中文整句被当成一个不可切分的长 token基本搜不到trigramtokenizetrigram无需分词器天然支持子串匹配还能加速 LIKE查询串短于 3 字符不命中索引体积约为原文 3 倍jieba 预处理unicode61 写入时用空格分词词粒度准bm25 权重合理索引和查询必须用同一版词表换词表要重建SQLite 3.34 起内置 trigram绝大多数题库场景直接用它最省事不用引入分词器的 Python 依赖也没有词表版本不一致的问题。代价就是短查询——面试官搜「SQL」「JVM」这种三个字符以内的词会直接返回空。3.3 查询写法bm25 排序 标签过滤 结果高亮-- 关键词检索标题命中权重更高 SELECT q.id, q.title, q.difficulty, bm25(q_fts, 5.0, 2.0, 1.0) AS score, snippet(q_fts, 1, em, /em, …, 24) AS body_snippet FROM q_fts JOIN questions q ON q.qid q_fts.rowid WHERE q_fts MATCH :kw AND q.status active AND (:diff IS NULL OR q.difficulty :diff) AND (:tag IS NULL OR EXISTS ( SELECT 1 FROM question_tags t WHERE t.question_id q.id AND t.tag :tag)) ORDER BY score LIMIT 20;参数说明bm25(q_fts, 5.0, 2.0, 1.0)里的三个权重依次对应 title、body、answer题干命中比答案里顺带提一句重要得多所以给 5.0。bm25 返回负值越小越相关ORDER BY score保持升序才对写成 DESC 会把最不相关的排到第一。snippet()的第二个参数是列序号从 0 开始这里 1 表示 body最后一个参数 24 是截断的最大 token 数。短词交给 LIKEtrigram 索引依然生效-- 三个字符以内的查询走 LIKE不会退化成全表扫描 SELECT id, title FROM questions WHERE title LIKE %SQL% AND status active;注意MATCH 的查询串不能把用户输入原样拼进去。FTS5 的查询语法里、*、NEAR、OR都有特殊含义用户搜「A* OR B」会解析成表达式。工程上把关键词按空白切成 token每个 token 用双引号包起来再拼或者直接用参数绑定单个 token。3.4 增量导入与触发器同步主表写入后要保证 FTS 索引同步用触发器最省心不用在每处业务代码里记得手工维护。CREATE TRIGGER questions_ai AFTER INSERT ON questions BEGIN INSERT INTO q_fts(rowid, title, body, answer) VALUES (new.qid, new.title, new.body, new.answer); END; CREATE TRIGGER questions_ad AFTER DELETE ON questions BEGIN INSERT INTO q_fts(q_fts, rowid, title, body, answer) VALUES (delete, old.qid, old.title, old.body, old.answer); END; CREATE TRIGGER questions_au AFTER UPDATE ON questions BEGIN INSERT INTO q_fts(q_fts, rowid, title, body, answer) VALUES (delete, old.qid, old.title, old.body, old.answer); INSERT INTO q_fts(rowid, title, body, answer) VALUES (new.qid, new.title, new.body, new.answer); END;导入脚本用 upsert同一道题重复导入变成更新而不是插新行import sqlite3, hashlib def qid_of(item): key (item[title] item[answer]).strip() return hashlib.sha1(key.encode(utf-8)).hexdigest()[:12] def load(items, dbbank.db): conn sqlite3.connect(db) conn.execute(PRAGMA foreign_keys ON) for it in items: if not it[answer].strip(): continue # 空答案不入库 key qid_of(it) conn.execute( INSERT INTO questions(qid, id, no, title, body, answer, difficulty, status, created_at) VALUES (NULL, ?, ?, ?, ?, ?, ?, active, datetime(now)) ON CONFLICT(id) DO UPDATE SET titleexcluded.title, bodyexcluded.body, answerexcluded.answer, difficultyexcluded.difficulty , (key, it.get(no), it[title], it[body], it[answer], it.get(difficulty) or medium)) for tag in {t.lower() for t in it[tags]}: conn.execute( INSERT OR IGNORE INTO question_tags(question_id, tag) VALUES (?, ?), (key, tag)) conn.commit()逻辑说明ON CONFLICT(id)让重复导入落到 UPDATE 分支此时questions_au触发器负责把旧索引删掉再写新的id 来自题干加答案的内容哈希改一个字就变成新题这是刻意的——质检环节正好能看到哪些题被悄悄改写成了两个版本。4. 面试题库组卷按标签与难度的抽题算法检索解决的是「找一道题」组卷解决的是「出一套题」。这两件事的复杂度差一个量级前者是单条件命中后者是多条件同时满足还要保证不重复、时长可控、知识点覆盖不塌方。4.1 把「出一套 45 分钟的题」翻译成约束口头描述没法直接算先翻译成可执行条件。业务说法可执行约束基础要考到别太难tagjava基础 AND difficultyeasy抽 3 题中间要有区分度difficultymedium 抽 4 题同一 tag 最多出现 2 次拔高一道就够difficultyhard 抽 1 题别连着问同一个知识点一套卷内 tag 去重后重复次数 ≤ 2上机题要留时间SUM(estimate_min) ≤ 354.2 分层抽样的抽题实现最朴素的做法是写一条大 SQL 加ORDER BY RANDOM() LIMIT n但多条件同时满足时 SQL 会变得难读且难调。更实用的方式是先把候选集取回内存按 tag 和 difficulty 二次分桶再逐桶取数。import random, sqlite3 from collections import defaultdict def pick_paper(conn, blueprint): blueprint 形如 {java基础: {easy: 3, medium: 2}, mysql: {medium: 2, hard: 1}, redis: {medium: 1, hard: 1}} paper, used [], set() for tag, quota in blueprint.items(): rows conn.execute( SELECT q.qid, q.id, q.title, q.difficulty, q.estimate_min FROM questions q JOIN question_tags t ON t.question_id q.id WHERE t.tag ? AND q.status active, (tag,)).fetchall() buckets defaultdict(list) for r in rows: if r[1] not in used: buckets[r[3]].append(r) for diff, need in quota.items(): pool buckets.get(diff, []) random.shuffle(pool) # 内存内打乱比 ORDER BY RANDOM 便宜 chosen pool[:need] if len(chosen) need: print(f[warn] {tag}/{diff} 储备不足需要 {need}可用 {len(chosen)}) for row in chosen: used.add(row[1]) # 跨 tag 去重 paper.append(row) return paper逻辑说明先按 tag 分桶再在桶内按 difficulty 二次分桶取够配额就停。used集合按内容哈希去重保证同一道题不会因为挂了两个标签被抽两次。warn 行是这段代码最有价值的输出——它直接告诉你哪一类题储备不够需要补题而不是偷偷放宽约束。参数说明blueprint 的 key 是 tagvalue 是{难度: 数量}random.shuffle用系统随机源如果需要一套可复现的卷子比如给多位候选人用同一套把random.seed(hash(候选人编号))固定下来即可。4.3 组卷后的三道校验抽出来不等于能用还要过一遍检查。这套检查建议固化成函数每次组卷自动跑。from collections import Counter def check_paper(paper, conn, max_same_tag2, max_minutes35): errs [] ids [r[1] for r in paper] # 1) 重复题 if len(ids) ! len(set(ids)): errs.append(同一道题被抽了两次) # 2) 标签过度集中 for tag, n in Counter(r[1] for r in paper).items(): if n max_same_tag: errs.append(f标签 {tag} 出现 {n} 次超过 {max_same_tag}) # 上面按 id 计数实际按 tag 计数请改成读取 tag 字段 # 3) 总时长 total conn.execute( fSELECT COALESCE(SUM(estimate_min), 0) FROM questions fWHERE id IN ({,.join(? * len(ids))}), ids).fetchone()[0] if total max_minutes: errs.append(f预计 {total} 分钟超出 {max_minutes} 分钟) return errs参数说明max_same_tag默认 2指同一标签在一次组卷中最多出现两次max_minutes按面试轮次填45 分钟的轮次建议传 35留 10 分钟给候选人提问和收尾否则很容易超时。注意,.join(? * len(ids))拼 IN 占位符的写法前提是 ids 完全来自题库内部。任何外部输入都不该走这条路径需要动态集合时改用临时表 JOIN。5. 面试题库去重与质检SimHash 和一组体检 SQL5.1 近重复题检测SimHash 加分段桶题库传了几年之后最大的问题不是题少而是同一道题换了三种问法。完全相同的题干能用 GROUP BY 直接找出来改写过的就得算相似度。SimHash 把文本压成 64 位指纹汉明距离 ≤ 3 就认为近重复适合题干这种短文本。import hashlib, re from collections import Counter, defaultdict def tokens(text): text re.sub(r[^\w\u4e00-\u9fff], , text) out [] for w in text.split(): if re.fullmatch(r[\u4e00-\u9fff], w): out [w[i:i 2] for i in range(len(w) - 1)] # 中文按 2-gram else: out.append(w.lower()) return out def simhash(text, bits64): v [0] * bits for word, freq in Counter(tokens(text)).items(): h int(hashlib.md5(word.encode()).hexdigest()[:16], 16) for i in range(bits): v[i] freq if (h i) 1 else -freq return sum(1 i for i in range(bits) if v[i] 0) def bands(fp, seg16, n4): mask (1 seg) - 1 return [(i, (fp (seg * i)) mask) for i in range(n)] def find_dup(items, max_dist3): items: [(id, title), ...] buckets, pairs defaultdict(list), [] for qid, title in items: fp simhash(title) for key in bands(fp): for other_id, other_fp in buckets[key]: if bin(fp ^ other_fp).count(1) max_dist: pairs.append((qid, other_id)) buckets[key].append((qid, fp)) return pairs逻辑说明中文按 2-gram 切分而不是单字是因为题干短「缓存穿透」和「缓存击穿」按单字切只差一个字指纹距离拉不开2-gram 之后差异被放大到多个 token距离区分度明显。如果连答案一起判重就把 title 和 answer 拼接后再算指纹只看题干会漏掉「换个问法问同一个知识点」的情况。参数说明max_dist3适合几十字的题干长文本可以放宽到 5。seg16, n4是标准的 LSH 切法根据鸽巢原理距离不超过 3 的两条指纹至少有一段完全相同因此不会被漏判同时把两两比较的规模从 O(n²) 压到桶内比较。5.2 题库体检 SQL四类问题一次查出来-- 1) 空答案或答案过短 SELECT id, title, LENGTH(answer) AS ans_len FROM questions WHERE status active AND LENGTH(TRIM(answer)) 15; -- 2) 没有任何标签的孤儿题 SELECT q.id, q.title FROM questions q LEFT JOIN question_tags t ON t.question_id q.id WHERE t.question_id IS NULL; -- 3) 题干超长多半是把场景描述混进了标题 SELECT id, LENGTH(title) AS tlen FROM questions WHERE LENGTH(title) 200 ORDER BY tlen DESC; -- 4) 难度分布失衡hard 占比超过 60% 的标签 SELECT tag, SUM(difficulty hard) AS hard_cnt, COUNT(*) AS total, ROUND(1.0 * SUM(difficulty hard) / COUNT(*), 2) AS hard_ratio FROM question_tags t JOIN questions q ON q.id t.question_id WHERE q.status active GROUP BY tag HAVING total 5 AND hard_ratio 0.6;检查项阈值建议动作答案长度 15 字标记 incomplete进补题队列无标签题任意用题干关键词兜底打标人工确认题干长度 200 字拆成 title 和 body 两段hard 占比 60%补 easy/medium否则组卷只能降级5.3 把机器判定变成待办而不是直接删判重结果不要直接执行删除。建一张dup_review表字段是keep_id、drop_id、distance、reviewed_by把find_dup的输出批量写进去人工每天花十分钟过一遍。确认合并的把被淘汰题的status改成 archived并加一个merged_into字段指向保留题这样历史上引用过被淘汰题的面试记录还能追溯回来。题库维护最怕的就是「某人一口气清掉了两百道重复题」后面发现清掉的那道其实是另一个方向的追问起点。6. 让面试题库.doc 语义可查向量召回与面试卡导出6.1 语义召回兜住关键词搜不到的情况搜「缓存雪崩」想召回「大量 key 同时过期」这类表述关键词索引无能为力因为两者字面上没有重叠。常见做法是先用嵌入模型把 title 和 answer 编码成向量存下来查询时算余弦相似度。import numpy as np, struct def to_blob(vec): return struct.pack(f{len(vec)}f, *vec.astype(float32)) def from_blob(blob): n len(blob) // 4 return np.array(struct.unpack(f{n}f, blob), dtypefloat32) def topk(conn, qvec, k5): rows conn.execute(SELECT id, title, vec FROM question_vec).fetchall() mat np.vstack([from_blob(r[2]) for r in rows]) q qvec / (np.linalg.norm(qvec) 1e-9) # 归一化后点积即余弦 m mat / (np.linalg.norm(mat, axis1, keepdimsTrue) 1e-9) score m q idx np.argsort(-score)[:k] return [(rows[i][0], rows[i][1], float(score[i])) for i in idx]题量在几千这个量级全量矩阵乘一次几十毫秒不需要专门的向量库。更稳的组合是先用 FTS5 关键词召回 50 条候选再在这批候选上做向量重排准确率比纯向量高也比纯关键词更能兜住换问法而且不用把所有题向量都加载进内存。6.2 面试卡导出一套卷子打成可打印的 Markdown结构化之后导出就是纯字符串拼接。追问字段为空时不要输出空小节否则面试官打印出来是一片空白。CARD ### {no}. {title} - 标签{tags} 难度{difficulty} 预计{minutes} 分钟 **参考答案** {answer} {follow_section} def render(paper, outpaper.md): parts [] for i, r in enumerate(paper, 1): fu render_followups(r[qid]) # 返回 或 **追问**\n\n... parts.append(CARD.format( noi, titler[title], tags、.join(r[tags]), difficultyr[difficulty], minutesr[estimate_min], answerr[answer], follow_section(\n fu) if fu else )) open(out, w, encodingutf-8).write(\n---\n.join(parts))6.3 一个技巧把追问链挂成父子题在 questions 上加一个parent_id字段主问题的追问作为独立行存进去difficulty比主问题高一级。组卷时只抽parent_id IS NULL的题面试过程中根据候选人的回答深度决定要不要下钻。这样一来一套题库既能出基础轮也能出二面不用维护两份文档也不会出现「二面的题基础轮就提前问掉了」的情况。每道主问题下挂 1 到 3 条parent_id指向它的追问难度逐级上调面试官点开一道题看到的不再是一段孤立答案而是一条可以随时停下的追问路径。本文还有配套的精品资源点击获取