技术博客

RAG 开发入门(二):不用向量库,手写一个最小 RAG

先不接向量库,也不引入框架。用 Java 把知识片段、关键词检索、上下文组装和模型调用串起来,先看清楚 RAG 最小闭环。

发布时间

阅读信息

约 15 分钟

主题标签

RAG / Java / 知识库

上一篇讲 RAG 的演化时,反复强调了一件事:RAG 不等于向量数据库。只要模型回答之前先从外部资料里找内容,再把这些内容放进上下文,让模型基于资料回答,这条链路就是 RAG。

所以第二篇先不碰 Embedding,不接向量库,也不引入 Spring AI 或 LangChain4j。先用 Java 手写一个最小版本。

这个版本的目标不是检索效果好,也不是生产可用。它只解决一个问题:把 RAG 这条链路跑起来,并且每一步都看得见。

最小 RAG 闭环

1. 最小 RAG 只需要四个对象

一个最小 RAG 可以拆成四个对象。

第一个是知识片段。真实系统里它来自 Markdown、PDF、Word、网页、接口文档或数据库记录。这里先手写几段,避免把文档解析和切块的问题提前带进来。

第二个是检索器。它的输入是一组关键词,输出是一批候选片段。这里先用最朴素的字符串匹配,不做分词,不做 BM25,也不做向量相似度。

第三个是上下文组装器。它负责把候选片段整理成模型能读的 prompt 上下文,并保留来源编号。

第四个是模型客户端。它只负责把 prompt 发给大模型,再拿回回答。具体接 OpenAI、通义、DeepSeek,还是公司内部网关,都不影响 RAG 主链路。

先把数据结构定下来:

public record DocumentChunk(
        String id,
        String source,
        String title,
        String text
) {
}

public record SearchHit(
        DocumentChunk chunk,
        int score
) {
}

这里的 source 很重要。RAG 不是只要把答案生成出来就行,至少要能知道答案依据来自哪里。后面做引用、权限、版本控制和问题排查,都要靠它。

示例代码按 Java 17 写。如果你还在用 JDK 8,把 record 换成普通类,把文本块换成字符串拼接,把 toList() 换成 collect(Collectors.toList()) 就行。

2. 先手写一份小知识库

为了把链路讲清楚,可以先把知识库写在内存里。

import java.util.List;

public class DemoKnowledgeBase {

    public static List<DocumentChunk> chunks() {
        return List.of(
                new DocumentChunk(
                        "doc-001",
                        "refund-policy.md",
                        "退款失败处理规则",
                        "退款失败时,需要先检查订单状态、支付渠道返回码和退款流水状态。"
                                + "如果退款流水已经成功,不应再次发起退款。"
                ),
                new DocumentChunk(
                        "doc-002",
                        "notification-policy.md",
                        "通知发送幂等规则",
                        "同一业务事件只能触发一次用户通知。通知服务应使用业务事件 ID 做幂等键,"
                                + "重复请求需要直接返回已有发送结果。"
                ),
                new DocumentChunk(
                        "doc-003",
                        "order-callback.md",
                        "订单回调处理规则",
                        "支付渠道回调可能重复到达。订单服务处理回调时,需要先判断当前订单状态,"
                                + "再决定是否更新订单和触发后续事件。"
                )
        );
    }
}

这段代码故意很简单。它没有文件读取,没有数据库,也没有索引。真实项目里这些内容不可能手写,但这一步能帮你先看清楚 RAG 需要的最小数据形态。

一个知识片段至少要有正文,最好还要有标题、来源和 ID。标题用于排序和展示,来源用于引用,ID 用于日志和排查。

3. 检索函数只接收关键词

先写一个最小检索器。

它不接收用户原始问题,只接收关键词。用户问题怎么变成关键词,可以先手动传入,后面再交给 query rewrite 或 Agent。这样检索层的职责更干净:给我关键词,我返回候选片段。

import java.util.Comparator;
import java.util.List;
import java.util.Locale;

public class KeywordRetriever {

    private final List<DocumentChunk> chunks;

    public KeywordRetriever(List<DocumentChunk> chunks) {
        this.chunks = chunks;
    }

    public List<SearchHit> search(List<String> keywords, int topK) {
        return chunks.stream()
                .map(chunk -> new SearchHit(chunk, score(chunk, keywords)))
                .filter(hit -> hit.score() > 0)
                .sorted(Comparator.comparingInt(SearchHit::score).reversed())
                .limit(topK)
                .toList();
    }

