RAG(Retrieval Augmented Generation,检索增强生成)这个技术方向,我最近花了不少时间研究。简单说就是让大模型在回答问题前先查资料,这样回答就不会胡编乱造了。这篇文章记录了我从零搭建一个智能知识库的完整过程,代码和踩坑经验都在这。


什么是 RAG,为什么前端人该关注

大模型有个老毛病:不知道的事情会编。你问它一个公司内部的政策,它根本没见过你的内部文档,但会编一个看起来合理的答案。

RAG 的思路很简单:回答之前,先从你的私有资料库里检索相关信息,把这些信息和问题一起交给大模型。这样模型回答的依据是真实数据,不是训练记忆。

前端工程师做 RAG 有几个优势:

  • 前端天然做用户界面,知识库的交互体验需要前端
  • 信息架构是前端的强项,RAG 本质就是把信息组织好、检索准
  • 不需要训练模型,用现成的 Embedding API + 向量库就能跑起来

项目目标

能用的智能知识库,需要这几件事:

  1. 上传 PDF/Markdown 文档作为知识来源
  2. 自动将文档切块、向量化、存入数据库
  3. 用户提问时,先检索相关文档片段,再生成回答
  4. 回答附带引用来源,方便核实

技术选型

试了几种方案,这是目前觉得适合前端开发者的组合:

组件 选择 理由
运行时 Node.js + TypeScript 前端友好
Embedding OpenAI text-embedding-3-small 便宜、快、效果好
向量库 Chroma(本地)/ Pinecone(云端) Chroma 零配置本地跑
框架 LangChain.js 生态成熟,文档全
前端 Next.js + Tailwind 快速出界面
文档解析 pdf-parse + marked 处理 PDF 和 Markdown

环境准备


1
2
3
4
5

mkdir
 rag-knowledge-base &&
 cd
 rag-knowledge-base

npm init -y

npm install langchain @langchain/openai @langchain/chroma

npm install pdf-parse marked

npm install -D typescript @types/node

配置 TypeScript:


1
2
3
4
5
6
7
8
9
10

{

  "compilerOptions"
:
 {

    "target"
:
 "ES2020"
,

    "module"
:
 "ESNext"
,

    "moduleResolution"
:
 "node"
,

    "outDir"
:
 "./dist"
,

    "rootDir"
:
 "./src"
,

    "esModuleInterop"
:
 true

  }

}


文档处理

知识库的第一步是把文档变成可以检索的小片段。流程:读取文件 → 切块 → 向量化。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

import
 fs
 from
 "fs"
;

import
 pdfParse
 from
 "pdf-parse"
;

import
 {
 RecursiveCharacterTextSplitter
 }
 from
 "langchain/text_splitter"
;

// 读取文件(支持 PDF 和 Markdown)

async
 function
 readFile
(
filePath: string
):
 Promise
<
string
> {

  if
 (filePath.
endsWith
(
".pdf"
)) {

    const
 dataBuffer = fs.
readFileSync
(filePath);

    const
 result =
 await
 pdfParse
(dataBuffer);

    return
 result.
text
;

  }

  return
 fs.
readFileSync
(filePath,
 "utf-8"
);

}

// 切块

async
 function
 chunkText
(
text: string
) {

  const
 splitter =
 new
 RecursiveCharacterTextSplitter
({

    chunkSize
:
 500
,

    chunkOverlap
:
 50
,

  });

  return
 await
 splitter.
splitText
(text);

}

// 测试

const
 text =
 await
 readFile
(
"./docs/my-document.pdf"
);

const
 chunks =
 await
 chunkText
(text);

console
.
log
(
`文档被切分为 ${chunks.length} 个片段`
);

切块这一步很关键。块太大检索不准,块太小上下文不够。500 字符是我试下来比较好的默认值,你可以根据实际文档类型调整。


向量存储

切好的片段通过 Embedding 变成向量,存入向量数据库。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33

import
 {
 Chroma
 }
 from
 "@langchain/chroma"
;

import
 {
 OpenAIEmbeddings
 }
 from
 "@langchain/openai"
;

import
 {
 Document
 }
 from
 "@langchain/core/documents"
;

const
 embeddings =
 new
 OpenAIEmbeddings
({

  modelName
:
 "text-embedding-3-small"
,

  apiKey
: process.
env
.
OPENAI_API_KEY
,

});

