AI 电子伴侣
创建时间: 2026-04-18最后更新: 2026-04-18

1. 为什么需要向量数据库

先看一个场景:你想做一个「公司内部知识库问答」,让 AI 根据公司文档回答问题。

大模型本身不知道你公司的文档内容,你有两个选择:

  1. 每次问问题时,把全部文档塞进 prompt(价格爆炸、上下文塞不下)
  2. 先找出和问题最相关的那几段文档,只把这几段塞进 prompt

第二种就是 RAG(Retrieval-Augmented Generation,检索增强生成)。而「找出最相关的几段」靠的就是向量相似度检索——这就是为什么需要向量数据库。

这一篇的目标:用 Cloudflare Vectorize + Workers AI 搭一个最小可用的 RAG 系统。

1.1 Embedding 是什么

把一段文字喂给 embedding 模型,它会返回一个固定长度的浮点数数组(比如 768 个数)。你可以把这个数组想象成文字在一个高维空间里的坐标——意思相近的文字,坐标距离也近

code.ts
1
"今天天气真好" → [0.12, -0.34, 0.88, ..., 0.05] (768 个数)
2
"阳光很棒" → [0.11, -0.32, 0.85, ..., 0.07] (和上面距离很近)
3
"今天股票跌了" → [-0.45, 0.67, -0.12, ..., 0.33] (和上面距离很远)

检索的时候,把用户问题也转成向量,在数据库里找「距离最近」的那几条就行。

2. Vectorize:Cloudflare 自己的向量数据库

数值
最大维度1536
每个索引最多向量数10,000,000
每个向量 metadata10 KiB
账号索引数上限免费版 100 / 付费版 50,000
最大相似度返回数100(带 metadata 时 50)

免费版 100 个索引、每个索引最多 1000 万条向量,个人项目和中小应用一般够用。

3. 创建索引

用 wrangler 命令行创建:

terminal
1
# 用 cosine 相似度,768 维(对应 bge-base-en-v1.5)
2
npx wrangler vectorize create docs-index \
3
--dimensions=768 \
4
--metric=cosine

几个概念需要搞清楚:

  • dimensions:向量维度,必须和你用的 embedding 模型对得上。bge-base-en-v1.5 是 768,OpenAI text-embedding-3-small 是 1536
  • metric:相似度算法,cosine(余弦)/euclidean(欧氏距离)/dot-product(点积)。文本场景几乎都用 cosine

wrangler.jsonc 里绑定:

wrangler.jsonc
01
{
02
"vectorize": [
03
{
04
"binding": "DOCS_INDEX",
05
"index_name": "docs-index"
06
}
07
],
08
"ai": {
09
"binding": "AI"
10
}
11
}

我们把 Workers AI 也挂上,因为等一下要用它生成 embedding。

4. 最小可用的写入 + 查询

先看最朴素的用法。Hono 里的类型声明:

src/types.ts
1
export type Bindings = {
2
DOCS_INDEX: Vectorize // 和 Ai 一样,Cloudflare Workers 内置的全局类型
3
AI: Ai
4
}
5
6
export type AppEnv = {
7
Bindings: Bindings
8
}

4.1 生成 embedding 并写入

src/routes/ingest.ts
01
import { Hono } from 'hono'
02
import type { AppEnv } from '../types'
03
04
const ingest = new Hono<AppEnv>()
05
06
ingest.post('/ingest', async (c) => {
07
const { docs } = await c.req.json<{
08
docs: Array<{ id: string; text: string; source?: string }>
09
}>()
10
11
// 1. 批量生成 embedding(Workers AI 一次最多 100 段文本)
12
// 返回 { data: [向量1, 向量2, ...] },每个向量是 float[]
13
const texts = docs.map((d) => d.text)
14
const { data: embeddings } = await c.env.AI.run('@cf/baai/bge-base-en-v1.5', {
15
text: texts,
16
})
17
18
// 2. 组装成 Vectorize 的格式
19
const vectors = docs.map((doc, i) => ({
20
id: doc.id,
21
values: embeddings[i],
22
metadata: {
23
text: doc.text,
24
source: doc.source || 'unknown',
25
insertedAt: Date.now(),
26
},
27
}))
28
29
// 3. 一次批量写入
30
const result = await c.env.DOCS_INDEX.upsert(vectors)
31
32
return c.json({
33
ingested: vectors.length,
34
mutationId: result.mutationId,
35
})
36
})
37
38
export default ingest

