RAG 的 Java 落地:从切分、入库到检索问答
模型不知道你公司的文档,微调又贵又不划算,RAG 是那条正路:回答之前先检索相关片段塞进上下文。概念早就不神秘了,问题是怎么在 Java 里落地。这篇把五步流水线的骨架用零依赖代码跑通,先把形状看懂,再谈换向量库。
流水线五步
#mermaid-svg-x7YqAVBAUwmflCXR{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-x7YqAVBAUwmflCXR .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-x7YqAVBAUwmflCXR .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-x7YqAVBAUwmflCXR .error-icon{fill:#552222;}#mermaid-svg-x7YqAVBAUwmflCXR .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-x7YqAVBAUwmflCXR .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-x7YqAVBAUwmflCXR .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-x7YqAVBAUwmflCXR .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-x7YqAVBAUwmflCXR .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-x7YqAVBAUwmflCXR .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-x7YqAVBAUwmflCXR .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-x7YqAVBAUwmflCXR .marker{fill:#333333;stroke:#333333;}#mermaid-svg-x7YqAVBAUwmflCXR .marker.cross{stroke:#333333;}#mermaid-svg-x7YqAVBAUwmflCXR svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-x7YqAVBAUwmflCXR p{margin:0;}#mermaid-svg-x7YqAVBAUwmflCXR .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-x7YqAVBAUwmflCXR .cluster-label text{fill:#333;}#mermaid-svg-x7YqAVBAUwmflCXR .cluster-label span{color:#333;}#mermaid-svg-x7YqAVBAUwmflCXR .cluster-label span p{background-color:transparent;}#mermaid-svg-x7YqAVBAUwmflCXR .label text,#mermaid-svg-x7YqAVBAUwmflCXR span{fill:#333;color:#333;}#mermaid-svg-x7YqAVBAUwmflCXR .node rect,#mermaid-svg-x7YqAVBAUwmflCXR .node circle,#mermaid-svg-x7YqAVBAUwmflCXR .node ellipse,#mermaid-svg-x7YqAVBAUwmflCXR .node polygon,#mermaid-svg-x7YqAVBAUwmflCXR .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-x7YqAVBAUwmflCXR .rough-node .label text,#mermaid-svg-x7YqAVBAUwmflCXR .node .label text,#mermaid-svg-x7YqAVBAUwmflCXR .image-shape .label,#mermaid-svg-x7YqAVBAUwmflCXR .icon-shape .label{text-anchor:middle;}#mermaid-svg-x7YqAVBAUwmflCXR .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-x7YqAVBAUwmflCXR .rough-node .label,#mermaid-svg-x7YqAVBAUwmflCXR .node .label,#mermaid-svg-x7YqAVBAUwmflCXR .image-shape .label,#mermaid-svg-x7YqAVBAUwmflCXR .icon-shape .label{text-align:center;}#mermaid-svg-x7YqAVBAUwmflCXR .node.clickable{cursor:pointer;}#mermaid-svg-x7YqAVBAUwmflCXR .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-x7YqAVBAUwmflCXR .arrowheadPath{fill:#333333;}#mermaid-svg-x7YqAVBAUwmflCXR .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-x7YqAVBAUwmflCXR .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-x7YqAVBAUwmflCXR .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-x7YqAVBAUwmflCXR .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-x7YqAVBAUwmflCXR .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-x7YqAVBAUwmflCXR .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-x7YqAVBAUwmflCXR .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-x7YqAVBAUwmflCXR .cluster text{fill:#333;}#mermaid-svg-x7YqAVBAUwmflCXR .cluster span{color:#333;}#mermaid-svg-x7YqAVBAUwmflCXR div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-x7YqAVBAUwmflCXR .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-x7YqAVBAUwmflCXR rect.text{fill:none;stroke-width:0;}#mermaid-svg-x7YqAVBAUwmflCXR .icon-shape,#mermaid-svg-x7YqAVBAUwmflCXR .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-x7YqAVBAUwmflCXR .icon-shape p,#mermaid-svg-x7YqAVBAUwmflCXR .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-x7YqAVBAUwmflCXR .icon-shape .label rect,#mermaid-svg-x7YqAVBAUwmflCXR .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-x7YqAVBAUwmflCXR .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-x7YqAVBAUwmflCXR .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-x7YqAVBAUwmflCXR :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 原始文档
切分
大文档变小块
表示
每块变成可检索的形态
入库
存进索引或向量库
用户问题
检索
按相似度取 top-k
拼上下文
塞进 prompt
模型回答
后端的直觉在这里全用得上:切分是分页,表示是编码,入库是索引,检索是查询,拼上下文是请求体组装。整条流水线就是一个「带检索的查询服务」。
切分的账
块太大会把无关内容塞进窗口,稀释相关性;块太小会把完整的语义切碎,检索到半句话。经验值是几百字一块,块与块之间留一点重叠,让被切在边界的知识有机会被完整检索。切分的粒度跟着文档类型走:规章条文按条切,代码按函数切,散文按段落切。
检索的两条腿
第一条腿,关键词打分。最朴素的做法是词频重合度,查询词在块里出现得越多越相关。优点是零依赖、可解释,缺点是同义词和语序全丢,「狗」检索不到「犬」。
第二条腿,向量相似。把块和查询都编码成向量,比余弦相似度。语义近的块距离近,同义词问题自然消解。代价是要 embedding 模型、要向量库、要算力。骨架阶段先用关键词腿跑通流程,流程对了再换向量腿;两条腿的接口是一样的,检索这个动作的输入输出没变。
拼上下文的预算
检索回来的块不是全塞,按相似度从高到低,塞到预算满为止,这就是之前装箱篇的场景再现。还有一件小事收益巨大:把每块的来源编号带上,回答里让模型引用编号,用户能核对,幻觉当场收敛。
亲手跑
下面用一段 Java 跑完整骨架,切块、词频打分、取 top-k、拼上下文、模拟回答,零依赖。直接运行 java RagSkeleton.java(JDK 11+)。
java
// 直接运行:java RagSkeleton.java
// RAG 骨架:切块、打分、检索、拼上下文,零依赖。
import java.util.ArrayList;
import java.util.List;
public class RagSkeleton {
static class Chunk {
final int id;
final String text;
Chunk(int id, String text) {
this.id = id;
this.text = text;
}
}
// 第一步:切分,演示按句号切
static List<Chunk> split(String doc) {
List<Chunk> chunks = new ArrayList<>();
int id = 1;
for (String part : doc.split("。")) {
if (!part.isBlank()) {
chunks.add(new Chunk(id++, part.trim() + "。"));
}
}
return chunks;
}
// 第二步:检索,二元组打分的最简版(中文零依赖的粗切词)
static List<String> tokenize(String text) {
List<String> grams = new ArrayList<>();
for (int i = 0; i < text.length() - 1; i++) {
grams.add(text.substring(i, i + 2));
}
return grams;
}
static List<Chunk> retrieve(String query, List<Chunk> chunks, int topK) {
List<String> grams = tokenize(query);
List<Chunk> ranked = new ArrayList<>(chunks);
ranked.sort((a, b) -> Integer.compare(score(grams, b), score(grams, a)));
return ranked.subList(0, Math.min(topK, ranked.size()));
}
static int score(List<String> grams, Chunk c) {
int s = 0;
for (String g : grams) {
if (c.text.contains(g)) {
s++;
}
}
return s;
}
// 第三步:拼上下文,带来源编号
static String buildContext(List<Chunk> hits) {
StringBuilder sb = new StringBuilder();
for (Chunk c : hits) {
sb.append("[").append(c.id).append("] ").append(c.text).append("\n");
}
return sb.toString();
}
public static void main(String[] args) {
String doc = "退款规则:七天内无理由退款。超期按比例折算。"
+ "运费规则:满 99 包邮。偏远地区加收 20 元。"
+ "发票规则:订单完成后 30 天内可开电子发票。";
List<Chunk> chunks = split(doc);
String query = "键盘退款怎么算";
List<Chunk> hits = retrieve(query, chunks, 2);
String context = buildContext(hits);
System.out.println("== 检索到的块 ==");
System.out.println(context);
System.out.println("== 拼进 prompt 的形状 ==");
System.out.println("请根据以下资料回答,引用编号:\n" + context + "问题:" + query);
System.out.println("模型的回答会带着 [编号],幻觉有据可查。");
}
}
记住一句话:RAG 是带检索的查询服务,先跑通形状,再谈换昂贵的腿。
适用边界
这一篇管骨架与形状。Embedding 的选型、向量库(Milvus、pgvector)的接入、检索质量的评估(命中率、忠实度),以及重排序(Rerank)这层加速器,是另外的题。词频打分是最简腿,只配当骨架,别在生产里裸奔。跑完骨架,把你的文档喂进来,先看检索命中率;命中率不够再考虑向量腿,顺序别反。