当上下文窗口越来越长时,向量数据库还需要吗?

1. 总体概述

每一代大型语言模型(LLM)都有更长的上下文窗口。因此许多团队开始提问:如果能把所有文档都放入 prompt,还需要用向量数据库构建 RAG 吗?

本文不会绝对回答“是”或“否”。你会看到两种方法在哪里有所不同。本文包含 Java 代码来说明如何估计 token 预算、一个最小的向量存储来理解检索机制,以及一个简单的策略选择器。读完后,你可以为自己的系统做出决定。

2. 向 LLM 注入知识的两种方式

LLM 不了解你的内部数据,比如退款政策、API 文档或客户订单历史。有两种主要方式将这些数据输入到答案中。

2.1. 带向量数据库的检索

典型的 RAG(检索增强生成)流程包括四个步骤:

  1. 将文档切分成较小的片段(chunk)。
  2. 使用嵌入模型将每个 chunk 转换为数值向量。
  3. 将向量保存到向量数据库中,并使用索引查找最近邻。
  4. 当用户提问时,嵌入问题,获取最相似的前 K 个 chunk,然后将其放入 prompt。

最终的 prompt 只包含几个相关段落,不包含整个文档库。

2.2. 长上下文:“全部塞进 prompt”

这种方法简单得多:将所有文档连接到 prompt 中,让模型自己查找信息。你不需要分块、不需要嵌入、不需要额外的基础设施。

对于小型文档库,这种方法通常会产生很好的结果。该模型看到了完整的上下文,所以可以回答需要综合多个部分的问题,这是 top-K 检索可能遗漏的。

3. 为什么这个问题受到关注

有几个原因使得这场讨论升温:

  • 上下文窗口快速增长。 曾经必须分割的文档库现在可以完全放入单个 prompt 中。
  • Prompt 缓存。 许多提供商允许缓存固定的 prompt 前缀。这样,重复发送相同文档块的成本和延迟会大幅降低。
  • RAG 难以做好。 分块错误、嵌入不适合语言、top-K 遗漏重要段落是非常常见的错误。任何调试过 RAG 管道的团队都希望减少一层复杂性。

长上下文也有其自身的局限。在非常长的 prompt 中,信息检索的质量可能不一致:位于大段文本中间的信息有时会被忽略。成本和延迟仍然随着输入 token 数量而增加。此外,还有一些问题是长上下文无法解决的,比如访问控制和不断变化的数据。下面的部分会逐一讨论这些要点。

4. 从数字开始:估计 token 预算

在选择架构之前,你需要知道你的文档库“有多重” token。下面的示例只使用标准 Java。

import java.util.List;

public class TokenBudget {

    // 粗估计:真实 tokenizer 的结果会有所不同,尤其是对于越南语
    private static final double CHARS_PER_TOKEN = 4.0;

    public static long estimateTokens(String text) {
        return Math.round(text.length() / CHARS_PER_TOKEN);
    }

    public static long estimateCorpus(List<String> documents) {
        return documents.stream().mapToLong(TokenBudget::estimateTokens).sum();
    }

    public static void main(String[] args) {
        List<String> policies = List.of(
            "退款政策:订单出现错误时,客户可以获得退款。",
            "运费根据重量和配送距离计算。",
            "客户账户在多次错误登录后被锁定。"
        );
        long total = estimateCorpus(policies);
        long budget = 8_000; // 你为文档预留的上下文部分
        System.out.printf("Corpus ~%d tokens, budget %d, fits=%b%n",
            total, budget, total <= budget);
    }
}

逐部分解释:

  • CHARS_PER_TOKEN 是一个非常粗糙的启发式方法。越南语带有声调通常比英语消耗更多 token。当需要精确数字时,你应该使用你正在使用的特定模型的 tokenizer。
  • budget 不是整个上下文窗口。你必须为系统 prompt、对话历史和答案留出空间。
  • 三个短段落的结果:
Corpus ~48 tokens, budget 8000, fits=true

如果 fits=true 且文档库很少改变,长上下文是一个值得认真考虑的候选。如果文档库大于预算很多倍,你仍然需要检索,无论上下文窗口有多长。

5. 一个最小向量存储来理解机制

为了看到检索实际做什么,我们编写一个内存中的向量存储。这里的嵌入是“词袋哈希”类型,仅用于演示目的。实际系统将调用嵌入模型。

import java.util.*;

public record Document(String id, String text) {}

public record ScoredDocument(Document document, double score) {}

public class HashingEmbedder {
    private final int dimensions;

    public HashingEmbedder(int dimensions) { this.dimensions = dimensions; }