    private int score(DocumentChunk chunk, List<String> keywords) {
        int score = 0;
        String title = normalize(chunk.title());
        String text = normalize(chunk.text());

        for (String keyword : keywords) {
            String word = normalize(keyword);
            if (word.isBlank()) {
                continue;
            }
            if (title.contains(word)) {
                score += 5;
            }
            score += count(text, word);
        }
        return score;
    }

    private int count(String text, String word) {
        int count = 0;
        int index = text.indexOf(word);
        while (index >= 0) {
            count++;
            index = text.indexOf(word, index + word.length());
        }
        return count;
    }

    private String normalize(String value) {
        return value == null ? "" : value.toLowerCase(Locale.ROOT);
    }
}

这个检索器很粗糙,但它已经有了检索系统最基本的样子。

标题命中权重大一点,正文命中按次数加分,最后按分数排序。你可以马上看到每个候选片段为什么被召回,也能通过日志确认关键词是不是选错了。

对最小 RAG 来说,这比一上来接向量库更有价值。向量库会把很多细节藏起来,你只看到相似度分数,却不一定知道为什么这个片段被召回,为什么另一个片段没被召回。

4. 把候选片段组装成上下文

检索结果不能直接丢给模型。模型需要的是一段结构清楚、边界明确、能引用来源的上下文。

可以先写一个很朴素的组装器:

import java.util.List;

public class ContextBuilder {

    public String build(List<SearchHit> hits, int maxChars) {
        StringBuilder context = new StringBuilder();

        for (int i = 0; i < hits.size(); i++) {
            DocumentChunk chunk = hits.get(i).chunk();
            String block = """
                    [资料%d]
                    来源:%s
                    标题:%s
                    内容:%s

                    """.formatted(
                    i + 1,
                    chunk.source(),
                    chunk.title(),
                    chunk.text()
            );

            if (context.length() + block.length() > maxChars) {
                break;
            }
            context.append(block);
        }

        return context.toString();
    }
}

这里用了字符数限制,不是 token 限制。生产系统当然应该按模型 tokenizer 估算 token,但最小版本先不用把问题做复杂。字符数限制至少能让你意识到一件事:检索到的内容不等于都能塞进模型上下文。

上下文里保留 [资料1][资料2] 这种编号,是为了让模型回答时能引用证据。引用不一定能完全阻止幻觉,但它能让答案更容易检查。用户看到回答后,也可以回到原始资料确认。

5. Prompt 要把回答边界写清楚

RAG 里最容易出问题的地方,不是模型完全不知道答案,而是模型拿到一点资料后开始补全。

所以 prompt 里要明确告诉模型:只能根据资料回答。资料里没有,就说没有找到足够依据。

public class RagPrompt {

    public String build(String question, String context) {
        return """
                你是企业知识库问答助手。

                请只根据 <context> 中的资料回答用户问题。
                如果资料中没有足够依据,请回答:资料里没有找到足够依据。
                回答时尽量简洁,并在关键结论后标注资料编号,例如:[资料1]。

                <context>
                %s
                </context>

                用户问题:%s
                """.formatted(context, question);
    }
}

这段 prompt 仍然不是生产级的。它没有处理权限,没有处理多轮对话,也没有做答案校验。但它已经把 RAG 的生成边界表达出来了:答案应该来自上下文,而不是来自模型自己猜。

很多人调 RAG 时只调检索,不调 prompt,最后会遇到一种问题:片段已经搜到了,模型还是自由发挥。这个时候问题不一定在检索层,也可能是上下文和指令没有把边界写清楚。

6. 把链路串起来

模型客户端先抽成一个接口。

public interface ChatClient {

    String complete(String prompt);
}

这个接口背后可以接任何模型 SDK。文章里不展开具体 HTTP 调用,是因为不同公司接入方式差异很大。有人直连模型厂商,有人走统一网关,有人接内部私有化模型。RAG 主链路不应该依赖某个 SDK 才能讲清楚。

最后写一个 RagService

import java.util.List;

public class RagService {

    private final KeywordRetriever retriever;
    private final ContextBuilder contextBuilder;
    private final RagPrompt ragPrompt;
    private final ChatClient chatClient;

    public RagService(
            KeywordRetriever retriever,
            ContextBuilder contextBuilder,
            RagPrompt ragPrompt,
            ChatClient chatClient
    ) {
        this.retriever = retriever;
        this.contextBuilder = contextBuilder;
        this.ragPrompt = ragPrompt;
        this.chatClient = chatClient;
    }

