跳到主要内容

向量搜索与 Embedding

问题

什么是向量搜索(Vector Search)?Embedding 模型如何工作?不同相似度算法和向量索引有什么区别?前端如何实现语义化搜索?生产环境中如何设计高效的向量搜索架构?

答案

向量搜索是通过数学相似度而非关键词匹配来检索内容的技术。核心思路是将文本(或图片、音频等)转化为高维向量(Embedding),语义相似的内容在向量空间中距离更近,从而实现"搜索意思而非字面"的能力。向量搜索是 RAG 检索增强生成、语义缓存(详见 AI 应用性能优化)等 AI 应用的核心基础设施。

一、Embedding 原理与模型选型

1.1 Embedding 如何工作

Embedding 模型的本质是一个编码器(Encoder),它将任意长度的文本压缩为一个固定维度的数值向量。这个过程是通过Transformer 架构中的 Encoder 部分实现的:

Embedding 模型 vs 生成模型

Embedding 模型只有 Encoder 部分(如 BERT 架构),不像 GPT 那样有 Decoder 做逐 token 生成。它将一段文本的整体语义压缩到一个向量中。因此 Embedding 模型通常比生成模型小得多(几百 MB 级别),推理也快得多。

1.2 向量维度权衡

维度是 Embedding 向量的信息容量。维度越高,能编码的语义细节越丰富,但存储、计算和检索成本也越高。

维度代表模型单向量存储万条数据存储搜索速度语义精度
384all-MiniLM-L6-v21.5 KB~15 MB最快适合简单场景
768BGE-base、M3E-base3 KB~30 MB中文效果好
1024Cohere embed-v34 KB~40 MB较快多语言优秀
1536text-embedding-3-small6 KB~60 MB中等通用性强
3072text-embedding-3-large12 KB~120 MB较慢最高精度
维度选择建议
  • 原型验证 / 小数据集:384 维(MiniLM),免费本地运行
  • 中文场景:768 维(BGE / M3E),中文语义理解好
  • 通用生产:1536 维(text-embedding-3-small),性价比最优
  • 高精度要求:3072 维(text-embedding-3-large),如法律/医疗

1.3 Matryoshka Embedding(俄罗斯套娃嵌入)

OpenAI 的 text-embedding-3 系列支持 Matryoshka Representation Learning,允许你截取向量的前 N 维作为降维后的 Embedding,且前 N 维仍保留了大部分语义信息(精度损失极小):

lib/matryoshka-embedding.ts
import OpenAI from 'openai';

const openai = new OpenAI();

// 使用 dimensions 参数控制输出维度
async function getEmbedding(
text: string,
dimensions: number = 1536 // 可选 256/512/1024/1536/3072
): Promise<number[]> {
const response = await openai.embeddings.create({
model: 'text-embedding-3-small',
input: text,
dimensions, // Matryoshka: 只返回前 N 维
});
return response.data[0].embedding;
}

// 对比不同维度的检索效果
async function compareDimensions(query: string, documents: string[]): Promise<void> {
const dims = [256, 512, 1024, 1536];

for (const dim of dims) {
const queryEmb = await getEmbedding(query, dim);
const docEmbs = await Promise.all(documents.map(d => getEmbedding(d, dim)));

const similarities = docEmbs.map((emb, i) => ({
doc: documents[i].slice(0, 30),
similarity: cosineSimilarity(queryEmb, emb),
}));

console.log(`维度 ${dim}:`, similarities);
// 维度 256: 排序结果与 1536 高度一致,相似度数值略有差异
}
}
Matryoshka 注意事项
  • 只有 text-embedding-3-* 系列支持此功能,旧模型(如 text-embedding-ada-002)不支持
  • 降维后的向量需要重新归一化(OpenAI API 已自动处理)
  • 从 1536 降至 512 维,检索准确率通常只下降 1-3%,但存储减少 67%

1.4 Embedding 模型对比

模型提供方维度最大 Token价格中文支持特点
text-embedding-3-smallOpenAI15368191$0.02/M tokens良好性价比最优,支持 Matryoshka
text-embedding-3-largeOpenAI30728191$0.13/M tokens良好最高精度,支持 Matryoshka
embed-v4Cohere1024128K$0.10/M tokens优秀超长上下文,多语言,int8 量化
voyage-3Voyage AI102432K$0.06/M tokens优秀长文本,代码搜索
BGE-large-zh-v1.5BAAI(智源)1024512免费开源最佳中文 SOTA,可本地部署
M3E-baseMoka768512免费开源优秀中文优化,轻量
all-MiniLM-L6-v2Sentence Transformers384256免费开源一般极轻量,适合浏览器端
jina-embeddings-v3Jina AI10248192$0.02/M tokens良好多任务,支持降维
选型建议
  • 预算充足 + 通用场景text-embedding-3-small(1536d)
  • 中文为主 + 自托管BGE-large-zh-v1.5(1024d)
  • 超长文档embed-v4(128K 上下文)
  • 浏览器端all-MiniLM-L6-v2(384d,通过 Transformers.js 运行)
  • 代码搜索voyage-code-3(Voyage 针对代码优化)

二、相似度算法详解

三种主流相似度度量各有适用场景,理解数学本质是选择的关键。

