时间:2026/08/04 10:43
在开始构建之前,我们需要先了解一个概念:RAG1。
需要明确的是,RAG是一种概念(或者说范式),而不是某一项具体的技术。用一句话概括它的工作流程就是:
用户输入 -> 从知识库检索相关的知识 -> 把检索到的知识交给
Agent-> 让Agent整理后再回答。
所以,我们要做的核心工作只有两件:搭建知识库,以及从知识库中检索出相关知识给Agent使用。
我们会用到以下技术,因此这些是需要提前掌握的前置知识:
orm库在通过drizzle-kit初始化的schema.ts文件中,定义需要的表,结构如下:
export const documentsTable = pgTable('documents', {
// 知识片段唯一 ID
id: integer().primaryKey().generatedAlwaysAsIdentity(),
// 知识来源类型
// 用于区分作者资料、文章、日记、项目等内容
sourceType: varchar({ length: 50 }).notNull(),
// 来源数据 ID
// 对应业务系统中的文章、日记等数据 ID
sourceId: varchar({ length: 200 }),
// 知识标题
// 例如:文章标题、日记标题、"关于我"等
title: varchar({ length: 500 }),
// AI 实际用于理解和回答的文本内容
content: text().notNull(),
// 当前内容属于第几个 chunk
// 未进行切分的短内容可以使用 0
chunkIndex: integer().notNull().default(0),
// 文本的 embedding 向量
// 维度必须与你使用的 Embedding 模型保持一致
embedding: vector({ dimensions: 1024 }),
// 创建时间
createdTime: timestamp().notNull().defaultNow(),
// 最后更新时间
updatedTime: timestamp().notNull().defaultNow(),
});上面最重要的是embedding字段,它是向量类型。但Postgresql默认并不支持向量类型,所以需要先开启对应的扩展。
进入neon后台,打开SQL Editor,输入以下命令来创建向量扩展:
CREATE EXTENSION IF NOT EXISTS vector;完成上述准备后,再通过drizzle-kit将数据表同步到数据库即可。
完成数据库的搭建后,接下来是整个流程中最关键的一步:把知识分块(Chunks)并进行Embedding,然后再入库。
使用RAG的初衷,就是希望只把精确、相关的知识片段交给大模型,让它整理后再回答,从而既节省Token,又能让回答更准确。
如果不分块,RAG就失去了意义——想象一下,把一个巨大的知识库整个丢给大模型,让它自己从中找答案:这不仅会消耗大量Token,回答也更容易出错。
所以我们需要先把长文本切分成一个个小片段,检索时只取出最相关的几块。
这里我们用到@langchain/textsplitters库,先通过npm安装:
npm i @langchain/textsplitters然后编写对应的方法,如下:
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 800, // 每一块的字符数量
chunkOverlap: 200, // 每一块与上一块、下一块可能重叠的字符数量
separators: ['\n\n', '\n', '。', '!', '?', ';', ','], // 参考这些符号进行分块
});
export const createDocuments = async (content: string) => {
const documents = await splitter.createDocuments([content]);
return documents;
};分块后的知识在入库之前,还需要再做一次Embedding操作。这个操作可以理解为:把一串“人类能理解的文字”转换成“计算机才能理解的向量”。
为什么要做Embedding呢?这是因为我们把知识Embedding后入库,之后就能借助Postgresql自带的能力,把用户的输入(同样Embedding化)与数据库中的向量做距离比较(即相似度比较),从而检索出最相关的知识交给大模型。
如何 Embedding?
要对知识片段做Embedding,需要借助大模型的能力,这里推荐阿里云的:文本与多模态向量化。
参考上面的地址,编写如下代码:
import OpenAI from 'openai';
// 注意: 不同地域的base_url不通用(下方示例使用北京地域的base_url)
// - 华北2(北京): https://{WorkspaceId}.cn-beijing.maas.aliyuncs.com/compatible-mode/v1,请将{WorkspaceId}替换为业务空间ID
// - 美国(弗吉尼亚): https://dashscope-us.aliyuncs.com/compatible-mode/v1
// - 新加坡: https://{WorkspaceId}.ap-southeast-1.maas.aliyuncs.com/compatible-mode/v1,请将{WorkspaceId}替换为业务空间ID
// - 德国(法兰克福): https://{WorkspaceId}.eu-central-1.maas.aliyuncs.com/compatible-mode/v1,请将{WorkspaceId}替换为业务空间ID
// - 日本(东京): https://{WorkspaceId}.ap-northeast-1.maas.aliyuncs.com/compatible-mode/v1,请将{WorkspaceId}替换为业务空间ID
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
baseURL: 'https://llm-jlpw7z9g5oi082gg.cn-beijing.maas.aliyuncs.com/compatible-mode/v1',
});
export const generateEmbeddings = async (inputTexts: string[]) => {
try {
const completion = await openai.embeddings.create({
model: 'text-embedding-v4',
input: inputTexts,
});
return completion;
} catch (error) {
console.error('Error:', error);
}
};至此,整个流程就串起来了:先把分块后的知识批量embedding,再连同向量一起入库;等用户输入后,把输入内容同样embedding,与数据库中的向量做相似度比较,取出相似度较高的片段作为资料交给大模型来回答。这就是一个最基础的RAG实现了。
前后端的交互借助vercel ai sdk可以非常方便地实现,这部分也可以直接让ai帮忙编写。
这里用到的大模型推荐deepseek flash,便宜耐造效果也不错。下面是nextjs的后端接口示例(路由为 /api/chat/route.ts,这是useChat默认调用的地址):
import { deepSeek } from '@ai-sdk/deepseek';
import {
UIMessage,
convertToModelMessages,
createUIMessageStreamResponse,
stepCountIs,
streamText,
toUIMessageStream,
tool,
} from 'ai';
import { z } from 'zod';
import { searchDocuments } from '@/app/actions/documents';
export async function POST(req: Request) {
const { messages }: { messages: UIMessage[] } = await req.json();
const result = streamText({
model: deepSeek('deepseek-v4-flash'),
system: `
你是这个个人博客的 AI 助手。
请根据知识库回答用户的问题。
规则:
- 全程使用简体中文思考(reasoning / 思考过程也必须用简体中文,不要用英文)。
- 优先使用知识库中的信息。
- 知识库没有的信息不要编造。
- 如果无法确定,明确告诉用户不知道。
- 回答应该自然、简洁。
特别处理:如果知识库中的'sourceType'为'article'或者'post'的话,需要针对标题和内容进行卡片式显示,卡片具有路由跳转功能,跳转链接通过'sourceId'跳转,即:<a href={sourceId}>{title}</a>。
`,
messages: await convertToModelMessages(messages),
stopWhen: stepCountIs(3), // 这里至少给3,以便agent能多次思考,将知识库的资料进行整理回答
tools: {
searchDocuments: tool({
description: '搜索个人博客知识库,获取与用户问题相关的信息',
inputSchema: z.object({
query: z.string().describe('需要搜索的用户问题'),
}),
execute: async ({ query }) => {
const documents = await searchDocuments(query);
return (
documents?.map((item) => ({
title: item.title,
sourceType: item.sourceType,
content: item.content,
sourceId: item.sourceId,
})) ?? ''
);
},
}),
},
});
return createUIMessageStreamResponse({
stream: toUIMessageStream({ stream: result.stream }),
});
}上面比较重要的一点是:知识库的检索是通过**工具(tool)**来完成并交给大模型组装的,而不是直接在messages里手动拼接。至于工具内部具体的相似度查询,实现方案如下:
import { sql } from 'drizzle-orm';
import { db } from '@/db';
import { generateEmbeddings } from '@/lib/embedding';
export async function searchDocuments(query: string) {
const embeddings = await generateEmbeddings([query]);
if (!embeddings?.data) {
return null;
}
const { embedding } = embeddings.data[0];
const result = await db.execute(sql`
SELECT
*,
1 - (embedding <=> ${JSON.stringify(embedding)}::vector) AS similarity
FROM documents
WHERE 1 - (embedding <=> ${JSON.stringify(embedding)}::vector) >= 0.35
ORDER BY embedding <=> ${JSON.stringify(embedding)}::vector
LIMIT 5
`);
return result.rows;
}这里需要理解的一点是:embedding <=> ${JSON.stringify(embedding)}::vector计算的是两个向量之间的距离,距离越小代表越相似,所以最后按距离升序排序、取前几条即可。
前端则可以通过useChat来完成交互,这部分同样可以让ai直接帮忙编写,或借助相关的ai ui库来实现。
下面是部分示例:
/** 聊天面板:useChat 管理消息流,v5 手动管理 input + sendMessage */
const ChatPanel = ({ show, onClose }: { show: boolean; onClose: () => void }) => {
// throttle:流式渲染时限制消息更新频率(50ms),减少 markdown 重解析导致的抖动/闪烁
const { messages, sendMessage, status } = useChat({ throttle: 50 });
const [input, setInput] = useState('');
const scrollRef = useRef<HTMLDivElement>(null);
const isStreaming = status === 'submitted' || status === 'streaming';
/** 新消息或流式更新时滚到底部 */
useEffect(() => {
const el = scrollRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [messages, status]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const text = input.trim();
if (!text || isStreaming) return;
sendMessage({ text });
setInput('');
};
return (
<motion.div
data-lenis-prevent
className="fixed right-4 bottom-28 z-95 flex h-112 max-h-[70vh] w-80 max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl md:right-6 md:bottom-32"
initial={false}
animate={{
opacity: show ? 1 : 0,
y: show ? 0 : 20,
scale: show ? 1 : 0.95,
pointerEvents: show ? 'auto' : 'none',
}}
transition={{ duration: 0.25, ease: 'easeInOut' }}
>
{/* 头部 */}
<div className="flex shrink-0 items-center justify-between border-b border-gray-200 px-4 py-3">
<div className="flex items-center gap-2 text-gray-700">
<MessageCircle size={18} className="text-purple-500" />
<span className="text-sm font-semibold">聊天</span>
</div>
<div
className="cursor-pointer rounded-md p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-700"
onClick={onClose}
>
<X size={18} />
</div>
</div>
{/* 消息列表 */}
<div ref={scrollRef} className="flex flex-1 flex-col gap-3 overflow-y-auto px-4 py-3">
{messages.length === 0 && (
<div className="flex h-full items-center justify-center text-center text-sm text-gray-400">
有什么可以帮你的吗?
</div>
)}
{messages.map((message, index) => {
const text = message.parts
.filter((part) => part.type === 'text')
.map((part) => part.text)
.join('');
const reasoning = message.parts
.filter((part) => part.type === 'reasoning')
.map((part) => part.text)
.join('');
// 最后一条助手消息且仍在流式、正文还没来时,显示“思考中”
const pending =
message.role === 'assistant' && index === messages.length - 1 && isStreaming && !text;
return (
<MessageBubble
key={message.id}
role={message.role}
text={text}
reasoning={reasoning}
pending={pending}
/>
);
})}
</div>
{/* 输入框 */}
<form
onSubmit={handleSubmit}
className="flex shrink-0 items-center gap-2 border-t border-gray-200 px-3 py-2"
>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="输入消息..."
className="flex-1 rounded-full border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-700 outline-none focus:border-purple-400 focus:bg-white"
/>
<button
type="submit"
disabled={!input.trim() || isStreaming}
className={cn(
'flex shrink-0 items-center justify-center rounded-full p-2 text-white transition-colors',
!input.trim() || isStreaming
? 'cursor-not-allowed bg-gray-300'
: 'cursor-pointer bg-purple-500 hover:bg-purple-600'
)}
>
<Send size={16} />
</button>
</form>
</motion.div>
);
};好了,这篇基于RAG概念实现AI博客助手的思路就分享到这里。整体流程可以概括为:分块 → Embedding → 入库 → 检索 → 交给大模型回答。理解了这条主线,你就能把它迁移到任何自己的知识库场景中。希望这篇文章对你有帮助。