    public double[] embed(String text) {
        double[] vector = new double[dimensions];
        for (String token : text.toLowerCase().split("\\P{L}+")) {
            if (token.isBlank()) continue;
            vector[Math.floorMod(token.hashCode(), dimensions)] += 1.0;
        }
        double norm = Math.sqrt(Arrays.stream(vector).map(v -> v * v).sum());
        if (norm > 0) for (int i = 0; i < dimensions; i++) vector[i] /= norm;
        return vector;
    }
}

public class InMemoryVectorStore {
    private record Entry(Document document, double[] vector) {}

    private final HashingEmbedder embedder;
    private final List<Entry> entries = new ArrayList<>();

    public InMemoryVectorStore(HashingEmbedder embedder) { this.embedder = embedder; }

    public void add(Document document) {
        entries.add(new Entry(document, embedder.embed(document.text())));
    }

    public List<ScoredDocument> search(String query, int topK) {
        double[] q = embedder.embed(query);
        return entries.stream()
            .map(e -> new ScoredDocument(e.document(), dot(q, e.vector())))
            .sorted(Comparator.comparingDouble(ScoredDocument::score).reversed())
            .limit(topK)
            .toList();
    }

    private static double dot(double[] a, double[] b) {
        double sum = 0;
        for (int i = 0; i < a.length; i++) sum += a[i] * b[i];
        return sum;
    }
}

要点:

  • embed 按非字母字符分割单词(\\P{L}+),因此保留越南语的声调字符。每个单词被哈希到向量的一个维度中。
  • 向量被归一化为长度 1。此时点积(dot)就是余弦相似度。
  • search 遍历所有条目。这是暴力搜索,时间复杂度为 O(n)。实际的向量数据库使用近似索引如 HNSW 来在向量数量很大时保持速度。

试运行:

var store = new InMemoryVectorStore(new HashingEmbedder(1024));
store.add(new Document("refund", "退款政策:订单出现错误时,客户可以获得退款。"));
store.add(new Document("shipping", "运费根据重量和配送距离计算。"));
store.add(new Document("account", "客户账户在多次错误登录后被锁定。"));

store.search("客户想要退款", 2)
     .forEach(r -> System.out.printf("%s -> %.3f%n", r.document().id(), r.score()));
refund -> 0.6xx
shipping -> 0.1xx

具体数字取决于哈希函数。顺序是稳定的:refund 排在第一位因为与问题有许多相同的词。只有这个段落被放入 prompt,而不是全部三个。

注意: 真实嵌入可以理解语义(“还钱”接近“退款”),而词袋哈希不能。不要在生产环境中使用上面的示例。

在生产环境中,如果你已经使用 PostgreSQL,pgvector 扩展通常是在考虑专门的向量数据库之前的合理第一步:

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE policy_chunk (
    id        BIGSERIAL PRIMARY KEY,
    tenant_id BIGINT NOT NULL,
    content   TEXT NOT NULL,
    embedding vector(768)
);

CREATE INDEX ON policy_chunk USING hnsw (embedding vector_cosine_ops);

-- :query_embedding 由应用程序提供
SELECT id, content
FROM policy_chunk
WHERE tenant_id = :tenant_id
ORDER BY embedding <=> :query_embedding
LIMIT 5;

维度 768 必须与你使用的嵌入模型相匹配。操作符 <=> 计算余弦距离。子句 WHERE tenant_id = ... 向我们展示了检索仍然必要的一个重要原因,将在第 8 部分讨论。

6. 结合两种方式:根据数据选择策略

在实践中,答案通常是“取决于文档库”。我们可以将这个决定编成代码,以便整个团队统一标准。

public enum ContextStrategy { FULL_CONTEXT, RETRIEVAL }

public record CorpusProfile(long totalTokens,
                            boolean changesFrequently,
                            boolean needsPerUserAccess) {}

public class ContextStrategySelector {
    private final long documentBudgetTokens;

    public ContextStrategySelector(long documentBudgetTokens) {
        this.documentBudgetTokens = documentBudgetTokens;
    }

    public ContextStrategy choose(CorpusProfile profile) {
        if (profile.needsPerUserAccess()) return ContextStrategy.RETRIEVAL;
        if (profile.totalTokens() > documentBudgetTokens) return ContextStrategy.RETRIEVAL;
        if (profile.changesFrequently() && profile.totalTokens() > documentBudgetTokens / 2) {
            return ContextStrategy.RETRIEVAL; // 数据频繁变化时 prompt 缓存效果不佳
        }
        return ContextStrategy.FULL_CONTEXT;
    }
}

