为什么会做这个
面面通最开始只是一个面试 + 简历的工具。后来加了论文润色、论文降重,用户用起来觉得还行。
但有个问题一直卡着。
润色的时候用户说:“帮我改一下这段,顺便看看有没有文献支持我的观点。”
问题在于——让 AI 自己引用,它真的会给你编文献。 编得像模像样,作者名、期刊名、年份全都有。一查,没这篇论文。
这不是 AI 的错。ChatGPT 从设计上就不是一个可靠的知识库检索器。它的引用是”看起来合理的文本模式”,不是”我真的读过这篇论文”。
所以面面通的论文知识库模块,目标是做一件事:
让引用可追溯、可验证、可清洗。
不是拒绝 AI 帮忙,而是让 AI 在一个受控的范围内工作——你只能引用已经上传到知识库的文献,而且必须经过三档证据分类,不能自己编。
整体链路
一条数据从上传到最终引用,走完的完整路径是这样的:
上传 PDF/DOCX → 文本提取 (pdf.js / mammoth) → 智能分块 + 参考文献检测 + 关键词提取 → 存入 IndexedDB (Dexie) → 构建全文搜索索引 (MiniSearch) → 用户写论文时,根据上下文检索相关片段 → AI 证据分类:direct_support / background_only / irrelevant → 引用芯片渲染 + 导出时引用标记剥离这条链路如果画出来,大概就是一台”不许 AI 瞎编”的引用流水线:

好,拆开来讲。
第一步:上传 ≠ 存文件
一开始我想得很简单:上传 PDF,存起来,搜索时全文匹配就行了。
实际写了才发现全是坑。
PDF 解析不是 .toString()
PDF 的文本提取比你想象中麻烦得多。pdf.js 能提取文本,但提取出来的是按页面渲染顺序排列的文本片段——标题可能和正文混在一起,数学公式变成乱码,页眉页脚穿插在段落中间。
// paperParser.ts — PDF 解析const pdfjsLib = await import('pdfjs-dist/legacy/build/pdf.mjs')pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorkerUrlconst pdf = await pdfjsLib.getDocument({ data, useWorkerFetch: false }).promise
for (let i = 1; i <= pdf.numPages; i++) { const page = await pdf.getPage(i) const content = await page.getTextContent() const text = content.items .map(item => ('str' in item ? item.str : '')) .join(' ') pageTexts.push(text) page.cleanup()}DOCX 用的 mammoth,稍微好一点,但也要处理图片和表格里的文本。
参考文献区域识别
解析出全文之后,参考文献区域必须剥离。原因很简单:如果你把参考文献列表也当成正文索引进去,搜索”深度学习”会匹配到参考文献标题而不是正文。
// paperChunker.ts — 参考文献区域检测const REFERENCE_HEADING_RE = /^(参考文献|参考资料|引用文献|references|bibliography)/iconst YEAR_RE = /\b(?:19|20)\d{2}\b/gconst JOURNAL_MARK_RE = /\[(?:J|M|D|C|R|P|EB\/OL|OL)\]/gi
function isReferenceLikeText(text: string): boolean { // 如果一段文本里年份出现 4 次以上 + 期刊标记 2 次以上 → 判断为参考文献 return (years >= 4 && (journalMarks >= 2 || pageRanges >= 3)) || (years >= 5 && authorSeparators >= 12 && citationTitles >= 2)}这个方法不算完美,但实战效果够用。按标题匹配、按内容特征匹配两层防御,误杀率很低。
智能分块
论文不能一整篇塞给 AI——上下文窗口有限。必须切成适当大小的块。
分块策略是:
function chunkPaper(fullText: string): PaperChunk[] { // 1. 按标题分段(# 一级标题 / 第一章 这种) const sections = splitByHeaders(stripReferenceSection(fullText))
for (const sec of sections) { // 2. 跳过参考文献区域 if (isReferenceSection(sec.section)) continue
// 3. 长段落按自然段拆分,每块 800 字符左右 const parts = splitLongChunk(sec.content)
for (const part of parts) { // 4. 提取关键词(词频统计 + 停用词过滤) const keywords = extractKeywords(part) chunks.push({ order, section, content, keywords }) } }}每个 chunk 还附带关键词,后面搜索时用得上。
第二步:搜索不是 Ctrl+F
知识库没有 AI 帮你召回——你只能搜你已经上传的东西。
前端的搜索方案用了 MiniSearch,一个纯前端全文搜索库,跑在 IndexedDB 上层。
const ms = new MiniSearch({ fields: ['content', 'keywords'], searchOptions: { boost: { keywords: 2 }, // 关键词匹配比正文匹配权重高 prefix: true, fuzzy: 0.2, // 允许 20% 的模糊匹配 },})数据存在 Dexie(IndexedDB 的包装),不会上传到任何后端服务器。
搜索时根据不同任务类型做了查询增强:
const TASK_KEYWORDS = { paper_polish: ['论文', '润色', '学术', '写作', '表达'], ai_reduce: ['降重', '重复', '相似', '改写', 'AI率'], plagiarism_reduce: ['查重', '抄袭', '重复率', '原创', '改写'], paper_qa: ['问题', '回答', '解释', '分析', '方法'],}用户在润色的时候搜”卷积神经网络”,实际查询相当于 "卷积神经网络 论文 润色 学术"——这能显著提升相关文献的排名。
第三步:证据分类 — 核心环节
这就是最花心思的部分了。
搜出来的文献片段,不能说”跟你的论文关键词匹配了就直接引用”。万一只是关键词重复,实际内容风马牛不相及呢?
所以设计了一个证据分类环节。前端把检索到的 chunk 发给后端,后端调 AI 判断每段文献与当前论文的关系。
三档判定标准
direct_support ✓ — 片段能直接支撑当前论文的具体观点,可作为正文引用background_only △ — 只提供背景参考,不应紧跟具体结论作为引用irrelevant ✗ — 仅关键词相似,不应使用举个更具体的例子。
假设用户当前论文里有一句话:
卷积神经网络可以提升图像分类任务的准确率。
这时候知识库搜出来的片段不能无脑贴上去,而是要先分流:
- 如果片段明确说 CNN 在某个图像分类数据集上提升了准确率,那就是
direct_support,可以分配引用序号。 - 如果片段只是介绍 CNN 的卷积层、池化层这些基础概念,那就是
background_only,适合当背景,不适合紧跟这个结论。 - 如果片段只是碰巧出现了”神经网络”几个字,但内容讲的是文本分类,那就是
irrelevant,不能用。

这一步看起来有点啰嗦,但它其实是在防止一种很隐蔽的错误:关键词相似,不等于观点被支撑。
对应的 Java 实现:
private String buildPrompt(EvidenceRequest request, List<EvidenceRequest.Chunk> chunks) { sb.append("判定标准:\n"); sb.append("- direct_support:片段能直接支撑当前论文中的具体观点、原因、方法、结论\n"); sb.append("- background_only:片段只提供主题背景或术语参考\n"); sb.append("- irrelevant:片段与当前观点关系弱或仅关键词相似\n"); sb.append("只输出 JSON 数组。每项字段:index, supportLevel, confidence, supportedClaim, reason\n"); sb.append("supportLevel 只能是 direct_support/background_only/irrelevant\n"); sb.append("confidence 只能是 high/medium/low\n");}降级策略
AI 接口可能超时、可能返回非法 JSON、可能干脆不可用。
所以证据分类必须有一个降级路径:
// AI 调用失败 → 按检索相关度分数做规则判断private EvidenceResponse.Evidence fallbackEvidence(Chunk chunk, int index) { double score = chunk.getScore(); if (score >= 0.72) { evidence.setSupportLevel("direct_support"); // 高分 → 可直接引用候选 evidence.setConfidence("medium"); } else if (score >= 0.42) { evidence.setSupportLevel("background_only"); // 中等分 → 背景参考 evidence.setConfidence("medium"); } else { evidence.setSupportLevel("irrelevant"); // 低分 → 不建议引用 evidence.setConfidence("low"); }}0.72 和 0.42 这两个阈值是拿实际论文数据跑了几轮试出来的,不一定通用,但在面面通的场景下效果不错。
引用索引的分配
前端拿到分类结果后,只有 direct_support + 非 low 置信度 的 chunk 才会分配引用序号:
function assignCitationIndexes(chunks: ContextChunk[]): ContextChunk[] { let citationIndex = 1 return chunks.map(chunk => { const allowCitation = chunk.supportLevel === 'direct_support' && chunk.confidence !== 'low' return { ...chunk, citationIndex: allowCitation ? citationIndex++ : undefined, } })}没分配到序号的文献,在 prompt 里明确告诉 AI:不能标注引用。
第四步:引用渲染 — 把 [N] 变成看得见的东西
AI 输出的文本里会带 [1]、[2] 这样的引用标记。前端要把它渲染成可点击的 chip,顺便生成参考文献列表。
export function renderCitations(text: string, citedChunks: CitedChunk[]) { // 把 [N] 替换为 chip HTML const html = text.replace(/\[(\d+)\]/g, (match, numStr) => { const index = parseInt(numStr) if (!chunkMap.has(index)) return match return `<span class="cite-chip" data-cite-index="${index}">${index}</span>` })
// 按引用顺序生成参考文献列表 const references = citedIndices.map(index => { const chunk = chunkMap.get(index) return `[${index}] ${chunk.paperTitle}` })
return { html, references, citedIndices }}交互效果是:正文里出现带高亮的数字 chip,鼠标悬停显示文献标题,底部自动生成参考文献列表。
引用提取 — 从论文元数据中找信息
上传时还会尝试从论文文本里提取引用元数据:
export function extractCitation(text: string): PaperCitation | null { // 提取中文名作者(2-4个字,连续出现) const cnMatch = text.match(/[一-鿿]{2,4}(?:[,,、\s]+[一-鿿]{2,4})+/) // 提取年份 const year = text.match(/\b((?:19|20)\d{2})\b/) // 提取期刊名 const journal = text.match(/\[J\]\s*[.。]?\s*(.+?)(?:[,,]|\s*\d{4}|\s*$)/)
return { authors, year, journal, rawReference }}这个纯粹是正则匹配,不调 AI。所以准确率大概 70%,但对于生成参考文献列表来说,够了——不对的后面可以手动改。
导出时剥离引用标记
导出 DOCX 的时候,引用标记必须去掉。不然 Word 里一堆 [1][2] 格式乱了。
export function stripCitationMarkers(text: string): string { return text.replace(/\[\d+\]/g, '')}就一行。但很容易漏掉,所以单独抽了个文件,所有导出路径统一调它。
第五步:安全与权限
知识库涉及用户上传的文献全文,有几个安全设计值得提一下。
日志脱敏
后端的知识库上下文处理器,日志里只打 chunk 数量,不打正文:
log.info("KB context sanitized: {} chunks, {} chars total", cleaned.size(), totalLength);这是写在类注释里的硬性要求:
/** * 隐私要求:后端日志不得打印 contextChunks.content 正文。 * 此类统一执行数量/长度限制和 null 清洗。 */知识库权限
知识库默认对普通用户不可用。需要满足以下条件之一:
- 用户配置了自己的 AI API Key
- 用户是管理员
- 管理员在后台单独开放了权限
if (!hasOwnKey && !isAdmin && !isGranted) { throw new IllegalArgumentException( "知识库需要配置自己的 AI API Key,或由管理员单独开放后才能使用" )}原因很简单:知识库调 AI 做证据分类,如果用系统 Key 给所有用户免费用,额度撑不住。
上下文长度限制
论文 chunk 送到 AI 之前还有一道清洗:
private static final int MAX_CHUNKS = 5; // 最多 5 个 chunkprivate static final int MAX_SINGLE_CONTENT_LENGTH = 1000; // 每个最多 1000 字private static final int MAX_TOTAL_LENGTH = 4000; // 总共不超过 4000 字超出部分截断,不够就从后面补。这样既保证 AI 能拿到足够的上下文,又不会突破 token 限制。
效果怎么样
目前面面通的论文工具组(润色 + AI 降重 + 查重降重)共享这一个知识库模块。
实际使用中的几个数据:
- 一篇 10 页的 PDF 解析 + 分块 + 建索引,大约 3-5 秒
- 知识库搜索 + 证据分类 + 结果返回,平均 8-12 秒
- 证据分类的 AI 调用约消耗 2000-3000 tokens/次
- 三档分类中,
direct_support约占 40%,background_only占 45%,irrelevant占 15%
最大的改进是用户不再怀疑引用是 AI 编的了。因为引用列表明确来自”你上传的这几篇论文”,不会凭空冒出一篇你没见过的文献。
回头看的感想
这个模块做下来,最深的体会是:
AI 能力越强,越需要给它画一个圈。
不是限制它发挥,而是告诉它:“你只能在这个圈里找答案,圈外的你碰都不要碰。” 圈画好了,输出才可靠。
知识库 + 证据分类就是那个圈。你上传的文献是边界,AI 的判断是栅栏——最终落到论文里的每一条引用,都有根可查。
当然还有能改进的地方。比如现在只做了文本检索,未来可以加上 embedding 向量检索,让语义匹配更准。DOCX 模板的引用标记格式保留也有优化空间。但目前的版本已经跑通了从”上传 → 检索 → 分类 → 渲染 → 导出”的完整链路,算是一个可以落地的方案。
如果你对面试模块、AiGateway 网关层或者 DOCX 格式保留导出感兴趣,面面通项目里还有不少类似的设计,完整代码在 GitHub 上。
如果这个项目的思路对你有帮助,点个 ⭐ 就行,对我来说是很大的鼓励。
部分信息可能已经过时





