使用 AI SDK 创建 「知识库」
今天分享一个用纯 Node.js 实现知识库(RAG)的最简方案。
RAG 的核心思路其实并不复杂:
- 对文档进行内容分割,将文档拆解成一个个小的语义分块(chunk);
- 将这些分块通过大模型解析成向量(embedding)并储存在向量数据库中;
- 当用户输入一个查询时,同样将查询进行向量化处理,通过向量数据库检索高度相关的知识片段;
- 最后将这些片段整合到发送给大模型的上下文中,提升回答的精准度和相关性。
开始
技术栈:
安装依赖
npm install @libsql/client @ai-sdk/openai-compatible ai dotenv
添加环境变量
先在项目根目录创建 .env:
AIPROXY_API_KEY=your_api_key_here
初始化
先把最基础的依赖准备好,包括模型客户端、本地数据库客户端,以及一组演示知识。
import 'dotenv/config'
import { createClient } from '@libsql/client/sqlite3'
import { createOpenAICompatible } from '@ai-sdk/openai-compatible'
import { embed, generateText } from 'ai'
const aiproxy = createOpenAICompatible({
baseURL: 'https://api.aiproxy.shop/v1',
apiKey: process.env.AIPROXY_API_KEY!,
name: 'aiproxy',
})
const db = createClient({
url: 'file:local.db',
})
const knowledgeDocuments = [
{
title: 'AI SDK 是什么',
content:
'AI SDK 是一个帮助 TS 和 JS 开发者快速接入大模型的工具包,支持流式响应、工具调用和多模型适配。',
},
{
title: 'RAG 的核心流程',
content:
'RAG 的核心流程是切分文档、生成向量、保存向量、查询时把问题也转成向量、最后检索最相近的内容作为上下文。',
},
{
title: '为什么要做分块',
content:
'因为整篇文档太长会影响检索精度,所以通常要先按语义切成多个 chunk,再分别生成 embedding。',
},
]
type StoredChunk = {
id: number
title: string
content: string
distance: number
}
文档分块
RAG 不会直接拿整篇文档做检索,而是将文档拆分成很多小的文本块。
const splitIntoChunks = (text: string, size = 200) => {
const normalized = text.replace(/\s+/g, ' ').trim()
if (!normalized) {
return []
}
const chunks: string[] = []
for (let index = 0; index < normalized.length; index += size) {
chunks.push(normalized.slice(index, index + size))
}
return chunks
}
如何生成 chunk 的内容?
最简单的办法就是直接按字符切分。比如上面 size = 200,就表示每 200 个字符切成一个块。或者可以通过标点符号进行切分,也可以用一些第三方库来进行智能分割。
生成 Embedding
这里会在两个阶段使用 embedding:
- 入库时把每个 chunk 转成向量并存储。
- 查询时把用户问题也转成向量,用于检索最相关的上下文。
const createEmbedding = async (value: string) => {
const result = await embed({
model: aiproxy.embeddingModel('openai/text-embedding-3-small'),
value,
})
return result.embedding
}
初始化知识库表
现在要把知识片段真正保存到本地数据库中。
const initializeDatabase = async () => {
await db.execute(`
CREATE TABLE IF NOT EXISTS documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
embedding BLOB NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`)
}
这里的设计也比较直接:
-
documents表存标题、文本内容和 embedding。 - embedding 用
BLOB存储,方便直接使用 libSQL 的向量函数检索。
这不是最终架构里性能最高的方案,但它非常利于你先理解“存储 + 检索”的闭环。
知识库入库
有了表结构之后,我们还需要把知识内容写入数据库。
const seedKnowledgeBase = async () => {
await initializeDatabase()
const countResult = await db.execute('SELECT COUNT(*) AS count FROM documents')
const count = Number(countResult.rows[0]?.count ?? 0)
if (count > 0) {
return
}
for (const document of knowledgeDocuments) {
const chunks = splitIntoChunks(document.content)
for (const chunk of chunks) {
const embedding = await createEmbedding(chunk)
const embeddingBuffer = Buffer.from(new Float32Array(embedding).buffer)
await db.execute({
sql: 'INSERT INTO documents (title, content, embedding) VALUES (?, ?, ?)',
args: [document.title, chunk, embeddingBuffer],
})
}
}
}
入库流程如下:
- 如果表里已经有数据,就不重复写入;
- 把每篇文档切成多个 chunk;
- 给每个 chunk 生成 embedding;
- 把 chunk 和向量一起存进数据库。
检索相关片段
当用户提问时,我们先把问题转成向量,然后直接在数据库里做 Top-K 检索。
const searchKnowledge = async (query: string, limit = 3) => {
const userQueryEmbedded = await createEmbedding(query)
const queryBuffer = Buffer.from(new Float32Array(userQueryEmbedded).buffer)
const rs = await db.execute({
sql: `
SELECT
id,
title,
content,
vector_distance_cos(embedding, vector32(?)) AS distance
FROM documents
ORDER BY distance ASC
LIMIT ?
`,
args: [queryBuffer, limit],
})
return rs.rows.map((row) => ({
id: Number(row.id),
title: String(row.title),
content: String(row.content),
distance: Number(row.distance),
}))
}
查询流程如下:
- 先把用户问题转成 embedding。
- 用
vector32(?)把查询向量传给数据库。 - 数据库内部用
vector_distance_cos计算距离并排序,拿到 Top-K 结果。
封装主函数
最后,把上面所有逻辑串起来。
export const chatWithKnowledge = async (question: string) => {
await seedKnowledgeBase()
const results = await searchKnowledge(question)
const contextText = results.map((item) => `- ${item.title}: ${item.content}`).join('\n')
const result = await generateText({
model: aiproxy('deepseek/deepseek-chat'),
system: `你是一个有用的助手。请优先根据知识库内容回答问题;如果知识库里没有相关信息,就明确告诉用户你不知道。\n\n知识库上下文:\n${contextText}`,
prompt: question,
})
return {
answer: result.text,
references: results,
}
}
整个请求链路到这里就闭环了:
- 函数执行时,先确保知识库已经初始化;
- 把用户问题转成向量;
- 在数据库层执行向量检索,拿到最相关片段;
- 把这些片段加入
system prompt,作为上下文或者参考信息; - 调用主模型生成最终回答;
- 返回最终答案和命中的参考片段。
把上面所有代码拼到一起,就是一个完整的单文件 RAG Demo。
在文件末尾加上入口调用,直接运行即可:
const result = await chatWithKnowledge('RAG 为什么要分块?')
console.log('回答:', result.answer)
console.log('参考片段:', result.references)
知识库文档处理
在上面的示例里,我直接在代码里写了一个 knowledgeDocuments 数组来模拟知识库内容:
const knowledgeDocuments = [
{
title: '你的第一篇文档',
content: '这里放你自己的知识内容',
},
]
如果你的内容来自 Markdown、数据库或者 CMS,也可以先把内容读出来,再复用同样的 splitIntoChunks -> createEmbedding -> insert 流程。如果你的文档是 PDF 或图片,也可以先用 OCR 或者 pdf.js 把它们转成文本,再进行后续处理。
运行
npx tsx rag.ts
结语
复杂的应用是一个个小的应用组合起来的,学会每个小的知识点,就能够构建非常牛x的大型项目。
大家可以看一下我实现的 知识库的 NPM CLI 工具 - Meow ,欢迎 star 🌟。