// 将切块转为 Document 对象

function
 chunksToDocuments
(
chunks: string[], sourceFile: string
) {

  return
 chunks.
map
(
(chunk, i) =>
 
    new
 Document
({

      pageContent
: chunk,

      metadata
: {
 source
: sourceFile,
 chunkIndex
: i },

    })

  );

}

// 存入 Chroma

async
 function
 storeDocuments
(
documents: Document[], collectionName: string
) {

  const
 vectorStore =
 new
 Chroma
(embeddings, {

    collectionName,

    url
:
 "http://localhost:8000"
,

  });

  await
 vectorStore.
addDocuments
(documents);

  console
.
log
(
`已存储 ${documents.length} 个文档到 ${collectionName}`
);

}

// 完整流程

const
 chunks =
 await
 chunkText
(
await
 readFile
(
"./docs/product-guide.pdf"
));

const
 docs =
 chunksToDocuments
(chunks,
 "product-guide.pdf"
);

await
 storeDocuments
(docs,
 "my-knowledge-base"
);

启动 Chroma 本地服务:


1
2

pip install chromadb

chroma run --path ./chroma-data


检索 + 生成回答

知识库建好了,接下来做核心的检索和回答功能。


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58

import
 {
 ChatOpenAI
 }
 from
 "@langchain/openai"
;

import
 {
 ChatPromptTemplate
 }
 from
 "@langchain/core/prompts"
;

import
 {
 StringOutputParser
 }
 from
 "@langchain/core/output_parsers"
;

import
 {
 Chroma
 }
 from
 "@langchain/chroma"
;

const
 llm =
 new
 ChatOpenAI
({

  modelName
:
 "gpt-4o"
,

  temperature
:
 0.3
,

});

// 连接已有的知识库

const
 vectorStore =
 new
 Chroma
(embeddings, {

  collectionName
:
 "my-knowledge-base"
,

  url
:
 "http://localhost:8000"
,

});

// RAG 提示词模板

const
 RAG_PROMPT
 =
 ChatPromptTemplate
.
fromMessages
([

  [
"system"
,
 `你是一个知识库助手,根据检索到的文档片段回答问题。

如果文档中有相关信息,请基于文档内容回答,并在末尾标注引用来源。

如果文档中没有相关信息,请如实告知用户
"知识库中没有找到相关信息"
。

不要编造答案。
`],

  [
"human"
,
 `检索到的文档:

{context}

用户问题:{question}
`],

]);

// 完整的 RAG 查询流程

async
 function
 queryKnowledge
(
question: string
) {

  // 1. 检索相关文档

  const
 results =
 await
 vectorStore.
similaritySearch
(question,
 3
);

  if
 (results.
length
 ===
 0
) {

    return
 {
 answer
:
 "知识库中没有找到相关内容"
,
 sources
: [] };

  }

  // 2. 组装上下文

  const
 context = results.
map
(
(doc, i) =>
 
    `[文档${i + 1}] ${doc.pageContent}\n来源: ${doc.metadata.source}`

  ).
join
(
"\n\n"
);

  // 3. 生成回答

  const
 chain =
 RAG_PROMPT
.
pipe
(llm).
pipe
(
new
 StringOutputParser
());

  const
 answer =
 await
 chain.
invoke
({ context, question });

  // 4. 返回回答 + 引用来源

  return
 {

    answer,

    sources
: results.
map
(
doc =>
 doc.
metadata
.
source
),

  };

}

// 测试

const
 result =
 await
 queryKnowledge
(
"产品的退款政策是什么?"
);

console
.
log
(
"回答:"
, result.
answer
);

console
.
log
(
"来源:"
, result.
sources
);


前端界面

前端开发者最关心的还是界面。用 Next.js 做一个简单的知识库问答界面:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91

// app/page.tsx

"use client"
;

import
 { useState }
 from
 "react"
;

interface
 Message
 {

  role
:
 "user"
 |
 "assistant"
;

  content
:
 string
;

  sources?:
 string
[];

}

export
 default
 function
 Home