三步:文本 → 向量 → 写库。注意这里用了 upsert 而不是 insert——前者在 id 已存在时会覆盖,后者会报错。日常更新文档场景 upsert 更顺手。

mutationId 是 Vectorize 的异步写入凭证,真正可查通常要几秒。对于实时性要求高的场景要注意这一点。

4.2 按相似度查询

src/routes/search.ts
01
import { Hono } from 'hono'
02
import type { AppEnv } from '../types'
03
04
const search = new Hono<AppEnv>()
05
06
search.post('/search', async (c) => {
07
const { query, topK = 5 } = await c.req.json<{
08
query: string
09
topK?: number
10
}>()
11
12
// 1. 把问题也转成向量
13
const { data: queryEmbeddings } = await c.env.AI.run(
14
'@cf/baai/bge-base-en-v1.5',
15
{ text: [query] }
16
)
17
const queryVector = queryEmbeddings[0]
18
19
// 2. 查最相似的 topK 条
20
const result = await c.env.DOCS_INDEX.query(queryVector, {
21
topK,
22
returnMetadata: 'all',
23
})
24
25
return c.json({
26
matches: result.matches.map((m) => ({
27
id: m.id,
28
score: m.score, // 相似度分数,0~1 之间
29
text: m.metadata?.text,
30
source: m.metadata?.source,
31
})),
32
})
33
})
34
35
export default search

query() 的选项里:

  • topK:返回多少条。带 metadata 时最多 50 条
  • returnMetadata: 'all':把写入时挂的 metadata 也带回来。不设的话结果只有 id 和 score
  • returnValues: true:把向量本身也返回(一般不需要,浪费带宽)

5. 组合成一个 RAG 接口

把「检索 + 生成」串起来,就是最小的 RAG。

src/routes/rag.ts
01
import { Hono } from 'hono'
02
import { streamSSE } from 'hono/streaming'
03
import type { AppEnv } from '../types'
04
05
const rag = new Hono<AppEnv>()
06
07
rag.post('/rag', async (c) => {
08
const { question } = await c.req.json<{ question: string }>()
09
10
// 1. 检索相关文档
11
// 只传了一段文本,所以 data 里只有一个向量,直接解构出来
12
const { data: [queryVec] } = await c.env.AI.run(
13
'@cf/baai/bge-base-en-v1.5',
14
{ text: [question] }
15
)
16
17
const { matches } = await c.env.DOCS_INDEX.query(queryVec, {
18
topK: 3,
19
returnMetadata: 'all',
20
})
21
22
// 2. 把检索到的片段拼进 prompt
23
const context = matches
24
.map((m, i) => `[${i + 1}] ${m.metadata?.text}`)
25
.join('\n\n')
26
27
const systemPrompt = `你是一个根据提供的上下文回答问题的助手。
28
只使用下面的上下文回答。如果上下文里没有答案,就回复"根据现有资料无法回答"。
29
30
上下文:
31
${context}`
32
33
// 3. 调用大模型流式生成
34
return streamSSE(c, async (stream) => {
35
const llmStream = await c.env.AI.run(
36
'@cf/meta/llama-3.1-8b-instruct',
37
{
38
messages: [
39
{ role: 'system', content: systemPrompt },
40
{ role: 'user', content: question },
41
],
42
stream: true,
43
}
44
)
45
46
const reader = (llmStream as ReadableStream).getReader()
47
const decoder = new TextDecoder()
48
49
while (true) {
50
const { done, value } = await reader.read()
51
if (done) break
52
await stream.writeSSE({
53
data: decoder.decode(value, { stream: true }),
54
event: 'delta',
55
})
56
}
57
58
// 顺便把引用来源也发给前端,方便展示
59
await stream.writeSSE({
60
data: JSON.stringify(matches.map((m) => ({ id: m.id, score: m.score }))),
61
event: 'sources',
62
})
63
})
64
})
65
66
export default rag