2.1 余弦相似度(Cosine Similarity)

衡量两个向量方向的一致性,忽略大小(模长),值域 [1,1][-1, 1]

cosine(A,B)=ABAB=i=1nAiBii=1nAi2i=1nBi2\text{cosine}(\vec{A}, \vec{B}) = \frac{\vec{A} \cdot \vec{B}}{|\vec{A}| \cdot |\vec{B}|} = \frac{\sum_{i=1}^{n} A_i B_i}{\sqrt{\sum_{i=1}^{n} A_i^2} \cdot \sqrt{\sum_{i=1}^{n} B_i^2}}
lib/similarity.ts
// 余弦相似度:最常用,对向量长度不敏感
function cosineSimilarity(a: number[], b: number[]): number {
let dotProduct = 0;
let normA = 0;
let normB = 0;

for (let i = 0; i < a.length; i++) {
dotProduct += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}

const denominator = Math.sqrt(normA) * Math.sqrt(normB);
if (denominator === 0) return 0;
return dotProduct / denominator;
}

2.2 点积(Dot Product / Inner Product)

同时考虑方向和大小,值域 (,+)(-\infty, +\infty)。当向量已归一化(L2 Norm = 1)时,点积 = 余弦相似度。

dot(A,B)=AB=i=1nAiBi\text{dot}(\vec{A}, \vec{B}) = \vec{A} \cdot \vec{B} = \sum_{i=1}^{n} A_i B_i
lib/similarity.ts
// 点积:向量已归一化时等价于余弦相似度,计算更快
function dotProduct(a: number[], b: number[]): number {
let result = 0;
for (let i = 0; i < a.length; i++) {
result += a[i] * b[i];
}
return result;
}

// L2 归一化
function normalize(vec: number[]): number[] {
const norm = Math.sqrt(vec.reduce((sum, v) => sum + v * v, 0));
if (norm === 0) return vec;
return vec.map(v => v / norm);
}

2.3 欧氏距离(Euclidean Distance / L2 Distance)

衡量两个向量在空间中的直线距离,值域 [0,+)[0, +\infty)。距离越小越相似。

L2(A,B)=i=1n(AiBi)2\text{L2}(\vec{A}, \vec{B}) = \sqrt{\sum_{i=1}^{n} (A_i - B_i)^2}
lib/similarity.ts
// 欧氏距离:值越小越相似
function euclideanDistance(a: number[], b: number[]): number {
let sum = 0;
for (let i = 0; i < a.length; i++) {
const diff = a[i] - b[i];
sum += diff * diff;
}
return Math.sqrt(sum);
}

// 转换为相似度分数(0~1)
function euclideanSimilarity(a: number[], b: number[]): number {
return 1 / (1 + euclideanDistance(a, b));
}

2.4 三种算法对比

维度余弦相似度点积欧氏距离
值域[1,1][-1, 1](,+)(-\infty, +\infty)[0,+)[0, +\infty)
相似方向越接近 1 越相似越大越相似越小越相似
是否归一化自动归一化需要归一化才等价于余弦不需要
计算复杂度O(n)O(n)(含归一化)O(n)O(n)O(n)O(n)
对向量长度敏感
适用场景文本语义搜索(最常用)已归一化的向量(性能优先)推荐系统、聚类
选择建议

大多数 Embedding 模型输出已归一化的向量(如 OpenAI、Cohere),此时三种算法的排序结果完全一致。实践中推荐:

  • 默认选余弦相似度:语义最直观,不依赖归一化
  • 性能优先选点积:省去归一化计算,pgvector/Pinecone 默认使用
  • 聚类任务选欧氏距离:K-Means 等算法基于欧氏距离

三、向量索引算法

当数据量达到百万甚至亿级时,暴力搜索(逐一比较)不可行。向量索引算法通过近似最近邻搜索(ANN, Approximate Nearest Neighbor) 在准确率和速度之间取得平衡。

3.1 暴力搜索(Brute Force / Flat)

遍历所有向量,计算与查询向量的距离,返回 Top-K。

  • 时间复杂度O(Nd)O(N \cdot d)(N 为向量数,d 为维度)
  • 准确率:100%(精确搜索)
  • 适用:数据量 < 10 万
lib/brute-force-search.ts
interface VectorRecord {
id: string;
vector: number[];
metadata?: Record<string, unknown>;
}

function bruteForceSearch(
query: number[],
dataset: VectorRecord[],
topK: number = 5
): Array<{ record: VectorRecord; score: number }> {
const results = dataset.map(record => ({
record,
score: cosineSimilarity(query, record.vector),
}));

// 全量排序取 Top-K
results.sort((a, b) => b.score - a.score);
return results.slice(0, topK);
}

3.2 IVF(Inverted File Index,倒排文件索引)

将向量空间通过 K-Means 聚类划分为多个 Voronoi 区域,每个区域有一个聚类中心(Centroid)。查询时只在最近的几个聚类中搜索。

  • 时间复杂度O(nlistd+nprobeNnlistd)O(\text{nlist} \cdot d + \text{nprobe} \cdot \frac{N}{\text{nlist}} \cdot d)
  • 关键参数nlist(聚类数),nprobe(查询时搜索的聚类数)
  • 适用:百万级数据