() {

  const
 [messages, setMessages] = useState<
Message
[]>([]);

  const
 [input, setInput] =
 useState
(
""
);

  const
 [loading, setLoading] =
 useState
(
false
);

  const
 sendMessage
 =
 async
 () => {

    if
 (!input.
trim
())
 return
;

    const
 userMessage
:
 Message
 = {
 role
:
 "user"
,
 content
: input };

    setMessages
(
prev =>
 [...prev, userMessage]);

    setInput
(
""
);

    setLoading
(
true
);

    const
 res =
 await
 fetch
(
"/api/query"
, {

      method
:
 "POST"
,

      headers
: {
 "Content-Type"
:
 "application/json"
 },

      body
:
 JSON
.
stringify
({
 question
: input }),

    });

    const
 data =
 await
 res.
json
();

    const
 assistantMessage
:
 Message
 = {

      role
:
 "assistant"
,

      content
: data.
answer
,

      sources
: data.
sources
,

    };

    setMessages
(
prev =>
 [...prev, assistantMessage]);

    setLoading
(
false
);

  };

  return
 (

    <div className=
"min-h-screen bg-gray-50 flex flex-col"
>

      <header className=
"bg-white shadow p-4"
>

        <h1 className=
"text-xl font-bold"
>智能知识库</h1>

      </header>

      <main className=
"flex-1 overflow-y-auto p-4 space-y-4"
>

        {messages.
map
(
(msg, i) =>
 (

          <div

            key={i}

            className={
`max-w-2xl p-4 rounded-lg ${

              msg.
role
 ===
 "user"

                ?
 "bg-blue-100 ml-auto"

                :
 "bg-white border"

            }
`}

          >

            <p className=
"whitespace-pre-wrap"
>{msg.
content
}</p>

            {msg.
sources
 && msg.
sources
.
length
 >
 0
 && (

              <div className=
"mt-2 text-xs text-gray-500"
>

                来源: {msg.
sources
.
join
(
", "
)}

              </div>

            )}

          </div>

        ))}

        {loading && (

          <div className=
"max-w-2xl p-4 rounded-lg bg-white border"
>

            <p className=
"text-gray-400"
>正在检索知识库...</p>

          </div>

        )}

      </main>

      <footer className=
"bg-white border-t p-4"
>

        <div className=
"max-w-2xl mx-auto flex gap-2"
>

          <input

            value={input}

            onChange={
(e) =>
 setInput
(e.
target
.
value
)}

            onKeyDown={
(e) =>
 e.
key
 ===
 "Enter"
 &&
 sendMessage
()}

            placeholder=
"输入你的问题..."

            className=
"flex-1 border rounded-lg px-4 py-2"

          />

          <button

            onClick={sendMessage}

            disabled={loading}

            className=
"bg-blue-600 text-white px-6 py-2 rounded-lg disabled:opacity-50"

          >

            提问

          </button>

        </div>

      </footer>

    </div>

  );

}

API 路由:


1
2
3
4
5
6
7
8
9

// app/api/query/route.ts

import
 {
 NextResponse
 }
 from
 "next/server"
;

import
 { queryKnowledge }
 from
 "@/lib/rag"
;

export
 async
 function
 POST
(
request: Request
) {

  const
 { question } =
 await
 request.
json
();

  const
 result =
 await
 queryKnowledge
(question);

  return
 NextResponse
.
json
(result);

}


批量导入文档

实际使用中不可能一条条录入。写一个批量导入脚本:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53

import
 glob
 from
 "glob"
;

import
 {
 RecursiveCharacterTextSplitter
 }
 from
 "langchain/text_splitter"
;

import
 {
 Chroma
 }
 from
 "@langchain/chroma"
;

import
 {
 OpenAIEmbeddings
 }
 from
 "@langchain/openai"
;

import
 {
 Document
 }
 from
 "@langchain/core/documents"
;

import
 fs
 from
 "fs"
;

import
 pdfParse
 from
 "pdf-parse"
;

async
 function
 importAllDocuments