按优先级排列的逻辑:

  • 如果每个用户只能看到数据的一部分,你必须在将其放入 prompt 之前进行筛选。Retrieval 可以自然地做到这一点。
  • 如果文档库大于 token 预算,除了检索没有其他选择。
  • 如果数据频繁变化且已占用相当的预算,prompt 缓存效果不佳,因此检索通常更便宜。
  • 其他情况:为简单起见使用长上下文。

阈值 / 2 只是一个示例。你应该根据自己的数据测量成本和质量,然后进行调整。

7. 测试主要行为

测试依赖项,如果项目使用 Spring Boot parent,则版本已经被管理:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;

class RetrievalAndStrategyTest {

    @Test
    void givenRefundQuestion_whenSearch_thenRefundPolicyRanksFirst() {
        var store = new InMemoryVectorStore(new HashingEmbedder(1024));
        store.add(new Document("refund", "退款政策:订单出现错误时,客户可以获得退款。"));
        store.add(new Document("shipping", "运费根据重量和配送距离计算。"));

        var results = store.search("客户想要退款", 1);

        assertThat(results).hasSize(1);
        assertThat(results.getFirst().document().id()).isEqualTo("refund");
    }

    @Test
    void givenSmallStaticCorpus_whenChoose_thenFullContext() {
        var selector = new ContextStrategySelector(50_000);
        var profile = new CorpusProfile(10_000, false, false);

        assertThat(selector.choose(profile)).isEqualTo(ContextStrategy.FULL_CONTEXT);
    }

    @Test
    void givenPerUserAccess_whenChoose_thenRetrievalEvenIfSmall() {
        var selector = new ContextStrategySelector(50_000);
        var profile = new CorpusProfile(1_000, false, true);

        assertThat(selector.choose(profile)).isEqualTo(ContextStrategy.RETRIEVAL);
    }
}

第三个测试在业务上最重要。即使文档库很小,访问控制要求仍然强制你使用检索。这是长上下文无法解决的一个约束。

8. 何时仍需要向量数据库,何时不需要

在以下情况下仍需要检索(向量数据库或现有数据库中的向量索引):

  • 文档库比上下文窗口大很多倍,例如所有支持 ticket 或整个内部 wiki。
  • 有按用户或租户的访问控制。你不能让模型“看到”用户无权查看的数据,即使仅在 prompt 中。
  • 数据不断更新并需要几乎立即反映。
  • 查询流量高。为每个请求发送巨大的 prompt 使成本和延迟随 token 数量线性增加。
  • 你需要明确引用来源,即知道答案基于哪个 chunk。

可以省略向量数据库的情况:

  • 文档库很小、稳定且完全在 token 预算内,例如产品的一套政策或单个模块的文档。
  • 问题通常需要综合所有文档,如“总结两份合同之间的所有差异”。Top-K 检索在这方面做得不好。
  • 你正在进行原型设计并想在投入基础设施之前验证价值。

也不一定要用向量搜索当查询具有精确性,如订单号、SKU 或函数名。全文搜索或 SQL 查询通常更好。许多系统使用混合搜索,结合关键词和向量。

9. 常见错误

  • 将上下文窗口当作文档预算。 你忘记为系统 prompt、聊天历史和输出留出空间,导致请求在实际环境中被截断或出错。
  • 把一切都放进 prompt 而不评估质量。 Prompt 越长,模型越容易遗漏位于中间的细节。构建一组有标准答案的测试问题,并在你的数据上进行测量。
  • 过早构建专门的向量数据库。 对于数万个 chunk,PostgreSQL 中的向量索引通常就足够了,而且你少了一个要运维的系统。
  • 在检索后过滤权限。 取前 K 个后再按权限过滤可能会丢弃所有有效结果。将权限条件放入查询本身,如第 5 部分的 WHERE tenant_id 子句。
  • 更改嵌入模型而不重新索引。 来自两个不同模型的向量无法相互比较。

注意: 长上下文和检索不是互斥的。一个常见的模式是“慷慨的”检索,取比以前更多的 chunk,然后利用长上下文让模型自己选择。这种方法降低了遗漏风险,同时保持成本在控制范围内。

10. 结论

更长的上下文窗口改变了平衡点,但并没有消除对检索的需求。对于小型且稳定的文档库,“全部塞进 prompt” 通常很简单且足够好。对于大型、不断变化、需要访问控制或流量高的数据,检索仍然是必要的。然而,它不一定非要是一个单独的向量数据库:现有数据库中的向量索引通常就足够了。

实际做法是先测量再决定。估计文档库的 token 数量,确定访问控制要求和更新频率,然后在真实问题集上评估质量。当这些标准被编成代码如 ContextStrategySelector 时,整个团队将有一个明确且可测试的决定。

vector databaseRAGLLMcontext windowJava