3.3 HNSW(Hierarchical Navigable Small World)

目前最流行的 ANN 算法,构建多层跳表式图结构。每一层是一个"小世界"图,顶层稀疏(远程跳转),底层稠密(精确搜索)。

搜索过程

  1. 从最顶层的入口节点开始
  2. 在当前层中贪心搜索最近邻
  3. 到达当前层的局部最优后,下降到下一层
  4. 重复步骤 2-3 直到底层(Layer 0)
  5. 在底层返回 Top-K 结果
  • 时间复杂度O(logNd)O(\log N \cdot d)
  • 关键参数M(每个节点的最大连接数),ef_construction(建索引时搜索宽度),ef_search(查询时搜索宽度)
  • 适用:百万到千万级数据,需要低延迟

3.4 Product Quantization(乘积量化,PQ)

将高维向量拆分为多个子空间,每个子空间独立做 K-Means 聚类,用聚类 ID(1 字节)代替原始浮点数。可将存储压缩 96% 以上。

例如:1536 维向量(6 KB) -> 分为 192 个子空间 -> 每个子空间用 1 字节 ID -> 192 字节

  • 常与 IVF 组合使用:IVF-PQ 是大规模场景的标配
  • 适用:亿级数据,内存受限场景

3.5 索引算法对比

算法搜索时间复杂度内存占用准确率建索引时间适用规模
Flat(暴力)O(Nd)O(N \cdot d)100%< 10 万
IVF-FlatO(nprobeN/nlistd)O(\text{nprobe} \cdot N/\text{nlist} \cdot d)95-99%10 万 ~ 百万
HNSWO(logNd)O(\log N \cdot d)高(需存图结构)97-99.5%百万 ~ 千万
IVF-PQO(nprobeN/nlistm)O(\text{nprobe} \cdot N/\text{nlist} \cdot m)极低(96% 压缩)90-95%千万 ~ 亿
HNSW + PQO(logNm)O(\log N \cdot m)93-97%千万 ~ 亿

四、向量数据库实战

4.1 pgvector(PostgreSQL 扩展)

对已有 PostgreSQL 基础设施的项目,pgvector 是最无缝的选择——不需要额外部署向量数据库。

database/pgvector-setup.sql
-- 启用 pgvector 扩展
CREATE EXTENSION IF NOT EXISTS vector;

-- 创建文档表
CREATE TABLE documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
content TEXT NOT NULL,
embedding vector(1536), -- 1536 维向量
metadata JSONB DEFAULT '{}',
content_hash VARCHAR(64), -- 用于增量更新
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);

-- 创建 HNSW 索引(推荐,查询更快)
CREATE INDEX ON documents
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);

-- 创建 IVFFlat 索引(建索引更快,适合频繁更新)
-- CREATE INDEX ON documents
-- USING ivfflat (embedding vector_cosine_ops)
-- WITH (lists = 100); -- lists 推荐为 sqrt(行数)

-- 相似度搜索(余弦距离,用 <=> 操作符)
SELECT id, content, metadata,
1 - (embedding <=> $1::vector) AS similarity
FROM documents
WHERE metadata->>'category' = 'frontend'
ORDER BY embedding <=> $1::vector
LIMIT 10;
pgvector 操作符
操作符含义索引类型
<=>余弦距离vector_cosine_ops
<->欧氏距离(L2)vector_l2_ops
<#>负点积vector_ip_ops

HNSW 索引参数调优

参数说明默认值调优建议
m每节点最大连接数16增大提高精度但增加内存,推荐 16-64
ef_construction建索引搜索宽度64增大提高索引质量但建索引更慢,推荐 64-200
ef_search查询搜索宽度40运行时设置 SET hnsw.ef_search = 100;

Prisma 集成示例

lib/pgvector-prisma.ts
import { PrismaClient } from '@prisma/client';

const prisma = new PrismaClient();

// pgvector + Prisma:使用 $queryRaw 执行向量搜索
async function searchDocuments(
queryEmbedding: number[],
topK: number = 10,
category?: string
): Promise<Array<{ id: string; content: string; similarity: number }>> {
const vectorStr = `[${queryEmbedding.join(',')}]`;

const results = await prisma.$queryRaw<
Array<{ id: string; content: string; similarity: number }>
>`
SELECT id, content,
1 - (embedding <=> ${vectorStr}::vector) AS similarity
FROM documents
WHERE (${category}::text IS NULL OR metadata->>'category' = ${category})
ORDER BY embedding <=> ${vectorStr}::vector
LIMIT ${topK}
`;

return results;
}

// 批量写入文档和向量
async function upsertDocuments(
docs: Array<{ id: string; content: string; embedding: number[]; metadata: Record<string, unknown> }>
): Promise<void> {
// 使用事务批量插入
await prisma.$transaction(
docs.map(doc =>
prisma.$executeRaw`
INSERT INTO documents (id, content, embedding, metadata, content_hash)
VALUES (
${doc.id}::uuid,
${doc.content},
${`[${doc.embedding.join(',')}]`}::vector,
${JSON.stringify(doc.metadata)}::jsonb,
encode(sha256(${doc.content}::bytea), 'hex')
)
ON CONFLICT (id) DO UPDATE SET
content = EXCLUDED.content,
embedding = EXCLUDED.embedding,
metadata = EXCLUDED.metadata,
content_hash = EXCLUDED.content_hash,
updated_at = NOW()
`
)
);
}

