类比推理任务在自然语言处理领域有一个经典的表述形式:a之于b,相当于c之于哪个词?最著名的例子是“男人:女人 = 国王:?”,模型需要推断出答案是“王后”。这个看似简单的任务,实际上是对词向量空间中语义关系的直接检验。ANALOGY正是这类任务的统称,Google的Word2Vec论文中展示的king - man + woman ≈ queen运算,就是它的最典型体现。本文将用Node.js从零实现一个可运行的类比推理程序,不依赖任何深度学习框架。

理解词向量与类比推理的数学原理
词向量(Word Embedding)的本质是把每个词映射成一个固定长度的实数向量,比如300维。训练良好的词向量有一个非常迷人的性质:语义关系在向量空间中体现为方向上的一致性。以“性别”这个语义方向为例,man到woman的位移向量,与king到queen的位移向量,在空间中几乎是平行的。这意味着两者做差之后得到的向量会高度接近,于是类比推理就可以转化为向量运算问题。
具体来说,给定三元组a、b、c,目标是找到词d,使得b - a ≈ d - c。换一种写法就是 d ≈ c + b - a。把king、man、woman的向量代入,就得到经典的 king - man + woman ≈ queen。为了评估候选词与目标向量的接近程度,通常使用余弦相似度,它衡量两个向量夹角的大小,取值范围从-1到1,越接近1表示方向越一致。之所以用余弦而不是欧氏距离,是因为词向量的模长往往反映词频信息,归一化后只看方向更能体现语义相似性。
ANALOGY任务有两种主流评分函数。第一种叫3CosAdd,直接计算c、b、a三个向量与候选词的余弦相似度并做加减组合;第二种叫3CosMul,改用乘法组合相似度,对相似度较小的维度更敏感,在部分数据集上表现更好。下面是两种策略的公式化描述:
// 3CosAdd:向量加法策略 // score(d) = cos(d, c) - cos(d, a) + cos(d, b) // 3CosMul:乘法组合策略(余弦值需归一化到0到1区间) // score(d) = (cos(d, c) * cos(d, b)) / (cos(d, a) + epsilon) // 其中epsilon是极小值,防止除零
用Node.js加载和解析词向量文件
实现的第一步是拿到一份预训练词向量。GloVe和fastText都提供了公开下载的文本格式词向量文件,每行由词本身和若干个浮点数组成,第一行是词表大小和向量维度。以glove.6B.100d.txt为例,文件大约有40万个词,直接全部读入内存需要几百MB,对于演示项目来说,建议先按需裁剪出一个小词表文件,方便调试。
解析过程要注意两个细节:一是文件采用空格分隔,每个词后面的数字个数等于向量维度,可以通过长度校验过滤掉解析异常的行;二是GloVe文件中存在类似“. . .”这样的重复词,加载时保留第一个出现的即可。下面给出加载器的完整实现:
const fs = require("fs");
const readline = require("readline");
async function loadEmbeddings(filePath) {
const vocab = new Map(); // 词到向量索引的映射
const vectors = []; // 所有向量按顺序存储
const rl = readline.createInterface({
input: fs.createReadStream(filePath, "utf8"),
crlfDelay: Infinity
});
let firstLine = true;
let dim = 0;
for await (const line of rl) {
if (firstLine) {
// GloVe没有头行,fastText第一行是 词数 维度
const parts = line.trim().split(/\s+/);
if (parts.length === 2 && !isNaN(parseFloat(parts[1]))) {
firstLine = false;
continue;
}
firstLine = false;
}
const parts = line.trim().split(/\s+/);
const word = parts[0];
const nums = parts.slice(1).map(parseFloat);
if (dim === 0) dim = nums.length;
if (nums.length !== dim || vocab.has(word)) continue;
vocab.set(word, vectors.length);
vectors.push(nums);
}
return { vocab, vectors, dim };
}
loadEmbeddings("./glove.small.txt").then(model => {
console.log("词表大小:", model.vocab.size, "维度:", model.dim);
});
这段代码使用流式读取,逐行解析并构建词表Map和向量数组。词到索引的Map设计让后续查找词向量的时间复杂度为O(1),而向量本体存放在扁平数组中,方便批量做数值运算。如果你的词表很大,也可以考虑把向量按Float32Array存储,能显著降低内存占用并提升计算速度。
实现余弦相似度与两种推理策略
有了词向量,接下来实现核心的数学运算。余弦相似度的计算公式是两个向量的点积除以模长乘积。为了效率,可以在加载完成后对所有向量预先做L2归一化,这样点积就是余弦相似度本身,省去每次重复计算模长的开销:
function normalize(vec) {
let sum = 0;
for (const v of vec) sum += v * v;
const norm = Math.sqrt(sum);
return vec.map(v => v / norm);
}
function dot(u, v) {
let s = 0;
for (let i = 0; i < u.length; i++) s += u[i] * v[i];
return s;
}
然后实现类比推理的主体函数。输入a、b、c三个词,先构造目标向量,再遍历整个词表计算相似度,排除掉题目中的三个词本身,最后返回得分最高的若干个候选词:
function analogy(model, wordA, wordB, wordC, topN = 5, useMul = false) {
const get = w => {
const idx = model.vocab.get(w);
if (idx === undefined) throw new Error("词不在词表中: " + w);
return model.vectors[idx];
};
const va = get(wordA), vb = get(wordB), vc = get(wordC);
const eps = 1e-8;
const results = [];
for (const [word, idx] of model.vocab) {
if (word === wordA || word === wordB || word === wordC) continue;
const vd = model.vectors[idx];
let score;
if (useMul) {
// 3CosMul:相似度先映射到0到1再相乘
const sc = (dot(vc, vd) + 1) / 2;
const sb = (dot(vb, vd) + 1) / 2;
const sa = (dot(va, vd) + 1) / 2;
score = (sc * sb) / (sa + eps);
} else {
// 3CosAdd:向量加法策略
score = dot(vc, vd) - dot(va, vd) + dot(vb, vd);
}
results.push({ word, score });
}
results.sort((x, y) => y.score - x.score);
return results.slice(0, topN);
}
module.exports = { loadEmbeddings, analogy };
写一个简单的测试脚本验证效果。如果词向量质量正常,输入man、woman、king应该能看到queen出现在前几名,同样可以试试paris、france、tokyo,期望答案是japan:
const { loadEmbeddings, analogy } = require("./analogy");
(async () => {
const model = await loadEmbeddings("./glove.small.txt");
console.log("男人:女人 = 国王:?");
console.table(analogy(model, "man", "woman", "king", 3));
console.log("法国:巴黎 = 日本:?");
console.table(analogy(model, "france", "paris", "tokyo", 3));
})();
工程优化与实际应用场景
上面的实现思路清晰,但每次推理都要遍历全部词表计算相似度,40万词的词表跑一次3CosAdd大约需要几十毫秒,对于交互式应用勉强够用,若要支撑高并发服务就需要优化。最常见的手段是把词向量矩阵和计算迁移到原生模块,比如用node结合blas库做矩阵乘法,一次性算出目标向量与整个词表的相似度,比纯JavaScript循环快一个数量级以上。另一个方向是引入近似最近邻检索库,如hnswlib或faiss的Node绑定,把查询复杂度从线性降到近似对数级别。
在应用层面,类比推理的价值不只是学术评测。它可以用于搜索同义词扩展,比如输入某行业的专业词,通过类比找出同关系的其他术语;也可以用于推荐系统的标签关联,挖掘物品属性之间的潜在映射;在智能问答和对话系统中,类比能力还能帮助模型理解用户口语化表达的语义关系。甚至有些代码搜索工具借助代码片段的向量表示做“这段代码类似于什么”的检索,底层逻辑与ANALOGY任务一脉相承。
最后提醒几个实践中的坑。第一,词向量是小写敏感的,查询前务必对输入做统一的小写化和分词处理,否则会出现词不在词表的报错。第二,类比推理结果高度依赖训练语料,GloVe在维基百科语料上训练的向量对常识类比效果好,但对领域专有词汇可能完全失效,垂直场景建议用领域语料重新训练或微调。第三,3CosMul并非总是优于3CosAdd,建议在自己的数据上各跑一遍评测再选型。掌握这套流程后,你还可以进一步尝试用fastText的子词向量处理未登录词,或者接入BERT等上下文向量模型,让类比推理系统具备更强的语义泛化能力。