智能客服、帮助中心、企业内部知识库,这些场景背后几乎都离不开同一项技术:FAQ问答检索。用户输入一句自然语言,系统从预先维护的问答对中找出最相关的条目并返回答案。听起来简单,但一旦知识库规模上来,或者用户提问方式五花八门,朴素的字符串匹配就会完全失效。本文将用Node.js从零实现一个FAQSearch检索模块,覆盖分词、索引、相似度计算和HTTP接口,最终得到一个可以直接跑起来的问答检索服务。

一、FAQ检索的核心原理与整体架构
FAQ检索本质上是一个文本相似度匹配问题。我们把每一条FAQ的问题看作一个文档,用户查询也看作一个文档,然后在向量空间中比较它们之间的距离,距离最近的FAQ就是最佳候选答案。整个流程可以拆成三步:第一步是文本预处理,包括清洗、分词、去停用词;第二步是向量化,把每个问题转成可以被计算的向量表示;第三步是检索排序,计算用户查询与所有FAQ的相似度,按分数从高到低返回。
在实现上,我们采用经典的TF-IDF加余弦相似度方案。TF-IDF衡量一个词在单个文档中的重要程度,出现次数多但全局很少见的词权重高;余弦相似度则衡量两个向量方向的接近程度,取值范围0到1,越接近1表示越相关。这种方案的优点是纯本地计算、不依赖外部API、响应速度快,对于中小规模知识库(几千到几万条)完全够用。如果后续需要更强的语义理解,可以再接入预训练语言模型做语义向量,但这套基础架构依然适用,只需替换向量化层即可。
整体架构设计如下:知识库以JSON文件或数据库存储,服务启动时加载并构建倒排索引;用户请求进来后走同一个预处理管道,保证查询与文档在同一空间中比较;检索层支持TopN返回并附带置信度分数,方便上层业务判断是否需要转人工或者触发兜底话术。这种分层设计让每个环节都可以独立替换和测试。
二、中文分词与知识库预处理
中文与英文最大的区别在于词与词之间没有空格分隔,所以分词是绕不开的第一步。Node.js生态中最常用的中文分词库是nodejieba,它基于结巴分词的词典算法,支持精确模式、全模式和搜索引擎模式,还允许加载自定义词典。对于FAQ这种短文本场景,精确模式就够了。安装方式为npm install nodejieba,注意它是C++原生模块,Windows环境下需要预先安装编译工具链。
如果不想引入原生依赖,也可以退而求其次用正则做简单分词,或者使用纯JavaScript实现的分词库,比如segment。此外还有一个小技巧:很多FAQ问题本身包含专有名词(产品名、功能名),这些词通用词典未必收录,此时务必使用nodejieba.load加载自定义词典,否则“云备份”可能被切成“云”和“备份”,严重影响召回质量。
const nodejieba = require('nodejieba');
// 加载自定义词典,格式为:词 词频 词性
const fs = require('fs');
fs.writeFileSync('./user.dict.utf8', '云备份 100 n\n工单系统 100 n\n');
nodejieba.load({
userDict: './user.dict.utf8',
});
// 停用词表,实际项目中建议维护一份更完整的列表
const STOP_WORDS = new Set(['的', '了', '是', '怎么', '如何', '请问', '我', '你', '吗', '呢', '在', '有']);
function tokenize(text) {
return nodejieba.cut(text)
.map(w => w.trim().toLowerCase())
.filter(w => w.length > 0 && !STOP_WORDS.has(w));
}
console.log(tokenize('请问怎么开启云备份功能'));
// 输出类似: [ '开启', '云备份', '功能' ]停用词处理同样关键。FAQ提问里充斥着“请问”“怎么”“如何”这类语气词和疑问词,它们对区分不同问题几乎没有贡献,反而在计算相似度时引入噪声。上面的tokenize函数把分词、小写化、去停用词合并成一个统一管道,后面无论是构建索引还是处理查询,都复用这一个入口,避免两边处理逻辑不一致导致的分数偏差。
三、构建TF-IDF索引与相似度检索
有了分词管道,下一步是把知识库中的每条问题转成TF-IDF向量。TF指词频,即某个词在当前文档中出现的次数;IDF指逆文档频率,计算公式为log(N / (1 + df)),其中N是文档总数,df是包含该词的文档数。一个词只在少数文档中出现时IDF值高,说明它具有很强的区分能力。我们把所有文档的词到IDF的映射缓存起来,查询时直接复用。
余弦相似度的计算方式是把两个向量的对应维度相乘求和,再除以两个向量模长的乘积。由于FAQ文本较短,向量本身稀疏,用JavaScript对象(哈希表)存储非零维度比数组更省内存、计算更快。下面是完整的检索器实现,包含索引构建和查询两个核心方法。
class FAQSearcher {
constructor(faqList) {
// faqList: [{ id, question, answer }]
this.docs = faqList.map(faq => ({
...faq,
tokens: tokenize(faq.question),
}));
this.total = this.docs.length;
this.idfMap = this.buildIdf();
this.docVectors = this.docs.map(d => this.toVector(d.tokens));
}
// 统计每个词出现在多少个文档中,计算IDF
buildIdf() {
const dfMap = new Map();
for (const doc of this.docs) {
for (const word of new Set(doc.tokens)) {
dfMap.set(word, (dfMap.get(word) || 0) + 1);
}
}
const idfMap = new Map();
for (const [word, df] of dfMap) {
idfMap.set(word, Math.log(this.total / (1 + df)) + 1);
}
return idfMap;
}
// 将词列表转为稀疏TF-IDF向量
toVector(tokens) {
const tfMap = new Map();
for (const t of tokens) tfMap.set(t, (tfMap.get(t) || 0) + 1);
const vec = {};
for (const [word, tf] of tfMap) {
vec[word] = tf * (this.idfMap.get(word) || 1);
}
return vec;
}
cosine(vecA, vecB) {
let dot = 0, normA = 0, normB = 0;
for (const w in vecA) {
normA += vecA[w] * vecA[w];
if (w in vecB) dot += vecA[w] * vecB[w];
}
for (const w in vecB) normB += vecB[w] * vecB[w];
if (normA === 0 || normB === 0) return 0;
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}
// 查询:返回TopN结果及分数
search(query, topN = 3) {
const queryVec = this.toVector(tokenize(query));
return this.docs
.map((doc, i) => ({
id: doc.id,
question: doc.question,
answer: doc.answer,
score: this.cosine(queryVec, this.docVectors[i]),
}))
.sort((a, b) => b.score - a.score)
.slice(0, topN);
}
}这里有一个容易被忽略的细节:IDF计算中加了平滑项,公式里用了1 + df并在最后加1,避免某个词在所有文档都出现时IDF变成零或负数。另外search方法返回的是带分数的完整结果列表而不是单个答案,这样上层可以设置阈值,比如分数低于0.3就认为没有命中,走兜底逻辑,而不是硬塞一个不相关的答案给用户,这对客服类应用尤其重要。
四、用Express搭建查询接口与工程优化建议
检索器写好后,用Express包一层HTTP服务即可对外提供能力。服务启动时加载知识库并构建索引,之后所有查询共享同一份索引,这是典型的空间换时间策略——索引只在启动或知识库更新时构建一次,查询时的计算量只有一次向量化加N次余弦计算,对于几千条FAQ,单次检索耗时通常在几毫秒级别。
const express = require('express');
const app = express();
app.use(express.json());
const faqData = require('./faq.json');
const searcher = new FAQSearcher(faqData);
app.post('/api/faq/search', (req, res) => {
const { query, topN } = req.body;
if (!query || typeof query !== 'string') {
return res.status(400).json({ code: 400, msg: '缺少query参数' });
}
const results = searcher.search(query, topN || 3);
res.json({
code: 0,
matched: results[0] && results[0].score > 0.3,
results,
});
});
app.listen(3000, () => console.log('FAQ服务已启动,端口3000'));在工程层面还有几个值得投入的优化方向。第一是同义词扩展:用户说“手机换号码”,知识库写的是“更换手机号”,字面完全不同但语义一致,可以在分词后把查询词扩展成同义词组,让二者在向量空间中产生交集。第二是拼音纠错,针对用户打错字的情况,可以在低置信度时触发一次拼音级别的重试。第三是性能方面,当知识库增长到十万级时,暴力遍历会开始变慢,此时应引入倒排索引做初筛——只对包含查询词的文档计算相似度,候选集会缩小一到两个数量级。
最后是兜底与可观测性。任何检索系统都不可能百分百命中,建议记录每次查询的分数分布和未命中case,定期分析这些日志去补充知识库和同义词典,形成数据闭环。如果业务对语义理解要求更高,可以把本文的TF-IDF向量化替换成ONNX Runtime加载的中文BERT模型做语义向量,检索架构其余部分完全不用动。一个分层清晰的FAQSearch模块,价值正在于此:每一层都可以独立演进。