4.2 Pinecone(云托管向量数据库)

Pinecone 是全托管服务,无需运维,适合快速上线。

lib/pinecone.ts
import { Pinecone } from '@pinecone-database/pinecone';

const pinecone = new Pinecone({ apiKey: process.env.PINECONE_API_KEY! });

// Serverless 索引(推荐:按用量计费,自动扩缩)
const index = pinecone.Index('documents');

// ---- 写入 ----

// 批量 upsert(最大 100 条/批)
async function batchUpsert(
records: Array<{
id: string;
text: string;
embedding: number[];
metadata: Record<string, unknown>;
}>,
namespace: string = 'default'
): Promise<void> {
const BATCH_SIZE = 100;
const ns = index.namespace(namespace);

for (let i = 0; i < records.length; i += BATCH_SIZE) {
const batch = records.slice(i, i + BATCH_SIZE);
await ns.upsert(
batch.map(r => ({
id: r.id,
values: r.embedding,
metadata: { ...r.metadata, text: r.text },
}))
);
}
}

// ---- 查询 ----

// 带 metadata 过滤的语义搜索
async function semanticSearch(
queryEmbedding: number[],
options: {
topK?: number;
namespace?: string;
filter?: Record<string, unknown>;
} = {}
): Promise<Array<{ id: string; text: string; score: number }>> {
const { topK = 10, namespace = 'default', filter } = options;

const results = await index.namespace(namespace).query({
vector: queryEmbedding,
topK,
filter, // 例如:{ category: { $eq: 'frontend' }, date: { $gte: '2024-01-01' } }
includeMetadata: true,
});

return results.matches?.map(m => ({
id: m.id,
text: m.metadata?.text as string,
score: m.score ?? 0,
})) ?? [];
}

// ---- 混合搜索(Sparse-Dense Vectors)----

// Pinecone 支持稀疏-稠密混合向量,结合关键词和语义
async function hybridSearch(
denseEmbedding: number[],
sparseValues: { indices: number[]; values: number[] }, // BM25 稀疏向量
topK: number = 10,
alpha: number = 0.7 // 稠密向量权重
): Promise<Array<{ id: string; score: number }>> {
const results = await index.namespace('default').query({
vector: denseEmbedding,
sparseVector: sparseValues,
topK,
includeMetadata: true,
});

return results.matches?.map(m => ({
id: m.id,
score: m.score ?? 0,
})) ?? [];
}
Pinecone Serverless vs Pod
维度Serverless(推荐)Pod-based
计费按读写 + 存储量按 Pod 数量(固定月费)
扩缩容自动手动配置
冷启动有(首次查询稍慢)
适用大多数场景低延迟要求、稳定流量
成本低流量时极低最低约 $70/月

4.3 向量数据库生产选型

数据库类型索引算法最大规模混合搜索价格适用场景
pgvectorPG 扩展HNSW/IVF千万级需手动实现免费已有 PG、中小规模
Pinecone云托管专有引擎亿级原生支持按用量快速上线、零运维
Qdrant开源/云HNSW亿级原生支持免费/按用量高性能、自托管生产
Weaviate开源/云HNSW亿级原生支持免费/按用量GraphQL API、多模态
Milvus开源IVF/HNSW/PQ百亿级原生支持免费超大规模、分布式
Chroma开源HNSW百万级有限免费原型开发、本地

五、文档分块策略(Chunking)

分块是向量搜索质量的决定性因素——分块粒度直接影响检索的精度和召回率。更详细的分块策略可参考 RAG 检索增强生成 中的文档预处理部分。

5.1 分块方法对比

lib/chunking.ts
// ---- 1. 固定大小分块 ----
function fixedSizeChunk(
text: string,
chunkSize: number = 500,
overlap: number = 100
): string[] {
const chunks: string[] = [];
for (let i = 0; i < text.length; i += chunkSize - overlap) {
chunks.push(text.slice(i, i + chunkSize));
}
return chunks;
}

// ---- 2. 递归字符分块(推荐,LangChain 默认策略)----
function recursiveChunk(
text: string,
maxSize: number = 500,
overlap: number = 100,
separators: string[] = ['\n\n', '\n', '。', '!', '?', '.', ' ', '']
): string[] {
if (text.length <= maxSize) return [text];

const sep = separators.find(s => text.includes(s)) ?? '';
const splits = text.split(sep).filter(Boolean);

const chunks: string[] = [];
let current = '';

for (const split of splits) {
const candidate = current ? current + sep + split : split;
if (candidate.length > maxSize && current) {
chunks.push(current);
// overlap: 保留上一块尾部
const overlapText = current.slice(-overlap);
current = overlapText + sep + split;
} else {
current = candidate;
}
}
if (current) chunks.push(current);

return chunks;
}

