使用JS从零构建本地 RAG 知识库系统:基于 LangGraph + Ollama + LanceDB 的完整实践

0 阅读16分钟

从零构建本地 RAG 知识库系统

一句话看懂这篇文章:你将亲手写出一个完全跑在自己电脑上、不花一分钱 API 费用、数据绝不外传的 AI 智能知识库——上传公司文档、私人笔记、学习资料,然后用自然语言问它任何问题,它会像一个读完你所有资料的私人顾问一样回答你。


全文总览:一张图看懂你即将学会什么

在开始动手之前,请先花 30 秒看完这张思维导图。它是全文的骨架,后面每一章都是在填充这张图的某个分支:

image.png


第一章 · RAG 到底是什么?用家教老师的故事讲给你听

1.1 小白版:三分钟听懂 RAG

想象你有一位记忆力超群但知识停在 2024 年某天的家教老师(这就是大语言模型 LLM),你今天有三个问题想问他:

问题一:"今年诺贝尔奖得主是谁?"他会编一个看起来合理但完全错误的名字——这就是 AI 的幻觉问题二:"我们公司新产品的 SKU 编码规则是什么?"他根本没见过你们公司的资料,又开始硬编问题三:"RAG 是什么?"这是公开知识,他答得飞起。

聪明的做法不是把他送回学校重读(那叫 Fine-tuning,贵得离谱),而是——提问前,先把相关资料翻到他面前,让他看着资料回答。这就是 RAG。

1.2 本质公式

RAG = Retrieval(检索) + Augmented(增强) + Generation(生成) 翻译成人话 = 先查资料,再写作业

**1.3 RAG vs Fine-tuning

维度RAG(检索增强)Fine-tuning(微调)
知识更新速度改数据库即可,秒级生效重新训练,数小时到数天
单次成本几乎为零GPU 训练费 + 数据标注费
可解释性能追溯答案来自哪篇文档黑盒,无法追溯
适合场景企业知识库、客服、文档问答领域风格定制、指令遵循
数据安全数据可全本地化训练数据可能被模型记住

:"生产环境是二选一还是可以叠加?" :"组合拳才是标配——RAG 解决'知识'问题,Fine-tune 解决'风格和格式'问题。比如做法律咨询 Agent:用 RAG 检索最新法条,用 Fine-tune 让模型输出带有律师口吻和规范结构。"


第二章 · 技术选型的灵魂拷问

2.1 为什么每一个技术都这么选?

层级我的选择放弃的候选权衡理由
LLMOllama + Qwen3.5OpenAI API、文心一言零 API 成本、数据不出本机、中文能力强
嵌入模型nomic-embed-textBGE、OpenAI Embedding体积仅 274MB、中英双语、本地秒级
向量库LanceDBPinecone、Milvus、Chroma嵌入式(像 SQLite),无需单独部署
编排LangGraphLangChain Chain、裸调状态图可视化、支持循环和分支
后端Express + TypeScriptNestJS、Fastify轻量灵活,SSE 支持成熟
前端Next.js 15 + React 19Vue、Nuxt官方 agent-chat-ui 模板加持
通信SSEWebSocket、轮询LLM 是单向流,SSE 天然契合

2.2 一张图看清整个系统

image.png

:"为什么不直接用 OpenAI API,省事又强大?" :"三条红线——数据合规(医疗、金融、企业机密不能出境)、成本可控(大规模调用费用不可承受)、离线可用(断网场景必须能跑)。全本地化是 B 端 AI 应用的必选项。"


第三章 · 环境准备:10 分钟把地基打好

3.1 安装 Ollama 和模型

# macOS 使用 Homebrew 安装(Windows 从官网下安装包)
brew install ollama

# 启动 Ollama 服务(这是个常驻进程,另开一个终端)
ollama serve

# 下载对话模型(通义千问 Qwen3.5)
ollama pull qwen3.5:latest

# 下载嵌入模型(文字转向量)
ollama pull nomic-embed-text

# 验证安装
ollama list

