RAG 开发入门(二):不用向量库,手写一个最小 RAG
先不接向量库,也不引入框架。用 Java 把知识片段、关键词检索、上下文组装和模型调用串起来,先看清楚 RAG 最小闭环。
上一篇讲 RAG 的演化时,反复强调了一件事:RAG 不等于向量数据库。只要模型回答之前先从外部资料里找内容,再把这些内容放进上下文,让模型基于资料回答,这条链路就是 RAG。
所以第二篇先不碰 Embedding,不接向量库,也不引入 Spring AI 或 LangChain4j。先用 Java 手写一个最小版本。
这个版本的目标不是检索效果好,也不是生产可用。它只解决一个问题:把 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 和向量库,才不会把一个数据质量问题误判成模型能力问题。