// ---- 3. 语义分块(基于 Embedding 相似度断句)----
async function semanticChunk(
sentences: string[],
threshold: number = 0.5 // 相似度低于此值则断开
): Promise<string[][]> {
const embeddings = await getEmbeddings(sentences);
const groups: string[][] = [[]];

for (let i = 0; i < sentences.length; i++) {
if (i === 0) {
groups[groups.length - 1].push(sentences[i]);
continue;
}

// 计算相邻句子的相似度
const sim = cosineSimilarity(embeddings[i - 1], embeddings[i]);
if (sim < threshold) {
// 相似度低 → 语义跳变 → 开始新块
groups.push([sentences[i]]);
} else {
groups[groups.length - 1].push(sentences[i]);
}
}

return groups;
}

5.2 分块策略选型

策略优点缺点适用场景
固定大小简单、可预测可能截断语义日志、结构化文本
递归字符尊重文本结构参数需调优通用文档(推荐默认)
语义分块语义完整性最好需调用 Embedding API、成本高高质量知识库
按标题分块保留文档结构依赖格式规范Markdown、HTML
滑动窗口无信息丢失chunk 数量多、冗余大长篇法律/学术文档
分块大小建议
  • 太小(< 100 字):语义碎片化,检索结果缺乏上下文
  • 太大(> 2000 字):噪声多,稀释关键信息
  • 推荐范围:300-800 字,overlap 50-200 字
  • 经验法则:chunk 大小与 Embedding 模型的最大 token 长度匹配效果最好

六、多模态 Embedding

多模态 Embedding 将不同类型的数据(文本、图片、音频)映射到同一个向量空间,实现跨模态搜索——例如用文本搜索图片。

6.1 CLIP 模型原理

CLIP(Contrastive Language-Image Pre-training)由 OpenAI 提出,通过对比学习将文本和图片编码到共享向量空间。

6.2 以文搜图实现

lib/multimodal-search.ts
import OpenAI from 'openai';

const openai = new OpenAI();

// 图片 Embedding(通过 CLIP 或多模态模型)
// 方案1:使用 Jina CLIP v2 API
async function getImageEmbedding(imageUrl: string): Promise<number[]> {
const response = await fetch('https://api.jina.ai/v1/embeddings', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.JINA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'jina-clip-v2',
input: [{ image: imageUrl }],
}),
});
const data = await response.json();
return data.data[0].embedding;
}

// 文本 Embedding(同一模型)
async function getTextEmbedding(text: string): Promise<number[]> {
const response = await fetch('https://api.jina.ai/v1/embeddings', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.JINA_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'jina-clip-v2',
input: [{ text }],
}),
});
const data = await response.json();
return data.data[0].embedding;
}

// 以文搜图:用文本查询搜索图片库
async function searchImagesByText(
query: string,
imageIndex: Array<{ id: string; url: string; embedding: number[] }>,
topK: number = 5
): Promise<Array<{ id: string; url: string; similarity: number }>> {
const queryEmbedding = await getTextEmbedding(query);

const results = imageIndex.map(img => ({
id: img.id,
url: img.url,
similarity: cosineSimilarity(queryEmbedding, img.embedding),
}));

results.sort((a, b) => b.similarity - a.similarity);
return results.slice(0, topK);
}

// 使用示例
// searchImagesByText('一只在海边奔跑的金毛犬', imageIndex);
// → 返回最匹配的图片(即使图片没有任何文字标签)
多模态 Embedding 应用场景
  • 电商图搜:用户描述"红色连衣裙"搜索商品图
  • 图片管理:用自然语言搜索相册中的照片
  • 内容审核:将敏感文本描述与上传图片做相似度匹配
  • RAG 增强:将文档中的图表也纳入检索范围

七、Embedding 缓存与增量更新

在生产环境中,避免对未变更的文档重复调用 Embedding API 是成本控制的关键

lib/incremental-embedding.ts
import crypto from 'crypto';

interface DocumentRecord {
id: string;
content: string;
contentHash: string;
embedding: number[];
updatedAt: Date;
}

class IncrementalEmbeddingPipeline {
private db: DatabaseClient; // 抽象数据库客户端

constructor(db: DatabaseClient) {
this.db = db;
}

// 计算内容哈希
private hash(content: string): string {
return crypto.createHash('sha256').update(content).digest('hex');
}

// 增量更新:只对变更的文档重新 Embedding
async processDocuments(
documents: Array<{ id: string; content: string }>
): Promise<{ created: number; updated: number; skipped: number }> {
const stats = { created: 0, updated: 0, skipped: 0 };
const toEmbed: Array<{ id: string; content: string }> = [];

for (const doc of documents) {
const hash = this.hash(doc.content);
const existing = await this.db.getDocument(doc.id);

if (existing && existing.contentHash === hash) {
// 内容未变,跳过
stats.skipped++;
continue;
}

toEmbed.push(doc);
stats[existing ? 'updated' : 'created']++;
}

// 批量生成 Embedding(节省 API 调用)
if (toEmbed.length > 0) {
const BATCH_SIZE = 100;
for (let i = 0; i < toEmbed.length; i += BATCH_SIZE) {
const batch = toEmbed.slice(i, i + BATCH_SIZE);
const embeddings = await getEmbeddings(batch.map(d => d.content));

await this.db.upsertDocuments(
batch.map((doc, j) => ({
id: doc.id,
content: doc.content,
contentHash: this.hash(doc.content),
embedding: embeddings[j],
updatedAt: new Date(),
}))
);
}
}

console.log(
`处理完成: 新增 ${stats.created}, 更新 ${stats.updated}, 跳过 ${stats.skipped}`
);
return stats;
}

// 删除已不存在的文档
async cleanup(activeIds: Set<string>): Promise<number> {
const allIds = await this.db.getAllDocumentIds();
const toDelete = allIds.filter(id => !activeIds.has(id));
if (toDelete.length > 0) {
await this.db.deleteDocuments(toDelete);
}
return toDelete.length;
}
}