整个流程就是标准的 RAG:问题向量化 → 检索 top-K → 拼 prompt → 流式生成,最后把引用来源也透给前端,方便展示"回答依据"。

6. Metadata 过滤

真实场景里你不会只对「全库」做检索,通常要按部门、按时间、按用户做过滤。Vectorize 支持索引 metadata 字段然后在查询时做过滤。

6.1 创建 metadata 索引

terminal
1
# 允许按 source 字段过滤
2
npx wrangler vectorize create-metadata-index docs-index \
3
--property-name=source \
4
--type=string

每个 Vectorize 索引最多 10 个 metadata index。字段类型支持 string / number / boolean

6.2 带过滤的查询

src/routes/search.ts
1
const result = await c.env.DOCS_INDEX.query(queryVector, {
2
topK: 5,
3
returnMetadata: 'all',
4
filter: {
5
source: { $eq: 'internal-wiki' },
6
},
7
})

过滤语法用 $eq(等于)、$ne(不等于)、$in(在列表中)、$gt(大于)、$lt(小于)这种操作符写法,多字段可以组合使用。

对多租户应用,最实用的用法是按 tenantId 过滤——一个索引承载所有租户的数据,查询时自动隔离:

src/routes/search.ts
1
const result = await c.env.DOCS_INDEX.query(queryVector, {
2
topK: 5,
3
returnMetadata: 'all',
4
filter: {
5
tenantId: { $eq: c.get('user').tenantId },
6
},
7
})

7. Namespace:更重的隔离

如果数据之间物理隔离比「加条 metadata 过滤」更安全,用 namespace。每条向量写入时指定一个 namespace 字符串,查询时也带上同一个 namespace,彼此完全不可见:

src/routes/search.ts
1
// 写入时
2
await c.env.DOCS_INDEX.upsert(vectors.map(v => ({ ...v, namespace: 'tenant-42' })))
3
4
// 查询时
5
const result = await c.env.DOCS_INDEX.query(queryVec, {
6
namespace: 'tenant-42',
7
topK: 5,
8
})

Namespace 和 metadata 过滤的区别:

  • metadata 过滤 是查询时的软过滤,写入时不隔离,只是查询时按字段筛
  • namespace 是物理隔离,不同 namespace 之间完全不能查到对方的数据

多租户 SaaS 通常用 namespace 做隔离,比 metadata 过滤更安全——即使代码 bug 忘了加过滤条件,不同 namespace 的数据也不会串。

8. 选型和成本

8.1 Embedding 模型选型

模型维度用法
@cf/baai/bge-small-en-v1.5384轻量、便宜、英文为主
@cf/baai/bge-base-en-v1.5768通用首选,性价比最高
@cf/baai/bge-large-en-v1.51024精度要求高的场景
@cf/google/embeddinggemma-300m768Google 的新选择,多语言好
text-embedding-3-small(OpenAI)1536多语言通用、质量高,但要走第三方、花钱

要处理中文内容bge-m3(社区版)或 text-embedding-3-large 更稳。Workers AI 自带的 bge 系列对中文也凑合,但不如中文专用模型。

8.2 分块策略

前面写入数据时用的都是短文本,但实际场景里你的文档可能几千上万字。一整篇文档直接算一个向量不行——太长的文本做 embedding,语义会被"平均化",检索精度很差。所以要先把文档切成小段(chunk),每段单独算向量。

一个实用的做法:

  • 按 800~1200 字符切片
  • 切片之间留 100~200 字符重叠(防止关键信息被切断)
  • 一个切片一个向量,metadata 里放原文、所属文档 ID、chunk 索引

太大的切片会稀释语义,太小的切片会丢失上下文。800~1200 是经验值,不是铁律。

9. 小结

这一篇用到的东西:Vectorize 存向量、Workers AI 生成 embedding 和回答、Hono 把检索和生成串起来、namespace 和 metadata 过滤做数据隔离。

向量数据库本身不复杂——写入时把文本转成向量存进去,查询时把问题转成向量找最近的。搞定写入、查询、过滤这三件事,RAG 的核心就跑通了。剩下的是工程细节:分块策略、模型选型、缓存优化,这些根据实际效果调整就行。