向量搜索与 Embedding
问题
什么是向量搜索(Vector Search)?Embedding 模型如何工作?不同相似度算法和向量索引有什么区别?前端如何实现语义化搜索?生产环境中如何设计高效的向量搜索架构?
答案
向量搜索是通过数学相似度而非关键词匹配来检索内容的技术。核心思路是将文本(或图片、音频等)转化为高维向量(Embedding),语义相似的内容在向量空间中距离更近,从而实现"搜索意思而非字面"的能力。向量搜索是 RAG 检索增强生成、语义缓存(详见 AI 应用性能优化)等 AI 应用的核心基础设施。
一、Embedding 原理与模型选型
1.1 Embedding 如何工作
Embedding 模型的本质是一个编码器(Encoder),它将任意长度的文本压缩为一个固定维度的数值向量。这个过程是通过Transformer 架构中的 Encoder 部分实现的:
Embedding 模型只有 Encoder 部分(如 BERT 架构),不像 GPT 那样有 Decoder 做逐 token 生成。它将一段文本的整体语义压缩到一个向量中。因此 Embedding 模型通常比生成模型小得多(几百 MB 级别),推理也快得多。
1.2 向量维度权衡
维度是 Embedding 向量的信息容量。维度越高,能编码的语义细节越丰富,但存储、计算和检索成本也越高。
| 维度 | 代表模型 | 单向量存储 | 万条数据存储 | 搜索速度 | 语义精度 |
|---|---|---|---|---|---|
| 384 | all-MiniLM-L6-v2 | 1.5 KB | ~15 MB | 最快 | 适合简单场景 |
| 768 | BGE-base、M3E-base | 3 KB | ~30 MB | 快 | 中文效果好 |
| 1024 | Cohere embed-v3 | 4 KB | ~40 MB | 较快 | 多语言优秀 |
| 1536 | text-embedding-3-small | 6 KB | ~60 MB | 中等 | 通用性强 |
| 3072 | text-embedding-3-large | 12 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 维仍保留了大部分语义信息(精度损失极小):
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 高度一致,相似度数值略有差异
}
}
- 只有
text-embedding-3-*系列支持此功能,旧模型(如text-embedding-ada-002)不支持 - 降维后的向量需要重新归一化(OpenAI API 已自动处理)
- 从 1536 降至 512 维,检索准确率通常只下降 1-3%,但存储减少 67%
1.4 Embedding 模型对比
| 模型 | 提供方 | 维度 | 最大 Token | 价格 | 中文支持 | 特点 |
|---|---|---|---|---|---|---|
| text-embedding-3-small | OpenAI | 1536 | 8191 | $0.02/M tokens | 良好 | 性价比最优,支持 Matryoshka |
| text-embedding-3-large | OpenAI | 3072 | 8191 | $0.13/M tokens | 良好 | 最高精度,支持 Matryoshka |
| embed-v4 | Cohere | 1024 | 128K | $0.10/M tokens | 优秀 | 超长上下文,多语言,int8 量化 |
| voyage-3 | Voyage AI | 1024 | 32K | $0.06/M tokens | 优秀 | 长文本,代码搜索 |
| BGE-large-zh-v1.5 | BAAI(智源) | 1024 | 512 | 免费开源 | 最佳 | 中文 SOTA,可本地部署 |
| M3E-base | Moka | 768 | 512 | 免费开源 | 优秀 | 中文优化,轻量 |
| all-MiniLM-L6-v2 | Sentence Transformers | 384 | 256 | 免费开源 | 一般 | 极轻量,适合浏览器端 |
| jina-embeddings-v3 | Jina AI | 1024 | 8192 | $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)
衡量两个向量方向的一致性,忽略大小(模长),值域 。
// 余弦相似度:最常用,对向量长度不敏感
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)
同时考虑方向和大小,值域 。当向量已归一化(L2 Norm = 1)时,点积 = 余弦相似度。
// 点积:向量已归一化时等价于余弦相似度,计算更快
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)
衡量两个向量在空间中的直线距离,值域 。距离越小越相似。
// 欧氏距离:值越小越相似
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 越相似 | 越大越相似 | 越小越相似 |
| 是否归一化 | 自动归一化 | 需要归一化才等价于余弦 | 不需要 |
| 计算复杂度 | (含归一化) | ||
| 对向量长度敏感 | 否 | 是 | 是 |
| 适用场景 | 文本语义搜索(最常用) | 已归一化的向量(性能优先) | 推荐系统、聚类 |
大多数 Embedding 模型输出已归一化的向量(如 OpenAI、Cohere),此时三种算法的排序结果完全一致。实践中推荐:
- 默认选余弦相似度:语义最直观,不依赖归一化
- 性能优先选点积:省去归一化计算,pgvector/Pinecone 默认使用
- 聚类任务选欧氏距离:K-Means 等算法基于欧氏距离
三、向量索引算法
当数据量达到百万甚至亿级时,暴力搜索(逐一比较)不可行。向量索引算法通过近似最近邻搜索(ANN, Approximate Nearest Neighbor) 在准确率和速度之间取得平衡。
3.1 暴力搜索(Brute Force / Flat)
遍历所有向量,计算与查询向量的距离,返回 Top-K。
- 时间复杂度:(N 为向量数,d 为维度)
- 准确率:100%(精确搜索)
- 适用:数据量 < 10 万
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)。查询时只在最近的几个聚类中搜索。
- 时间复杂度:
- 关键参数:
nlist(聚类数),nprobe(查询时搜索的聚类数) - 适用:百万级数据
3.3 HNSW(Hierarchical Navigable Small World)
目前最流行的 ANN 算法,构建多层跳表式图结构。每一层是一个"小世界"图,顶层稀疏(远程跳转),底层稠密(精确搜索)。
搜索过程:
- 从最顶层的入口节点开始
- 在当前层中贪心搜索最近邻
- 到达当前层的局部最优后,下降到下一层
- 重复步骤 2-3 直到底层(Layer 0)
- 在底层返回 Top-K 结果
- 时间复杂度:
- 关键参数:
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(暴力) | 低 | 100% | 无 | < 10 万 | |
| IVF-Flat | 低 | 95-99% | 中 | 10 万 ~ 百万 | |
| HNSW | 高(需存图结构) | 97-99.5% | 长 | 百万 ~ 千万 | |
| IVF-PQ | 极低(96% 压缩) | 90-95% | 中 | 千万 ~ 亿 | |
| HNSW + PQ | 中 | 93-97% | 长 | 千万 ~ 亿 |
四、向量数据库实战
4.1 pgvector(PostgreSQL 扩展)
对已有 PostgreSQL 基础设施的项目,pgvector 是最无缝的选择——不需要额外部署向量数据库。
-- 启用 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;
| 操作符 | 含义 | 索引类型 |
|---|---|---|
<=> | 余弦距离 | 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 集成示例:
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 是全托管服务,无需运维,适合快速上线。
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,
})) ?? [];
}
| 维度 | Serverless(推荐) | Pod-based |
|---|---|---|
| 计费 | 按读写 + 存储量 | 按 Pod 数量(固定月费) |
| 扩缩容 | 自动 | 手动配置 |
| 冷启动 | 有(首次查询稍慢) | 无 |
| 适用 | 大多数场景 | 低延迟要求、稳定流量 |
| 成本 | 低流量时极低 | 最低约 $70/月 |
4.3 向量数据库生产选型
| 数据库 | 类型 | 索引算法 | 最大规模 | 混合搜索 | 价格 | 适用场景 |
|---|---|---|---|---|---|---|
| pgvector | PG 扩展 | HNSW/IVF | 千万级 | 需手动实现 | 免费 | 已有 PG、中小规模 |
| Pinecone | 云托管 | 专有引擎 | 亿级 | 原生支持 | 按用量 | 快速上线、零运维 |
| Qdrant | 开源/云 | HNSW | 亿级 | 原生支持 | 免费/按用量 | 高性能、自托管生产 |
| Weaviate | 开源/云 | HNSW | 亿级 | 原生支持 | 免费/按用量 | GraphQL API、多模态 |
| Milvus | 开源 | IVF/HNSW/PQ | 百亿级 | 原生支持 | 免费 | 超大规模、分布式 |
| Chroma | 开源 | HNSW | 百万级 | 有限 | 免费 | 原型开发、本地 |
五、文档分块策略(Chunking)
分块是向量搜索质量的决定性因素——分块粒度直接影响检索的精度和召回率。更详细的分块策略可参考 RAG 检索增强生成 中的文档预处理部分。
5.1 分块方法对比
// ---- 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 以文搜图实现
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);
// → 返回最匹配的图片(即使图片没有任何文字标签)
- 电商图搜:用户描述"红色连衣裙"搜索商品图
- 图片管理:用自然语言搜索相册中的照片
- 内容审核:将敏感文本描述与上传图片做相似度匹配
- RAG 增强:将文档中的图表也纳入检索范围
七、Embedding 缓存与增量更新
在生产环境中,避免对未变更的文档重复调用 Embedding API 是成本控制的关键。
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;
}
}
八、生产向量搜索架构
一个完整的生产级向量搜索系统需要考虑索引流水线、异步处理、监控告警等工程问题。
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 | 在 Top-K 中找到了多少相关文档 | |
| Precision@K | Top-K 中有多少是真正相关的 | |
| MRR | 第一个相关结果的平均排名倒数 | |
| NDCG@K | 归一化折扣累积增益 | 考虑排名位置的综合指标 |
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,
};
}
当评估指标不理想时,按以下优先级排查:
- 分块策略(影响最大):chunk 太大或太小都会降低质量
- Embedding 模型:换更强的模型(如从 small 换到 large)
- Reranking:在向量召回后用 Cross-Encoder 精排
- 混合搜索:结合关键词搜索提升 Recall
- 索引参数:调大 HNSW 的
ef_search提升精度
常见面试问题
Q1: 向量搜索和关键词搜索有什么区别?
答案:
| 维度 | 关键词搜索 | 向量搜索 |
|---|---|---|
| 匹配方式 | 精确字符串匹配 | 语义相似度 |
| 搜 "React 状态" 找 "Vue 响应式" | 搜不到 | 可以找到(语义相近) |
| 同义词处理 | 需手动配置同义词库 | 自动理解语义 |
| 技术基础 | 倒排索引(Elasticsearch) | Embedding + ANN |
| 搜索速度 | 极快(毫秒级) | 稍慢(需向量计算) |
| 数据要求 | 无需预处理 | 需要生成 Embedding |
| 最佳实践 | 两者结合的混合搜索 |
混合搜索公式:,通常 (偏重语义)效果较好。
Q2: 对比余弦相似度、点积和欧氏距离
答案:
三者是向量相似度的三种度量方式,数学本质不同但在特定条件下等价:
- 余弦相似度:只看方向不看大小,值域 ,最适合文本语义搜索
- 点积:同时考虑方向和大小,向量已归一化时等价于余弦相似度,计算最快
- 欧氏距离:空间中的直线距离,距离越小越相似,适合聚类场景
关键结论:大多数 Embedding 模型输出已归一化的向量,此时三者的排序结果完全一致。实践中默认选余弦相似度(语义最直观),性能敏感选点积。
Q3: HNSW 索引是什么?它如何工作?
答案:
HNSW(Hierarchical Navigable Small World)是目前最主流的近似最近邻搜索(ANN)算法,被 pgvector、Qdrant、Weaviate 等广泛采用。
核心思想:构建多层图结构,类似跳表(Skip List)。顶层稀疏用于远距离跳转(快速定位大致区域),底层稠密用于精确搜索。
搜索过程:
- 从最顶层的入口节点出发
- 在当前层中贪心搜索(每步走向最近邻)
- 到达当前层的局部最优后,降到下一层继续搜索
- 在底层(Layer 0,包含所有节点)返回 Top-K
关键参数:
M:每个节点的最大连接数,越大精度越高但内存越大(推荐 16-64)ef_construction:建索引时的搜索宽度,越大索引质量越好但建索引越慢(推荐 64-200)ef_search:查询时的搜索宽度,越大查询越准但越慢(可运行时调整)
性能:搜索时间复杂度 ,百万级数据下单次查询通常在 1-10 ms。
Q4: 如何选择 Embedding 模型的维度?
答案:
维度是语义信息的容量,需要在精度、成本和速度之间权衡:
- 384 维(all-MiniLM-L6-v2):免费、极快,适合浏览器端或原型验证
- 768 维(BGE-base/M3E-base):中文效果好,可自托管
- 1536 维(text-embedding-3-small):通用性价比最优,OpenAI 推荐
- 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);
}
}
完整方案:
- 文档变更时通过 Webhook / 消息队列 触发增量任务
- 用 SHA-256 哈希比对内容是否真正变更
- 只对变更文档重新分块和 Embedding
- 用 BullMQ + Redis 实现异步队列处理
- 定期执行全量同步作为兜底,清理孤立数据
Q6: 对比 pgvector、Pinecone 和 Qdrant 的生产选型
答案:
| 维度 | pgvector | Pinecone | Qdrant |
|---|---|---|---|
| 类型 | PostgreSQL 扩展 | 全托管云服务 | 开源 + 托管云 |
| 部署 | 复用已有 PG | 零运维 | 自托管/云 |
| 索引算法 | HNSW / IVFFlat | 专有引擎 | HNSW + 量化 |
| 最大规模 | 千万级 | 亿级 | 亿级 |
| 混合搜索 | 需手动实现 | 原生 Sparse-Dense | 原生支持 |
| 过滤 | SQL WHERE | Metadata Filter | Payload Filter |
| 价格 | 免费(PG 成本) | 按用量($0.33/M 读) | 免费 / 按用量 |
| 生态 | Prisma/Drizzle/TypeORM | 官方 SDK | 官方 SDK + REST |
| 适用 | 已有 PG 基础设施 | 快速上线、零运维 | 高性能自托管 |
选型建议:
- 已有 PostgreSQL 且数据 < 1000 万 -> pgvector
- 追求零运维、快速上线 -> Pinecone Serverless
- 需要自托管、高性能 -> Qdrant
- 超大规模(10 亿+) -> Milvus
Q7: 如何实现多模态搜索(以文搜图)?
答案:
多模态搜索的核心是多模态 Embedding 模型(如 CLIP),它将文本和图片映射到同一个向量空间。
实现步骤:
- 选择多模态 Embedding 模型(如 Jina CLIP v2、OpenCLIP)
- 用 Image Encoder 对所有图片生成 Embedding,存入向量数据库
- 用户输入文本描述时,用 Text Encoder 生成 Embedding
- 用余弦相似度检索最匹配的图片向量
应用场景:
- 电商以文搜图("红色连衣裙 → 商品图")
- 相册智能搜索("海边日落 → 匹配照片")
- 内容审核(将违规描述与上传图片匹配)
注意文本和图片必须使用同一个模型的 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 个结果中找到了多少相关文档。 意味着 10 个结果中涵盖了 80% 的相关文档
- MRR(Mean Reciprocal Rank):第一个相关结果的平均排名倒数。MRR = 0.5 意味着相关结果平均在第 2 位
- Precision@K:Top-K 中有多少比例是相关的
- NDCG@K:考虑排名位置权重的综合指标
评估流程:
- 准备 50-200 个测试 query + 人工标注的相关文档
- 对每个 query 执行搜索,计算 Recall@5/10 和 MRR
- 目标: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,降低成本和延迟:
- 内容哈希缓存:对文本内容做 SHA-256,相同内容直接复用已有 Embedding
- TTL 缓存:为 Embedding 设置过期时间,适合时效性内容
- 增量更新:只对变更的文档重新生成 Embedding,通过 content_hash 判断
- 二级缓存:Redis(热数据) + 向量数据库(全量数据)
成本对比:
- OpenAI Embedding:$0.02/M tokens
- 10 万文档(平均 500 tokens)= 5000 万 tokens = $1
- 如果每天全量重建 = 每月 1.5
相关链接
- OpenAI Embeddings 文档
- Pinecone 官方文档
- pgvector GitHub
- HNSW 论文
- Matryoshka Representation Learning
- CLIP 论文
- RAG 检索增强生成 - 向量搜索在 RAG 中的应用
- AI 应用性能优化 - 语义缓存与成本控制
- 前端接入大模型 API - Embedding API 的调用方式