SPLADE(Sparse Lexical and Expansion Model)作为一种可学习的稀疏表示方法,在信息检索领域获得了广泛关注。它不像BM25那样依赖固定词表匹配,而是通过神经网络对查询和文档进行词元级别的权重预测,生成高维但大部分为零的稀疏向量。同时,很多SPLADE变体也会保留Transformer最后一层的CLS向量或平均池化向量,作为密集语义表示。这种稀疏与密集向量的组合,能够同时捕获精确词项匹配和语义相似度,在混合检索中表现出色。

然而,大多数SPLADE模型的推理示例都是用Python编写,基于HuggingFace Transformers库。对于Node.js开发者来说,如何在不引入Python微服务的情况下,在现有的Node.js后端中直接生成SPLADE稀疏密集向量,成了一个值得解决的问题。本文将给出一个可落地的方案,使用onnxruntime-node加载ONNX模型,纯JavaScript实现推理与后处理。
理解SPLADE的稀疏密集输出
SPLADE模型的核心是在BERT等编码器之上增加了一个词汇投影层,将每个位置的隐藏状态映射到词汇表大小的logits,然后通过log-saturation和ReLU等激活函数得到非负权重。对于一段输入文本,模型会对所有潜在词元给出权重,但大多数权重接近于零,最终输出一个稀疏向量。这个稀疏向量的维度等于词表大小(通常是30522或更大),其中非零元素对应被模型认为重要的词元及其权重。
与此同时,模型也会输出一个固定维度的密集向量。最常见的是取最后一层[CLS]位置的隐藏状态,经过一个线性层或直接归一化后作为语义表示。这个密集向量通常维度为768或1024,适合用于ANN索引进行语义搜索。将稀疏向量和密集向量结合起来,就可以在同一个检索系统中实现精确词匹配与语义匹配的互补。
在Node.js中实现这一过程,首先需要导出ONNX格式的模型。你可以使用HuggingFace的optimum-cli或直接将PyTorch模型转换为ONNX。导出时注意保留两个输出:一个是稀疏logits(形状为[1, seq_len, vocab_size]),另一个是密集向量(形状为[1, hidden_dim])。本文假设模型已经导出为一个ONNX文件,并包含这两个输出。
在Node.js中加载并运行SPLADE模型
Node.js生态中运行ONNX模型最成熟的方案是微软的onnxruntime-node。首先安装依赖:
npm install onnxruntime-node
接着,我们需要实现文本预处理。SPLADE模型通常使用BERT分词器,将输入文本切分为WordPiece token IDs,并构建attention mask。由于Node.js缺少原生HuggingFace tokenizers,我们可以使用轻量级的@huggingface/transformers库(注意该库也支持Node.js),或者直接使用预置的词汇表文件手动实现分词。下面给出一个使用@huggingface/transformers的简化示例:
const { AutoTokenizer } = require('@huggingface/transformers');
async function tokenize(text) {
const tokenizer = await AutoTokenizer.from_pretrained('naver/splade-cocondenser-ensembledistil');
const encoded = await tokenizer(text, { padding: true, truncation: true, max_length: 256 });
return {
input_ids: encoded.input_ids,
attention_mask: encoded.attention_mask
};
}
注意:@huggingface/transformers在Node.js环境下的API可能与浏览器略有不同,请根据版本调整。此外,tokenizer加载会缓存本地模型文件,首次运行需要网络下载。如果希望避免运行时下载,可以提前将tokenizer文件放在本地目录,然后指定本地路径。
加载ONNX模型并进行推理的代码如下:
const ort = require('onnxruntime-node');
async function loadModel(modelPath) {
const session = await ort.InferenceSession.create(modelPath);
return session;
}
async function runSplade(session, inputIds, attentionMask) {
const inputIdsTensor = new ort.Tensor('int64', BigInt64Array.from(inputIds.map(BigInt)), [1, inputIds.length]);
const attentionMaskTensor = new ort.Tensor('int64', BigInt64Array.from(attentionMask.map(BigInt)), [1, attentionMask.length]);
const feeds = {
input_ids: inputIdsTensor,
attention_mask: attentionMaskTensor
};
const results = await session.run(feeds);
// 假设输出名为 'sparse_logits' 和 'dense_vector'
const sparseLogits = results.sparse_logits;
const denseVector = results.dense_vector;
return { sparseLogits, denseVector };
}
这里需要注意onnxruntime-node对int64的处理:必须使用BigInt64Array,因为JavaScript的Number无法精确表示64位整数。另外,根据模型导出的输入名称,feeds对象中的键名可能需要调整。如果模型期望的是int32的token IDs,可以用Int32Array。
得到sparse_logits后,我们需要进行后处理以转化为稀疏向量。标准做法是:先对logits应用log(1 + ReLU(x)),或者直接ReLU再取对数,然后按词表维度求和(如果有多层输出则加权求和),最后过滤掉权重低于阈值的项。以下代码展示了提取稀疏向量的过程:
function sparsePostProcess(sparseLogitsTensor) {
const data = sparseLogitsTensor.data; // Float32Array,形状为 [1, seq_len, vocab_size]
const [batch, seqLen, vocabSize] = sparseLogitsTensor.dims;
const vocabWeights = new Float32Array(vocabSize);
// 对每个位置,累加经过log(1+ReLU)后的logits
for (let pos = 0; pos < seqLen; pos++) {
const offset = pos * vocabSize;
for (let v = 0; v < vocabSize; v++) {
const logit = data[offset + v];
const activated = Math.log1p(Math.max(0, logit));
vocabWeights[v] += activated;
}
}
// 构建稀疏表示:非零元素索引和权重
const indices = [];
const weights = [];
const threshold = 0.01; // 可根据经验调整
for (let v = 0; v < vocabSize; v++) {
if (vocabWeights[v] > threshold) {
indices.push(v);
weights.push(vocabWeights[v]);
}
}
// 可选:L2归一化
let norm = 0;
for (let i = 0; i < weights.length; i++) norm += weights[i] * weights[i];
norm = Math.sqrt(norm);
if (norm > 0) {
for (let i = 0; i < weights.length; i++) weights[i] /= norm;
}
return { indices, weights };
}
以上代码假定sparse_logits的形状为[1, seq_len, vocab_size],实际模型可能在计算前已经对位置维度做了聚合。请根据导出模型的具体输出维度调整。如果模型输出已经是每个词元的最终权重(例如已经做了求和),则可以简化处理。
密集向量的提取相对简单,通常只需要对结果做L2归一化:
function normalizeDense(denseTensor) {
const data = denseTensor.data; // Float32Array,形状 [1, hidden_dim]
const norm = Math.sqrt(data.reduce((acc, val) => acc + val * val, 0));
return data.map(val => val / norm);
}
最终,我们可以将稀疏向量表示为一个对象:{ indices: number[], values: number[] },密集向量为一个Float32Array。然后将两者组合起来返回给上层调用。
稀疏密集向量的存储与混合检索策略
生成稀疏和密集向量之后,下一步是如何在向量数据库中存储和利用它们。不少主流向量数据库如Milvus、Qdrant、Weaviate都支持在一个集合中同时存储稀疏向量和密集向量。以Qdrant为例,你可以在创建集合时同时定义两个向量字段:一个使用dense向量,一个使用sparse向量。存储时分别写入,检索时可以使用混合评分(如加权求和或Reciprocal Rank Fusion)来合并两种相似度。
在Node.js中,你可以使用官方客户端库。以Qdrant的@qdrant/js-client-rest为例,插入点的代码大致如下:
import { QdrantClient } from '@qdrant/js-client-rest';
const client = new QdrantClient({ url: 'http://localhost:6333' });
async function upsertPoint(collectionName, id, sparseVec, denseVec, payload) {
await client.upsert(collectionName, {
points: [{
id,
vector: {
dense: denseVec,
sparse: {
indices: sparseVec.indices,
values: sparseVec.values
}
},
payload
}]
});
}
查询时,可以同时发送两种查询向量并指定融合策略。Qdrant支持prefetch和query的组合,例如先分别用稀疏和密集向量进行预取,然后使用fusion: 'rrf'合并结果。这种混合检索方案能够充分发挥SPLADE稀疏向量的精确词匹配能力,同时利用密集向量的语义泛化能力。
如果不想依赖外部向量数据库,也可以在Node.js内存中实现简单的混合检索。例如,维护一个倒排索引存储稀疏向量,再使用hnswlib-node等库构建密集向量的ANN索引。检索时分两路获取候选,然后根据权重融合分数。这种方式适合数据量较小的场景,避免了额外的服务部署成本。
性能优化与常见问题
在Node.js中运行深度学习模型,最大的性能瓶颈通常是推理延迟。onnxruntime-node默认使用CPU执行,对于SPLADE这种需要处理词表维度的模型,单次推理可能需要几十毫秒。为了提升吞吐量,可以采取以下几个措施:一是开启ONNX Runtime的图优化和线程数设置,例如设置ort.env.logLevel = 'warning'以及ort.env.numThreads = 4;二是对输入文本进行截断,减少序列长度;三是使用批量推理,将多个请求合并为一个batch。
另一个常见问题是内存占用。SPLADE的稀疏logits输出形状为[1, seq_len, vocab_size],对于vocab_size=30522,seq_len=128,这是一个约390万个浮点数的张量,约15MB。频繁推理会带来较大的内存分配压力。可以通过在导出ONNX模型时直接在计算图中完成log-saturation和位置求和,从而减小输出张量的大小。如果无法修改模型,也可以在Node.js中及时释放张量引用,让垃圾回收器更高效地工作。
此外,分词器的选择也值得注意。如果使用@huggingface/transformers,它内部会下载并加载完整的tokenizer配置,可能会带来一定启动开销。对于生产环境,建议将tokenizer所需的vocab.txt等文件提前打包到项目中,并使用轻量级的tokenizer实现(例如自己解析WordPiece规则),或者使用Rust编写的tokenizer并通过napi-rs编译成Node原生模块,以获得更快的分词速度。
总之,Node.js完全有能力在不依赖Python的情况下实现SPLADE稀疏密集向量的生成与检索。通过合理使用onnxruntime-node、高效的文本预处理和巧妙的混合检索设计,你可以在纯JavaScript服务中构建高性能的搜索引擎或问答系统。希望本文提供的代码和思路能为你自己的项目带来启发。