八、生产向量搜索架构

一个完整的生产级向量搜索系统需要考虑索引流水线异步处理监控告警等工程问题。

server/embedding-pipeline.ts
import { Queue, Worker } from 'bullmq';
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL!);

// 创建 Embedding 任务队列
const embeddingQueue = new Queue('embedding', { connection: redis });

// 文档更新时投递任务
async function onDocumentUpdated(docId: string): Promise<void> {
await embeddingQueue.add('embed', { docId }, {
removeOnComplete: 100, // 保留最近 100 条完成记录
removeOnFail: 50,
attempts: 3, // 失败重试 3 次
backoff: { type: 'exponential', delay: 1000 },
});
}

// Worker:消费队列,执行 Embedding
const worker = new Worker('embedding', async (job) => {
const { docId } = job.data;
const doc = await fetchDocument(docId);

if (!doc) {
console.log(`文档 ${docId} 不存在,跳过`);
return;
}

// 检查内容是否变更
const hash = computeHash(doc.content);
const existing = await getExistingHash(docId);
if (hash === existing) {
console.log(`文档 ${docId} 内容未变更,跳过`);
return;
}

// 分块 + Embedding + 写入
const chunks = recursiveChunk(doc.content, 500, 100);
const embeddings = await getEmbeddings(chunks);

await upsertChunksToVectorDB(docId, chunks, embeddings, doc.metadata);
await saveHash(docId, hash);

console.log(`文档 ${docId} 已更新,${chunks.length} 个分块`);
}, { connection: redis, concurrency: 5 });

// Webhook 端点:接收文档更新通知
// app.post('/webhook/document-updated', async (req, res) => {
// await onDocumentUpdated(req.body.documentId);
// res.status(200).send('OK');
// });

九、搜索质量评估

向量搜索的质量需要量化指标来衡量和持续优化。

核心评估指标

指标公式含义
Recall@K前K结果中的相关文档数全部相关文档数\frac{\text{前K结果中的相关文档数}}{\text{全部相关文档数}}在 Top-K 中找到了多少相关文档
Precision@K前K结果中的相关文档数K\frac{\text{前K结果中的相关文档数}}{K}Top-K 中有多少是真正相关的
MRR1Qi=1Q1ranki\frac{1}{\|Q\|} \sum_{i=1}^{\|Q\|} \frac{1}{\text{rank}_i}第一个相关结果的平均排名倒数
NDCG@K归一化折扣累积增益考虑排名位置的综合指标
lib/evaluation.ts
interface EvalQuery {
query: string;
relevantDocIds: string[]; // 人工标注的相关文档
}

interface SearchResult {
id: string;
score: number;
}

// Recall@K:在 Top-K 结果中找回了多少相关文档
function recallAtK(results: SearchResult[], relevant: string[], k: number): number {
const topKIds = new Set(results.slice(0, k).map(r => r.id));
const found = relevant.filter(id => topKIds.has(id)).length;
return found / relevant.length;
}

// MRR(Mean Reciprocal Rank)
function mrr(queries: EvalQuery[], searchFn: (q: string) => SearchResult[]): number {
let totalRR = 0;

for (const { query, relevantDocIds } of queries) {
const results = searchFn(query);
const relevantSet = new Set(relevantDocIds);

// 找到第一个相关结果的排名
const firstRelevantRank = results.findIndex(r => relevantSet.has(r.id)) + 1;
totalRR += firstRelevantRank > 0 ? 1 / firstRelevantRank : 0;
}

return totalRR / queries.length;
}

// 批量评估
async function evaluateSearchQuality(
evalSet: EvalQuery[],
searchFn: (query: string) => Promise<SearchResult[]>
): Promise<{
avgRecall5: number;
avgRecall10: number;
avgMRR: number;
}> {
let totalRecall5 = 0;
let totalRecall10 = 0;
let totalRR = 0;

for (const { query, relevantDocIds } of evalSet) {
const results = await searchFn(query);

totalRecall5 += recallAtK(results, relevantDocIds, 5);
totalRecall10 += recallAtK(results, relevantDocIds, 10);

const relevantSet = new Set(relevantDocIds);
const rank = results.findIndex(r => relevantSet.has(r.id)) + 1;
totalRR += rank > 0 ? 1 / rank : 0;
}

const n = evalSet.length;
return {
avgRecall5: totalRecall5 / n,
avgRecall10: totalRecall10 / n,
avgMRR: totalRR / n,
};
}
质量优化方向

