首页 / 文章 / 你可能不需要向量数据库来做RAG
← 返回
AI技术

你可能不需要向量数据库来做RAG

✍️ zhirenhun 📅 2026/7/14 👁 38 阅读 ⏱ 21 分钟
你可能不需要向量数据库来做RAG

你可能不需要向量数据库来做RAG

来源:dev.to/arthurpro


提到“RAG”,脑海中会浮现一幅特定画面:一个嵌入模型、一个像Pinecone或pgvector这样的向量数据库,以及每次查询都要调用的嵌入API。这感觉就像入场券——真正的基础设施、真实的账单、实际的运维面——仅仅为了让聊天机器人从你的文档中回答问题。

对于许多项目来说,这种画面有些大材小用。RAG(检索增强生成)的核心本质上是三个步骤:找到与问题相关的文本,将其粘贴到提示中,让模型据此回答。这个定义中没有任何内容说“查找”步骤必须是向量搜索。如果你的知识库覆盖了一个词汇一致的专注领域,纯关键词匹配通常能检索到与向量搜索相同的文本块——无需嵌入、无需向量存储、无需额外的网络跳转,甚至无需数据库。本文将介绍如何构建这样的系统,同样重要的是,何时简单版本不再够用。

关键词检索何时真正足够好

语义搜索——向量存储中的嵌入——因其处理模糊语言的能力而闻名。它能理解“情绪低落”和“抑郁”相关,“解雇员工”意味着“开除”。当用户用文档中未出现的词语表达时,嵌入技术弥合了差距。

但许多知识库没有这个问题。在专注领域——特定法律领域、医学专科、某个产品的文档——用户倾向于使用该领域自身的术语提问,因为这些术语正是主题本身的内容。当查询和正确段落共享实际词汇时,关键词重叠就能找到它们。而且关键词检索带来了向量无法做到的东西:它是确定性的(相同查询总是返回相同文本块),不需要额外基础设施,也没有每次查询的嵌入调用增加延迟和成本。这种权衡是真实的,且双向作用——你放弃了同义词理解,换来了简单性、速度和可预测性。对于狭窄的语料库,这通常是值得的权衡。

构建检索器

整个检索器很小。三步走:将文档分块,提前将每个块转换为关键词集,然后根据这些集对传入查询进行评分。

按意义分块,而不是按盲目长度。不要将文档切成固定的token窗口,而是按结构拆分——例如markdown的##标题——这样每个块都是一个带有标题的连贯部分。标题本身就是一个强大的关键词信号。

在构建时进行分词和预计算关键词。将文本小写化,拆分成单词,去除停用词和非常短的token,并去重。在构建索引时执行一次,而不是每次请求都做:

const STOPWORDS = new Set(["the", "a", "an", "and", "or", "but", "in", "on", / … /]);

function tokenize(text: string): string[] {
  const words = text.toLowerCase().match(/[a-z]+/gi) ?? [];
  return [...new Set(words.filter(w => w.length > 2 && !STOPWORDS.has(w)))];
}

使用Jaccard相似度评分。对于查询,将其token集与每个块预计算的关键词集进行比较:交集大小除以并集大小。共享单词越多,分数越高。保留分数非零的前K个块。

function retrieve(query: string, chunks: Chunk[], topK = 3): Chunk[] {
  const q = new Set(tokenize(query));
  if (q.size === 0) return [];

  return chunks
    .map(chunk => {
      const overlap = [...q].filter(t => chunk.keywords.includes(t)).length;
      const union = new Set([...q, ...chunk.keywords]).size;
      return { chunk, score: union ? overlap / union : 0 };
    })
    .filter(c => c.score > 0)
    .sort((a, b) => b.score - a.score)
    .slice(0, topK)
    .map(c => c.chunk);
}

这就是整个检索引擎。如果你希望在更长查询上获得更好的排名,可以将Jaccard替换为BM25——搜索引擎使用了几十年的经典关键词排名函数——它对稀有术语赋予更高权重,并更好地处理文档长度,同时保持纯关键词计算,无需嵌入。

调整检索内容

两个旋钮决定检索质量,且它们相互制约。块大小:拆分太粗,匹配的块中大部分是不相关的文本,会稀释提示;拆分太细,你会失去使段落有意义的周围上下文。按标题分节通常落在合理的中间地带,但如果你的部分很长,考虑进一步拆分。Top-K:注入多少块。太少会错过相关块;太多会用噪声淹没答案并消耗token。对于专注的机器人,两到三个匹配良好的块通常是理想选择。