为什么选 Qwen3.5? 中文理解、代码生成、逻辑推理全面提升,建议至少 16GB 内存跑起来才流畅。

3.2 项目初始化

mkdir LangChainDoc && cd LangChainDoc
mkdir backend && cd backend
npm init -y

npm install express cors dotenv multer uuid \
  @langchain/langgraph @langchain/core @langchain/ollama \
  @langchain/community @lancedb/lancedb pdf-parse

npm install -D typescript @types/node @types/express @types/multer tsx eslint

npx tsc --init

package.json 关键配置:

{
  "type": "module",
  "scripts": {
    "dev": "tsx watch src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

3.3 环境变量配置

backend/.env:

OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_LLM_MODEL=qwen3.5:latest
OLLAMA_EMBEDDING_MODEL=nomic-embed-text:latest
PORT=2024
LANCEDB_PATH=./data/lancedb

第四章 · 向量化:让机器"理解"语义的魔法

先记住一个结论:嵌入模型是一个翻译官,它把任何一段文字翻译成 768 个数字组成的密码

神奇的是,这个翻译官有个特性——意思相近的文字,翻译出来的密码也长得相近:

"机器学习"  →  [0.12, -0.34, 0.56, ... ] (768 个数字)
"深度学习"  →  [0.11, -0.32, 0.58, ... ] ← 看!几乎一样
"今天天气"  →  [0.89,  0.21, -0.15, ... ] ← 差了十万八千里

所以当用户搜"AI 技术"时,我们把这句话也翻译成 768 个数字,然后在数据库里找数字最接近的文档——这就是向量检索,比传统关键词匹配高明太多。

4.2 向量检索流程图

image.png

4.3 核心代码:初始化 + 入库 + 搜索

初始化 LanceDB(嵌入式数据库,连接即创建):

import { OllamaEmbeddings } from "@langchain/ollama";
import * as lancedb from "@lancedb/lancedb";

const VECTOR_DIM = 768;

export async function initLanceDB(dbPath?: string) {
  const defaultPath = process.env.LANCEDB_PATH || "./data/lancedb";
  const absolutePath = path.resolve(process.cwd(), defaultPath);
  
  if (!fs.existsSync(absolutePath)) {
    fs.mkdirSync(absolutePath, { recursive: true });
  }
  return await lancedb.connect(absolutePath);
}

export function initEmbeddings() {
  return new OllamaEmbeddings({
    model: process.env.OLLAMA_EMBEDDING_MODEL || "nomic-embed-text:latest",
    baseUrl: process.env.OLLAMA_BASE_URL || "http://localhost:11434",
  });
}

动态分批入库(生产级关键细节):

export async function addDocumentsToVectorStore(db, documents, tableName = "documents") {
  const embeddings = initEmbeddings();
  
  // 表不存在时用测试向量创建
  let table;
  try {
    table = await db.openTable(tableName);
  } catch {
    const testVector = await embeddings.embedQuery("test");
    table = await db.createTable(tableName, [{
      id: "init", content: "init", metadata: "{}", vector: testVector,
    }]);
  }
  
  const MAX_EMBED_LENGTH = 4000;          // 单文档截断
  const MAX_TOTAL_BATCH_LENGTH = 20000;   // 单批总字符上限
  const MIN_BATCH_SIZE = 3;
  
  // 预处理:超长文档截断
  const processedDocs = documents.map(doc => {
    let content = doc.pageContent.trim();
    if (content.length > MAX_EMBED_LENGTH) {
      content = content.substring(0, MAX_EMBED_LENGTH) + "...[已截断]";
    }
    return { ...doc, pageContent: content };
  });
  
  // 动态分批(同时看文档数和总字符数)
  const allData = [];
  let batch = [], batchLen = 0;
  
  for (const doc of processedDocs) {
    if (batch.length >= MIN_BATCH_SIZE && 
        (batchLen + doc.pageContent.length > MAX_TOTAL_BATCH_LENGTH || batch.length >= 10)) {
      const vectors = await embeddings.embedDocuments(batch.map(d => d.pageContent));
      batch.forEach((d, i) => allData.push({
        id: uuidv4(), content: d.pageContent,
        metadata: JSON.stringify(d.metadata), vector: vectors[i],
      }));
      batch = []; batchLen = 0;
    }
    batch.push(doc);
    batchLen += doc.pageContent.length;
  }
  // 处理最后一批...
  
  await table.add(allData);
  return allData.length;
}

向量相似度搜索(带优雅降级):

export async function searchSimilarDocuments(db, query, topK = 5, tableName = "documents") {
  const embeddings = initEmbeddings();
  
  try {
    // 防御性检查
    const tables = await db.tableNames();
    if (!tables.includes(tableName)) return [];
    
    const table = await db.openTable(tableName);
    const queryVector = await embeddings.embedQuery(query);
    const results = await table.search(queryVector).limit(topK).toArray();
    
    return results.map(r => ({
      content: r.content,
      metadata: JSON.parse(r.metadata),
      score: r._distance,  // 越小越相似
    }));
  } catch (error) {
    console.error("向量搜索失败,降级为普通对话:", error);
    return [];
  }
}

:"向量维度为什么是 768?能改成 1536 吗?" :"维度由嵌入模型决定,不可自定义——nomic-embed-text 是 768,OpenAI text-embedding-3-small 是 1536。维度越高表达越精细但存储和计算成本越高,一般 768-1024 是甜点区间。"


第五章 · 文本分块:为什么不能"一刀切 1000 字"?

5.1 为什么要切?怎么切?

嵌入模型有胃口上限——一次最多消化 8192 个 tokens(中文约 4000-5000 字)。一本 500 页的 PDF 直接塞给它,会被"撑死"。所以必须切片

但切片不能随便切。看这个血的教训:

原文:"第一步是从知识库中找到相关文档,第二步是交给大模型处理。"

❌ 不重叠切法:
[块 0] "第一步是从知识库中找到相关文档,"
[块 1] "第二步是交给大模型处理。"
       用户问"第一步和第二步是什么关系?"两块都答不全

✅ 重叠切法(overlap=200):
[块 0] "第一步是从知识库中找到相关文档,第二步是交给..."
[块 1] "...找到相关文档,第二步是交给大模型处理。"
       每块都包含完整上下文,召回不漏信息

5.2 滑动窗口分块流程图

image.png

5.3 核心代码

export function splitDocuments(documents, chunkSize = 1000, chunkOverlap = 200) {
  const chunks = [];
  
  for (const doc of documents) {
    const content = doc.pageContent;
    const chunksCount = Math.ceil(
      (content.length - chunkOverlap) / (chunkSize - chunkOverlap)
    );
    
    for (let i = 0; i < chunksCount; i++) {
      const start = i * (chunkSize - chunkOverlap);
      const end = Math.min(start + chunkSize, content.length);
      const chunkContent = content.substring(start, end);
      
      if (chunkContent.trim().length > 0) {
        chunks.push(new Document({
          pageContent: chunkContent,
          metadata: { ...doc.metadata, chunk: i, totalChunks: chunksCount },
        }));
      }
    }
  }
  return chunks;
}

:"chunkSize 和 overlap 怎么调?有没有经验值?" :"按内容类型调优——技术文档 1000/200 适中;聊天记录 500/100 短小;法律条文 2000/400 需要大段上下文;代码文件按函数切,不按字数切。生产环境要用评估集跑召回率测试,这是 RAG 调优的第一大杠杆。"


第六章 · LangGraph Agent:让 AI 学会"思考步骤"

Chain vs Graph 的区别

把 AI 工作流比作做菜:

  • LangChain 的 Chain(链):固定菜谱,洗菜→切菜→炒菜,一条道走到黑。
  • LangGraph 的 Graph(图):智能大厨——尝一口不够咸?回去加盐;食材不够?循环去买。支持状态、分支、循环

这就是为什么做复杂 Agent(多轮检索、工具调用、反思)必须用 LangGraph。

6.2 RAG Agent 状态图

stateDiagram-v2
    [*] --> retrieve: 收到用户消息
    retrieve --> model: 注入 context
    model --> [*]: 返回 AI 回答
    
    note right of retrieve
      1. 取出最新 HumanMessage
      2. embedQuery 问题向量化
      3. LanceDB 搜索 Top 3
      4. 组装检索上下文
    end note
    
    note right of model
      判断模式:
      • 有 context → RAG 增强
      • 无 context → 普通对话
      • 注入 SystemPrompt
      • LLM 生成 AIMessage
    end note

6.3 核心代码:四步构建 Agent

import { StateGraph, Annotation } from "@langchain/langgraph";
import { ChatOllama } from "@langchain/ollama";
import { HumanMessage, AIMessage, SystemMessage, BaseMessage } from "@langchain/core/messages";

// ① 定义状态结构(LangGraph 的精髓)
const AgentAnnotation = Annotation.Root({
  messages: Annotation<BaseMessage[]>({
    reducer: (left, right) => left.concat(Array.isArray(right) ? right : [right]),
    default: () => [],
  }),
  context: Annotation<string | undefined>(),  // 检索上下文通过 state 在节点间传递
});

// ② LLM 初始化
const llm = new ChatOllama({
  model: process.env.OLLAMA_LLM_MODEL || "qwen3.5:latest",
  baseUrl: process.env.OLLAMA_BASE_URL || "http://127.0.0.1:11434",
  temperature: 0.7,
});

// ③ retrieve 节点
async function retrieveContext(state) {
  const lastMsg = state.messages[state.messages.length - 1];
  if (!(lastMsg instanceof HumanMessage)) return {};
  
  const db = await initLanceDB();
  const docs = await searchSimilarDocuments(db, lastMsg.content as string, 3);
  
  if (docs.length > 0) {
    const context = docs.map((d, i) => `[文档 ${i+1}]:
${d.content}`).join('

');
    return { context };
  }
  return {};
}

// ④ model 节点
async function callModel(state: AgentState) {
  const messages = state.messages;
  const lastMessage = messages[messages.length - 1];

  // 二种模式的智能路由:

  // 模式 1:有知识库上下文 → RAG 增强
  if (state.context && lastMessage instanceof HumanMessage) {
    const systemPrompt = `你是一个智能知识库助手。
请参考以下知识库文档:
${state.context}
请基于上述信息回答问题。如果信息不足,请诚实告诉用户。`;

    const enhancedMessages = [
      ...messages.slice(0, -1),      // 历史消息
      new SystemMessage(systemPrompt), // 注入知识库上下文
      lastMessage,                     // 当前问题
    ];

    const response = await llm.invoke(enhancedMessages);
    return { messages: [response as AIMessage], context: undefined };
  }

  // 模式 2:没有上下文 → 普通对话
  const response = await llm.invoke(messages);
  return { messages: [response as AIMessage] ];
  

  
  instanceof HumanMessage {
    const systemPrompt = `你是一个智能知识库助手。
请参考以下知识库文档:


请基于上述信息回答问题。如果信息不足,请诚实告诉用户。`;
    
    const enhanced = [
      ...state.messages.slice(0, -1),
      new SystemMessage(systemPrompt),
      lastMsg,
    ];
    const response = await llm.invoke(enhanced);
    return { messages: [response as AIMessage], context: undefined };
  }
  
  const response = await llm.invoke(state.messages);
  return { messages: [response as AIMessage] };
}

// ⑤ 编排成图
const workflow = new StateGraph(AgentAnnotation)
  .addNode("retrieve", retrieveContext)
  .addNode("model", callModel)
  .addEdge("__start__", "retrieve")
  .addEdge("retrieve", "model")
  .addEdge("model", "__end__");

export const agent = workflow.compile();

:"如果想加'联网搜索兜底'功能,怎么改?" :"加一个 webSearch 节点,在 retrieve 后面加条件边——检索结果 score 都低于阈值时路由到 webSearch,否则直接进 model。这就是 LangGraph 对比 Chain 的核心优势:条件路由。"


**第七章 · SSE 流式通信

7.1 小白版:为什么 ChatGPT 能一个字一个字吐出来?

因为它用了 SSE(Server-Sent Events)——服务器像水龙头一样持续往前端推送数据,前端不用反复发请求。

SSE 和 WebSocket 的对比:

维度SSEWebSocket
方向服务端 → 客户端(单向)全双工
协议标准 HTTP(防火墙友好)ws:// 独立协议
重连浏览器原生支持需手写
复杂度极低较高
适合场景LLM 流式、股票、通知聊天室、多人游戏

LLM 回复天然是单向流,SSE 是标准答案。

7.2 SSE 完整时序图

sequenceDiagram
    participant U as 👤 用户
    participant F as 🖥️ Next.js 前端
    participant E as ⚙️ Express
    participant A as 🤖 Agent
    participant L as 🗄️ LanceDB
    participant O as 🧠 Ollama
    
    U->>F: 输入"如何重置设备?"
    F->>E: POST /runs/stream (SSE)
    
    Note over E: 设置 SSE headers<br/>Content-Type: text/event-stream
    E-->>F: event: metadata<br/>{run_id, thread_id}
    
    E->>A: agent.invoke({messages})
    
    rect rgb(255, 245, 225)
        Note over A,L: retrieve 节点
        A->>O: embedQuery(问题)
        O-->>A: [0.55, -0.23, ...]
        A->>L: search(vector).limit(3)
        L-->>A: Top 3 相关文档
    end
    
    rect rgb(225, 240, 255)
        Note over A,O: model 节点
        A->>O: llm.invoke([history, sysPrompt, query])
        O-->>A: AIMessage(答案)
    end
    
    A-->>E: 完整 messages
    E->>E: 持久化到 JSON
    E-->>F: event: values<br/>{messages: [...]}
    E-->>F: event: end
    F->>U: 渲染答案

7.3 核心代码:SSE 流式接口

app.post("/runs/stream", async (req, res) => {
  const { thread_id, input } = req.body;
  
  // ① 必须三件套 + X-Accel-Buffering(禁 Nginx 缓冲)
  res.setHeader("Content-Type", "text/event-stream");
  res.setHeader("Cache-Control", "no-cache");
  res.setHeader("Connection", "keep-alive");
  res.setHeader("X-Accel-Buffering", "no");
  
  // ② 拼装消息历史
  let messages = chatHistoryManager.get(thread_id) || [];
  const userMsg = new HumanMessage({
    content: input.messages?.[0]?.content || input,
    id: uuidv4(),
  });
  messages.push(userMsg);
  
  // ③ 元数据事件
  res.write(`event: metadata
data: )}

`);
  
  // ④ 调 Agent
  const result = await agent.invoke({ messages }, { configurable: { thread_id } });
  chatHistoryManager.set(thread_id, result.messages);
  
  // ⑤ ★ 关键:发 values 不是 messages!
  res.write(`event: values
data: )),
  })}

`);
  
  // ⑥ 结束
  res.write(`event: end
data: {}

`);
  res.end();
});

7.4 踩坑警告:一行代码的 BUG

// ❌ 错误:前端永远 loading
res.write(`event: messages
data: )}

`);

// ✅ 正确:LangGraph SDK 硬编码监听 values 事件
res.write(`event: values
data: )}

`);

SSE 铁律:格式必须是 `event: xxx data: {...}

`——最后两个换行符,少一个浏览器就不触发事件监听

:"SSE 连接断了怎么办?" :"浏览器 EventSource 原生支持自动重连,默认 3 秒重试。服务端可用 retry: 字段自定义间隔。更进阶的是给每条消息加 id:,重连时浏览器自动带上 Last-Event-ID 请求头,服务端据此断点续传。"


第八章 · 前端实现:useStream Hook 的魔法

8.1 技术栈

前端基于 LangGraph 官方 agent-chat-ui 模板:

  • Next.js 15 + React 19:最新全栈框架
  • Tailwind CSS + shadcn/ui:现代 UI 组件库
  • @langchain/langgraph-sdk/react:官方 Hook,自动处理 SSE

8.2 Stream Provider:核心连接管理

import { useStream } from "@langchain/langgraph-sdk/react";

const StreamSession = ({ apiUrl, assistantId, apiKey }) => {
  const streamValue = useStream({
    apiUrl,           // "http://localhost:2024"
    assistantId,      // "agent"
    threadId,         // 当前会话 ID
    fetchStateHistory: true,
    onThreadId: (id) => setThreadId(id),
  });
  
  return (
    <StreamContext.Provider value={streamValue}>
      {children}
    </StreamContext.Provider>
  );
};

useStream Hook 封装了全部复杂度——自动建立 SSE 连接、解析 metadata/values/end 事件、维护消息状态、触发 UI 更新、处理重连和错误恢复。

8.3 Provider 嵌套架构

export default function DemoPage() {
  return (
    <React.Suspense fallback={<div>Loading...</div>}>
      <Toaster />
      <ThreadProvider>         {/* 多线程管理 */}
        <StreamProvider>        {/* SSE 流式连接 */}
          <ArtifactProvider>    {/* 代码/文件预览 */}
            <Thread />          {/* 主聊天界面 */}
          </ArtifactProvider>
        </StreamProvider>
      </ThreadProvider>
    </React.Suspense>
  );
}

每一层提供不同的上下文能力,组件通过 Context 共享状态——这是大型 AI 应用前端的标准分层模式。


第九章 · 一次完整请求的全生命周期

9.1: 12 步走完整条链路

用真实场景把所有知识串起来——假设你刚上传了《产品使用手册.pdf》,然后问:"如何重置设备?"

image.png

9.2 每一步到底在干什么?

1-4 步:前端到后端的握手——前端 useStream 发请求,Express 设置三个关键响应头:text/event-streamno-cacheX-Accel-Buffering: no(禁止 Nginx 缓冲,这个头必须加,否则 SSE 在反向代理后会卡住)。

5-8 步:检索节点的三部曲——问题向量化 → 数据库搜索 → 拼接上下文。这里有个工程细节:searchSimilarDocuments 返回的 _distance 越小表示越相似,如果最小距离都超过阈值(比如 0.8),说明知识库里根本没相关内容,这时不应该注入 context,应让模型老实说"我不知道"。

9-11 步:模型节点的 Prompt 工程——最关键的一句话是 SystemPrompt 里的"如果信息不足,请诚实告诉用户"。这一句能把幻觉率降低 30% 以上,是廉价却极其有效的防御手段。

12 步:流式响应回推——event: values 把完整消息链送回前端,event: end 告诉前端"本回合结束"。如果要实现逐字流式输出(像 ChatGPT),这里要改成 llm.stream() 并把每个 chunk 分别推送。


第十章 · 踩坑实录

这几个坑每一个都是我真实花了数小时才定位出来的,只有亲手写过的人才会遇到

踩坑:LanceDB 表不存在就崩溃

现象:首次启动没上传文档就提问,直接报错 Table 'documents' was not found解法:

const tables = await db.tableNames();
if (!tables.includes(tableName)) return [];  // 优雅降级

踩坑:多进程写入导致 .lance 文件损坏

现象:tsx watch 热重载时偶尔会同时存在两个 Node 进程,同时写坏 LanceDB 底层文件,报错 Failed to get next batch from stream救急:

pkill -f "tsx watch"
rm -rf backend/data/lancedb/

预防:开发环境改用 nodemon + 显式单例,或用 pm2 做进程管理。

踩坑:流式分块陷入死循环

现象:处理大文件时进度条到 100% 后还在跑,CPU 拉满。 根因:重叠机制让 position 会"后退",没有终止条件就永不结束。 解法:

while (position < totalLength) {
  // ... 分块逻辑
  if (endPosition >= totalLength) break;  // ★ 必须硬断
  position = endPosition - CHUNK_OVERLAP;
}

踩坑:嵌入模型上下文超限

现象:上传长文档报 context length exceeded根因:nomic-embed-text 实际上限 8192 tokens(中文约 4000-5000 字),但 Ollama 默认配置只开 2048。 解法:三层截断兜底——单文档 4000 字、批次总长 20000 字、批次数上限 10 篇。


第十一章 · 部署与运行

11.1 一键启动

# 终端 1:启动 Ollama
ollama serve

# 终端 2:启动后端
cd backend && npm install && npm run dev
# → 🚀 服务器运行在 http://localhost:2024

# 终端 3:启动前端
cd agent-chat-ui && pnpm install && pnpm dev
# → 打开 http://localhost:3000

11.2 上传文档 & 验证

# 方式 1:curl 命令
curl -X POST http://localhost:2024/upload \
  -F "file=@我的技术文档.pdf"

# 方式 2:通过前端界面上传

# 验证知识库状态
curl http://localhost:2024/knowledge/stats
# {"documentCount": 47, "status": "active"}

第十二章 · 扩展方向:从 60 分到 95 分的路线图

当前版本已经是60 分及格线,以下是从合格到顶尖的升级路径。

12.1 中级优化(80 分):混合检索

向量检索召回率高但精度一般,经常把"语义相关但答案错误"的文档排前面。工业界标准解法是向量 + BM25 双路召回 + RRF 融合:

flowchart LR
    Q[用户查询] --> V[向量检索<br/>Top 20]
    Q --> B[BM25 关键词<br/>Top 20]
    V --> F[RRF 融合排序]
    B --> F
    F --> T[最终 Top 10]
    
    style F fill:#fff4e1

为什么有效:向量擅长语义,BM25 擅长精确关键词(如错误码、产品型号),两者互补。

12.2 高级优化(95 分):Reranker + 查询改写 + 自反思

flowchart LR
    Q[用户查询] --> RW[🔄 查询改写<br/>口语化 → 检索友好]
    RW --> H[混合检索]
    H --> RR[🎯 Reranker<br/>交叉编码器精排]
    RR --> L[LLM 生成]
    L --> SR{🤔 自反思<br/>质量评估}
    SR -->|合格| Ans[✅ 返回答案]
    SR -->|不合格| W[🌐 联网搜索兜底]
    W --> L
    
    style RW fill:#ffe1f5
    style RR fill:#e1f5ff
    style SR fill:#fff4e1

image.png 三大利器作用:

  • 查询改写:用户问"那个啥来着",小模型改写成"产品 X 的退款流程"
  • Reranker:用 bge-reranker 对 Top 20 精排,精度再提 15-20%
  • 自反思:LLM 自己判断检索质量,差的话主动触发联网或二次检索

12.3 进阶功能清单

方向说明难度
多模态支持集成视觉模型,图片内容也能检索⭐⭐⭐
混合检索向量 + BM25 + RRF⭐⭐
Reranker 重排序交叉编码器精排⭐⭐
对话历史摘要长对话自动摘要防超限⭐⭐
文档权限控制多用户 ACL 隔离⭐⭐⭐
Agent 工具调用调计算器、日历、API⭐⭐

第十三章 · 前端工程师的 AI 时代生存法则

2026 年的前端岗位评估标准已经彻底变了

过去我们聊 Vue 响应式、React Fiber、Webpack 5,现在面试更想听你讲:

  • 你怎么把用户意图转成检索友好的 query?
  • 流式响应怎么做打字机动画中断控制?
  • 向量库和传统关系库在事务、一致性上有什么不同?
  • Agent 工作流的错误恢复怎么做?

这些问题没有一个和 DOM 相关,但每一个都是前端应用 AI 化后的必答题。

📖 项目资源地址


写在最后

祝你在 AI 时代的前端之路上,既能写出优雅的代码,也能画出清晰的架构,更能讲出打动人心的技术故事

打个求职信息: 擅长前端架构与工程化,熟练 Vue、React、Taro 及微前端 qiankun,精通组件库、性能优化与前端安全。 同时具备多端开发与 Node 全栈能力,熟练使用各类 AI 编码工具,掌握 Langchain 可开发 AI 应用。 过往负责技术选型与架构设计,带过领团队。

邮箱: sanlangguo1@outlook.com