当评估指标不理想时,按以下优先级排查:

  1. 分块策略(影响最大):chunk 太大或太小都会降低质量
  2. Embedding 模型:换更强的模型(如从 small 换到 large)
  3. Reranking:在向量召回后用 Cross-Encoder 精排
  4. 混合搜索:结合关键词搜索提升 Recall
  5. 索引参数:调大 HNSW 的 ef_search 提升精度

常见面试问题

Q1: 向量搜索和关键词搜索有什么区别?

答案

维度关键词搜索向量搜索
匹配方式精确字符串匹配语义相似度
搜 "React 状态" 找 "Vue 响应式"搜不到可以找到(语义相近)
同义词处理需手动配置同义词库自动理解语义
技术基础倒排索引(Elasticsearch)Embedding + ANN
搜索速度极快(毫秒级)稍慢(需向量计算)
数据要求无需预处理需要生成 Embedding
最佳实践两者结合的混合搜索

混合搜索公式score=αvectorScore+(1α)keywordScorescore = \alpha \cdot \text{vectorScore} + (1 - \alpha) \cdot \text{keywordScore},通常 α=0.7\alpha = 0.7(偏重语义)效果较好。

Q2: 对比余弦相似度、点积和欧氏距离

答案

三者是向量相似度的三种度量方式,数学本质不同但在特定条件下等价:

  • 余弦相似度:只看方向不看大小,值域 [1,1][-1, 1],最适合文本语义搜索
  • 点积:同时考虑方向和大小,向量已归一化时等价于余弦相似度,计算最快
  • 欧氏距离:空间中的直线距离,距离越小越相似,适合聚类场景

关键结论:大多数 Embedding 模型输出已归一化的向量,此时三者的排序结果完全一致。实践中默认选余弦相似度(语义最直观),性能敏感选点积。

Q3: HNSW 索引是什么?它如何工作?

答案

HNSW(Hierarchical Navigable Small World)是目前最主流的近似最近邻搜索(ANN)算法,被 pgvector、Qdrant、Weaviate 等广泛采用。

核心思想:构建多层图结构,类似跳表(Skip List)。顶层稀疏用于远距离跳转(快速定位大致区域),底层稠密用于精确搜索。

搜索过程

  1. 从最顶层的入口节点出发
  2. 在当前层中贪心搜索(每步走向最近邻)
  3. 到达当前层的局部最优后,降到下一层继续搜索
  4. 在底层(Layer 0,包含所有节点)返回 Top-K

关键参数

  • M:每个节点的最大连接数,越大精度越高但内存越大(推荐 16-64)
  • ef_construction:建索引时的搜索宽度,越大索引质量越好但建索引越慢(推荐 64-200)
  • ef_search:查询时的搜索宽度,越大查询越准但越慢(可运行时调整)

性能:搜索时间复杂度 O(logN)O(\log N),百万级数据下单次查询通常在 1-10 ms。

Q4: 如何选择 Embedding 模型的维度?

答案

维度是语义信息的容量,需要在精度、成本和速度之间权衡:

  1. 384 维(all-MiniLM-L6-v2):免费、极快,适合浏览器端或原型验证
  2. 768 维(BGE-base/M3E-base):中文效果好,可自托管
  3. 1536 维(text-embedding-3-small):通用性价比最优,OpenAI 推荐
  4. 3072 维(text-embedding-3-large):最高精度,适合法律/医疗等要求高的场景

实用技巧:OpenAI text-embedding-3 系列支持 Matryoshka Embedding,可以用 dimensions 参数生成 256/512/1024 等降维向量。从 1536 降至 512,存储减少 67%,检索准确率通常只下降 1-3%。

Q5: 如何实现增量向量索引更新?

答案

生产环境中避免每次全量重建索引的关键是基于内容哈希的变更检测

async function incrementalUpdate(docs: Document[]): Promise<void> {
for (const doc of docs) {
const hash = sha256(doc.content);
const existing = await db.getContentHash(doc.id);

if (existing === hash) continue; // 内容未变,跳过

const chunks = recursiveChunk(doc.content);
const embeddings = await getEmbeddings(chunks);
await db.upsertChunksWithHash(doc.id, chunks, embeddings, hash);
}
}

完整方案

  1. 文档变更时通过 Webhook / 消息队列 触发增量任务
  2. 用 SHA-256 哈希比对内容是否真正变更
  3. 只对变更文档重新分块和 Embedding
  4. BullMQ + Redis 实现异步队列处理
  5. 定期执行全量同步作为兜底,清理孤立数据

Q6: 对比 pgvector、Pinecone 和 Qdrant 的生产选型

答案

维度pgvectorPineconeQdrant
类型PostgreSQL 扩展全托管云服务开源 + 托管云
部署复用已有 PG零运维自托管/云
索引算法HNSW / IVFFlat专有引擎HNSW + 量化
最大规模千万级亿级亿级
混合搜索需手动实现原生 Sparse-Dense原生支持
过滤SQL WHEREMetadata FilterPayload Filter
价格免费(PG 成本)按用量($0.33/M 读)免费 / 按用量
生态Prisma/Drizzle/TypeORM官方 SDK官方 SDK + REST
适用已有 PG 基础设施快速上线、零运维高性能自托管

选型建议

  • 已有 PostgreSQL 且数据 < 1000 万 -> pgvector
  • 追求零运维、快速上线 -> Pinecone Serverless
  • 需要自托管、高性能 -> Qdrant
  • 超大规模(10 亿+) -> Milvus