看一个完整的查询示例会有所帮助。假设问题是“为什么我对亲近的人感到愤怒”,token(去除停用词后)是{angry, people, close},而相关概念的一个块有关键词{anger, shadow, projection, close, relationships}。重叠是{close}——很薄,提醒我们精确token匹配是字面的:“angry”和“anger”不匹配,除非你处理词形(下文会详述)。当匹配良好时,你通过将块及其来源粘贴在一起组装提示,这样模型可以基于其回答并引用:

相关文章(引用这些):
[来源1]:愤怒与阴影 → https://example.org/wiki/shadow/
> 我们拒绝的自我部分往往以对他人的烦躁形式浮现...

用户问题:为什么我对亲近的人感到愤怒?

消除数据库的技巧:将知识内嵌到包中

这是实现零基础设施的关键一步。在构建时,生成一个源文件——比如knowledge.ts——包含你的块及其预计算关键词作为普通数组,并将其导入应用。当你部署时,整个知识库随代码包一起部署并驻留在内存中。搜索是内存中的数组扫描:无需数据库连接,无需网络调用,结果在个位数毫秒内返回。

docs/*/.md  ──►  build-knowledge脚本  ──►  knowledge.ts (块 + 关键词)
│
导入到应用中,
部署时加载到内存

这听起来有些鲁莽,但在适当规模下效果很好。几百个块和几百KB对于现代运行时来说微不足道。不过要诚实地看待上限:这种方法只在知识库足够小、能舒适地放入部署包和内存时才有效。Serverless平台限制包大小(免费层通常约1MB左右),你也不希望在worker中持有几百MB的文本。小型、稳定的语料库:嵌入它,享受零运维的搜索。大型或快速增长语料库:这是你已超出此方法的信号之一。

连接其余部分,无需后端

检索器是有趣的部分;周围的机器人主要是小而谨慎的组件。在serverless边缘平台(这里用Cloudflare Workers)上构建,延续了无后端的主题,但带来了一些值得了解的约束。

状态存在于函数之外。边缘函数是无状态的——每个请求都是全新的——因此对话历史必须存储在外部。键值存储(Cloudflare KV)是自然的选择:每个用户一个键,限制存储的消息数量,并设置TTL使旧会话自动过期。

async function addMessage(kv: KVNamespace, userId: number, msg: Message) {
  const history = JSON.parse((await kv.get(`session:${userId}`)) ?? "[]");
  history.push(msg);
  await kv.put(`session:${userId}`, JSON.stringify(history.slice(-20)), {
    expirationTtl: 86400 * 7, // 7天
  });
}

使用fetch调用模型,而非笨重的SDK。边缘运行时并非完整的Node.js环境,大型供应商SDK可能无法运行或引入大量依赖。由于大多数提供商都提供兼容OpenAI的端点,一个轻量级的fetch封装比任何SDK都更稳健——只需更改URL即可切换提供商:

const res = await fetch("https://api.groq.com/openai/v1/chat/completions", {
  method: "POST",
  headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
  body: JSON.stringify({ model, messages, temperature: 0.7, max_tokens: 2048 }),
});

将检索到的文本块连同来源注入,以便模型能够引用。为每个文本块标注标题和指向来源的URL,然后将其与问题一同交给模型——并指示它仅引用提供的URL,绝不捏造来源。

最后补充两个不太明显的经验教训。首先,在基于webhook的机器人(如Telegram)中,始终返回HTTP 200——即使内部发生错误。非2xx状态码会让平台认为消息未送达,从而触发带退避的重试机制,导致函数被重复更新淹没。其次,在历史记录中存储原始用户消息,而非塞满检索上下文的版本。如果保存了增强后的提示,每个后续对话轮次都会拖入整篇文章,几次交互后就会撑爆模型的上下文窗口。每次对话轮次都重新检索;只记住用户实际说过的话。

更新知识只需两条命令

将知识直接打包到代码中的一个隐性巨大优势:更新变得极其简单。添加或编辑文档,然后重新构建和部署。

build   # 根据文档重新生成knowledge.ts
deploy  # 部署更新后的代码包

无需迁移、无需重新索引、无需重新运行嵌入批处理并为此付费。如果文档存放在独立仓库中,CI任务可以在每次变更时自动重建和部署,因此编辑一个markdown文件就足以更新机器人所知的内容。相比之下,向量路径中,更改分块或嵌入模型意味着需要重新嵌入整个语料库。

判断何时足够好——量化评估

"关键词检索在窄领域内表现良好"这一说法应通过实际语料验证,而非盲目相信。验证方法成本低廉:编写几十个真实问题,并为每个问题标注预期应被检索到的文档。通过检索器运行这些问题,统计正确来源出现在top-K中的次数——这就是命中率。每当更改分块、分词或top-K参数时重复此测试,就能明确知道调整是否真正有效,而非凭猜测。

同样的测试框架也是本文整体决策的信号指标。如果关键词检索在测试问题中达到90%以上的命中率,那就够了——无需向量。如果遗漏较多,在转向嵌入之前先分析遗漏原因:通常失败源于少数词汇不匹配,这类问题有比向量存储更廉价的解决方案。

在转向向量之前尝试更廉价的方案

关键词匹配失败时,通常是因为用户用词与文档用词是同一概念的不同表达形式,而非不同概念。无需嵌入即可弥补大部分差距:

  • 词干提取或词形还原。在匹配前将单词还原为词根,使"angry"、"anger"和"angrier"归并为同一词元,"running"/"ran"/"runs"同理。标准词干提取器是一个小型库,能将许多近似匹配转化为精确匹配。
  • 小型同义词映射。对于聚焦领域,通常已知少数关键等价关系——用它们扩展查询(或文本块关键词)。将"fired"映射为"termination",产品昵称映射为正式名称,日常用语映射为专业术语。几十个条目就能在特定术语上超越通用嵌入模型。
  • BM25替代简单Jaccard。如前所述,BM25通过加权稀有、独特的术语,在较长查询上排序效果更佳。

这些是阶梯上的中间层级。它们保持确定性和零基础设施,同时恢复朴素关键词匹配丢失的大部分能力——在引入向量存储之前,值得充分探索这些方案。

何时确实需要向量

本文并非反对向量搜索——而是反对默认选择向量搜索。存在明确信号表明已进入嵌入技术真正发挥价值的领域:

信号关键词(Jaccard/BM25)向量/混合搜索
用户使用领域专有术语✅ 效果优秀过度设计
同义词密集或模糊查询("感到困顿")遗漏✅ 理解语义
语料可放入部署包/内存✅ 直接嵌入代码
大型或快速增长语料无法扩展✅ 需要真实存储
跨语言检索效果弱✅ 嵌入技术优势明显
需要确定性/零基础设施/零成本增加基础设施和每次查询成本

当真正需要升级时,无需放弃已有成果:混合搜索——关键词(BM25)与向量结合,分数合并——始终优于单一方案,是标准生产级答案。而且通常可留在同一平台:边缘提供商可能提供托管向量索引(Cloudflare的Vectorize)和嵌入模型(Workers AI),紧邻正在运行的函数,因此"添加向量"不意味着"添加新供应商"。

如果使用Go自托管,我个人在多个项目中采用gosqlite.com(纯Go、无CGo的github.com/go-again/sqlite包),原因相同——它将向量搜索、BM25全文检索和混合排名整合到应用已使用的同一SQLite文件中,因此"添加向量"仍是单进程变更,而非引入新供应商或新守护进程。

最后一点责任说明:由于"从知识库回答问题"的机器人越来越多地涉及敏感领域,如果您的机器人涉及健康、心理健康或任何用户可能处于危机状态的场景,请构建明确的安全路径,提供真实求助资源,而非依赖模型即兴发挥。这是设计需求,而非锦上添花——且与检索方式无关。

重新审视

"RAG需要向量数据库"成为普遍认知的原因,在于让RAG声名鹊起的演示都基于此构建。但演示的架构并非定律。RAG只是"检索,然后生成",而检索是一个光谱:一端是内存中针对几百个文本块的关键词匹配,嵌入代码中;另一端是管理数百万文档的向量索引。从简单端开始。对于聚焦语料,廉价、确定、零基础设施的版本在答案质量上通常与昂贵版本难以区分——当领域语言变得模糊或语料规模变大时,随时可以升级到向量方案。为那套机制付费,应在问题真正需要时,而非因为某个图表告诉你RAG必须长成那样。


原文:https://dev.to/arthurpro/you-probably-dont-need-a-vector-database-for-rag-3op

——

🧑‍💻

zhirenhun

一个热爱技术的程序员,喜欢分享前沿AI知识和开发经验。

RAG LLM AI 向量检索
← 上一篇
一个梯度尖峰,六批数据后出现NaN:调试确定性神经网络训练失败
下一篇 →
提示词为何失败(以及如何修正)

📌 相关推荐

如何阻止Claude说“承重”之类的话
2026/7/15
LogiChat:从问答对到文档RAG的架构重构
2026/7/15
AI时代的可观测性设计(第二部分):PII防护与自愈
2026/7/15
← 返回文章列表