    public String answer(String question, List<String> keywords) {
        List<SearchHit> hits = retriever.search(keywords, 5);
        if (hits.isEmpty()) {
            return "资料里没有找到足够依据。";
        }

        String context = contextBuilder.build(hits, 6000);
        String prompt = ragPrompt.build(question, context);
        return chatClient.complete(prompt);
    }
}

调用时可以这样写:

List<DocumentChunk> chunks = DemoKnowledgeBase.chunks();
KeywordRetriever retriever = new KeywordRetriever(chunks);

RagService ragService = new RagService(
        retriever,
        new ContextBuilder(),
        new RagPrompt(),
        prompt -> {
            // 这里替换成真实模型调用。
            // 比如调用公司内部 Chat API,并把 prompt 作为用户消息发送。
            return "这里是模型返回结果";
        }
);

String answer = ragService.answer(
        "为什么这个订单退款失败,用户又收到了两次通知?",
        List.of("退款失败", "通知", "幂等", "订单回调")
);

到这里,一个最小 RAG 就跑通了。

它没有向量库,没有 Embedding,没有 rerank,也没有 Agent。可是它已经有了 RAG 的主干:外部资料、检索、上下文、生成。

7. 先把日志打出来

最小版本最好马上加日志。

public String answer(String question, List<String> keywords) {
    List<SearchHit> hits = retriever.search(keywords, 5);

    System.out.println("question = " + question);
    System.out.println("keywords = " + keywords);
    for (SearchHit hit : hits) {
        System.out.printf(
                "hit id=%s, source=%s, score=%d%n",
                hit.chunk().id(),
                hit.chunk().source(),
                hit.score()
        );
    }

    if (hits.isEmpty()) {
        return "资料里没有找到足够依据。";
    }

    String context = contextBuilder.build(hits, 6000);
    String prompt = ragPrompt.build(question, context);
    return chatClient.complete(prompt);
}

RAG 出错时,日志至少要能回答四个问题。

用户问了什么。系统拿什么关键词去搜。搜到了哪些片段。最后哪些片段进了 prompt。

没有这些日志,你只能看到一个错误答案,然后猜它到底错在哪里。可能是关键词不对,可能是文档没切好,可能是召回结果没排上来,也可能是模型拿到了证据还是答偏了。

这也是手写最小 RAG 的价值。它不会让系统马上变聪明,但会让问题变得可观察。

8. 这个版本会很快暴露问题

这个最小版本跑起来之后,很快会暴露几个问题。

第一个问题是关键词太脆。用户问“重复通知”,资料里写的是“通知幂等”,如果关键词里没有“幂等”,可能就搜不到关键片段。后面可以用同义词、分词、BM25、query rewrite 或 Agent 生成关键词来解决。

第二个问题是知识片段太理想。真实文档不会像示例代码这么干净。PDF 可能有页眉页脚,表格可能被拆乱,接口文档可能有大量字段说明。文档不处理好,检索器拿到的就是一堆噪音。

第三个问题是上下文长度有限。检索结果多了以后,不可能全部塞给模型。哪些片段进上下文,按什么顺序进,重复内容怎么去掉,引用怎么保留,都会影响回答质量。

第四个问题是模型仍然可能越界。即使 prompt 写了“只能根据资料回答”,模型也可能补充上下文里没有的内容。生产系统通常还要做答案校验、引用检查和人工反馈闭环。

这些问题不是最小 RAG 的失败,反而是它应该暴露出来的东西。只有先跑通最小链路,后面谈文档预处理、向量检索、混合检索、rerank 和 Agentic RAG 才有具体落点。

9. 下一步先补文档预处理

这篇故意把知识库写在内存里,是为了先看清楚 RAG 的主链路。

真实系统不会这么做。真实系统要从文件、网页、数据库、工单、接口文档里抽取内容,再清洗、切块、加元数据、绑定权限,最后写入索引。

这一步经常比接向量库更重要。文档解析错了,切块切坏了,来源和版本丢了,后面的检索和生成都会跟着出问题。

所以下一篇先讲文档预处理。等文档块变得可靠,再引入 Embedding 和向量库,才不会把一个数据质量问题误判成模型能力问题。

RAGJava知识库后端工程
上一篇 AI 帮你写的网站,为什么发给朋友就打不开 下一篇 RAG 开发入门(一):从关键词检索到 Agentic RAG