Q7: 如何实现多模态搜索(以文搜图)?

答案

多模态搜索的核心是多模态 Embedding 模型(如 CLIP),它将文本和图片映射到同一个向量空间

实现步骤

  1. 选择多模态 Embedding 模型(如 Jina CLIP v2、OpenCLIP)
  2. 用 Image Encoder 对所有图片生成 Embedding,存入向量数据库
  3. 用户输入文本描述时,用 Text Encoder 生成 Embedding
  4. 用余弦相似度检索最匹配的图片向量

应用场景

  • 电商以文搜图("红色连衣裙 → 商品图")
  • 相册智能搜索("海边日落 → 匹配照片")
  • 内容审核(将违规描述与上传图片匹配)

注意文本和图片必须使用同一个模型的 Text Encoder 和 Image Encoder,否则不在同一个向量空间中。

Q8: 文档分块(Chunking)策略怎么选?

答案

分块是向量搜索质量的决定性因素,比 Embedding 模型的选择更重要。

策略适用场景注意事项
固定大小日志、结构化文本可能截断语义
递归字符(推荐)通用文档\n\n\n. 逐级分割
语义分块高质量知识库需调用 Embedding API,成本较高
按标题Markdown/HTML依赖文档格式规范

核心原则

  • chunk 大小推荐 300-800 字,overlap 50-200 字
  • 太小会碎片化(缺上下文),太大会引入噪声
  • overlap 确保跨 chunk 边界的信息不丢失
  • chunk 大小应与 Embedding 模型的 max_tokens 匹配

更详细的分块策略参考 RAG 检索增强生成

Q9: 如何评估向量搜索质量(Recall@K、MRR)?

答案

评估向量搜索质量需要人工标注的评测集和量化指标:

核心指标

  • Recall@K(召回率):前 K 个结果中找到了多少相关文档。Recall@10=0.8Recall@10 = 0.8 意味着 10 个结果中涵盖了 80% 的相关文档
  • MRR(Mean Reciprocal Rank):第一个相关结果的平均排名倒数。MRR = 0.5 意味着相关结果平均在第 2 位
  • Precision@K:Top-K 中有多少比例是相关的
  • NDCG@K:考虑排名位置权重的综合指标

评估流程

  1. 准备 50-200 个测试 query + 人工标注的相关文档
  2. 对每个 query 执行搜索,计算 Recall@5/10 和 MRR
  3. 目标:Recall@10 > 0.9,MRR > 0.7

优化顺序(影响从大到小):分块策略 → Embedding 模型 → Reranking → 混合搜索 → 索引参数调优

Q10: 什么是混合搜索(Hybrid Search)?如何实现?

答案

混合搜索结合向量搜索关键词搜索的优势:

  • 向量搜索负责语义理解(找到意思相近的内容)
  • 关键词搜索负责精确匹配(确保包含特定术语,如 API 名称、错误码)

实现方式

function hybridSearch(
queryEmbedding: number[],
keywords: string[],
documents: Document[],
alpha: number = 0.7 // 向量权重
): ScoredDocument[] {
return documents.map(doc => {
const vectorScore = cosineSimilarity(queryEmbedding, doc.embedding);
const keywordScore = keywords.reduce((s, kw) =>
s + (doc.text.includes(kw) ? 1 : 0), 0
) / keywords.length;

return { doc, score: alpha * vectorScore + (1 - alpha) * keywordScore };
}).sort((a, b) => b.score - a.score);
}

Pinecone 原生支持通过 Sparse-Dense 向量实现混合搜索,稀疏向量来自 BM25 算法(类似 Elasticsearch 的 TF-IDF),稠密向量来自 Embedding 模型。

Q11: 前端可以做向量搜索吗?

答案

可以,适合小数据集(< 10000 条)。将预计算的 Embedding 加载到前端,用余弦相似度搜索:

  • 优点:无需后端、离线可用、零延迟
  • 缺点:内存占用高(1536 维 x 10000 条约 60 MB)
  • 技术方案
    • 预计算 Embedding 存为 JSON 或 Float32Array
    • 使用 Transformers.js 在浏览器端生成 query Embedding(384 维模型,约 20 MB)
    • 暴力搜索即可(10000 条 384 维,搜索耗时 < 10 ms)

大数据集(> 10 万)必须使用后端向量数据库。

Q12: Embedding 缓存有哪些策略?

答案

Embedding 缓存的核心目标是避免重复调用 Embedding API,降低成本和延迟:

  1. 内容哈希缓存:对文本内容做 SHA-256,相同内容直接复用已有 Embedding
  2. TTL 缓存:为 Embedding 设置过期时间,适合时效性内容
  3. 增量更新:只对变更的文档重新生成 Embedding,通过 content_hash 判断
  4. 二级缓存:Redis(热数据) + 向量数据库(全量数据)

成本对比

  • OpenAI Embedding:$0.02/M tokens
  • 10 万文档(平均 500 tokens)= 5000 万 tokens = $1
  • 如果每天全量重建 = 每月 30;增量更新(日均530;增量更新(日均 5% 变更)= 每月 1.5

相关链接