() {

  const
 files = glob.
sync
(
"./docs/**/*.{pdf,md}"
);

  console
.
log
(
`找到 ${files.length} 个文档`
);

  const
 allDocuments
:
 Document
[] = [];

  for
 (
const
 file
 of
 files) {

    let
 text
:
 string
;

    if
 (file.
endsWith
(
".pdf"
)) {

      const
 dataBuffer = fs.
readFileSync
(file);

      const
 result =
 await
 pdfParse
(dataBuffer);

      text = result.
text
;

    }
 else
 {

      text = fs.
readFileSync
(file,
 "utf-8"
);

    }

    const
 splitter =
 new
 RecursiveCharacterTextSplitter
({

      chunkSize
:
 500
,

      chunkOverlap
:
 50
,

    });

    const
 chunks =
 await
 splitter.
splitText
(text);

    const
 docs = chunks.
map
(
(chunk, i) =>

      new
 Document
({

        pageContent
: chunk,

        metadata
: {
 source
: file,
 chunkIndex
: i },

      })

    );

    allDocuments.
push
(...docs);

    console
.
log
(
`处理完成: ${file} → ${chunks.length} 个片段`
);

  }

  const
 embeddings =
 new
 OpenAIEmbeddings
({

    modelName
:
 "text-embedding-3-small"
,

  });

  const
 vectorStore =
 new
 Chroma
(embeddings, {

    collectionName
:
 "my-knowledge-base"
,

    url
:
 "http://localhost:8000"
,

  });

  await
 vectorStore.
addDocuments
(allDocuments);

  console
.
log
(
`全部导入完成,共 ${allDocuments.length} 个文档片段`
);

}

importAllDocuments();


踩坑记录

1. 中文检索效果不好

一开始用 text-embedding-ada-002,中文文档的检索准确率只有 60% 左右。换成 text-embedding-3-small 后提升到 85%。如果你的知识库以中文为主,建议直接用 3-small。

2. 切块大小影响很大

块太小(200 字符)检索出的内容缺乏上下文;块太大(1000 字符)又会混入不相关信息。500 字符是我试下来比较合适的值,具体看你的文档类型。技术文档可以偏大一些,FAQ 类偏小。

3. 回答引用的来源不对

有时候回答引用的文档和实际内容对不上。原因是 metadata 没有正确传递。确保每个 Document 对象都有 source 字段,检索结果按源文件过滤。

4. Chroma 本地存储的坑

Chroma 默认数据存在内存里,重启就没了。启动时加上 --path ./chroma-data 参数才能持久化。


进阶优化方向

方向 说明 难度
混合检索 关键词检索 + 向量检索结合 ⭐⭐
重排序 用 Cross-Encoder 对检索结果重新排序 ⭐⭐⭐
多路召回 多个知识库并行检索,合并结果 ⭐⭐⭐
流式输出 SSE 实现打字机效果 ⭐⭐
问答评估 自动化评估回答准确率和忠实度 ⭐⭐⭐

RAG 的入门门槛不高,但要做好需要反复调参。文档切块、检索策略、提示词设计这三个环节最值得花时间优化。

学AI大模型的正确顺序,千万不要搞错了

🤔2026年AI风口已来!各行各业的AI渗透肉眼可见,超多公司要么转型做AI相关产品,要么高薪挖AI技术人才,机遇直接摆在眼前!

有往AI方向发展,或者本身有后端编程基础的朋友,直接冲AI大模型应用开发转岗超合适!

就算暂时不打算转岗,了解大模型、RAG、Prompt、Agent这些热门概念,能上手做简单项目,也绝对是求职加分王🔋

在这里插入图片描述

📝给大家整理了超全最新的AI大模型应用开发学习清单和资料,手把手帮你快速入门!👇👇

学习路线:

✅大模型基础认知—大模型核心原理、发展历程、主流模型(GPT、文心一言等)特点解析
✅核心技术模块—RAG检索增强生成、Prompt工程实战、Agent智能体开发逻辑
✅开发基础能力—Python进阶、API接口调用、大模型开发框架(LangChain等)实操
✅应用场景开发—智能问答系统、企业知识库、AIGC内容生成工具、行业定制化大模型应用
✅项目落地流程—需求拆解、技术选型、模型调优、测试上线、运维迭代
✅面试求职冲刺—岗位JD解析、简历AI项目包装、高频面试题汇总、模拟面经

以上6大模块,看似清晰好上手,实则每个部分都有扎实的核心内容需要吃透!

我把大模型的学习全流程已经整理📚好了!抓住AI时代风口,轻松解锁职业新可能,希望大家都能把握机遇,实现薪资/职业跃迁~

这份完整版的大模型 AI 学习资料已经上传CSDN,朋友们如果需要可以微信扫描下方CSDN官方认证二维码免费领取【保证100%免费

在这里插入图片描述

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