【知识获取与分享社区项目 | 项目日记第 17 天】向量索引如何构建:Markdown 分块、指纹检测与单版本重建

一、索引构建是 RAG 的地基
上一篇讲了用户提问时的完整链路:
索引检查 → 向量检索 → Prompt 构造 → DeepSeek 流式生成
但 RAG 系统想要回答得准,前提是向量库中已经有高质量的文档切片。
如果切片太粗,召回不精准。
如果切片太碎,上下文容易断裂。
如果旧版本没有删除,模型可能基于过期内容回答。
所以项目中专门设计了 RagIndexService,负责把一篇知文从 Markdown 原文变成可检索的向量文档。
索引构建流程如下:
查询知文详情
↓
判断是否 published + public
↓
读取 contentUrl 中的 Markdown 内容
↓
按 Markdown 标题和长度分块
↓
检查 SHA256 / ETag 指纹
↓
删除旧切片
↓
写入新切片到向量库
二、索引服务入口
索引服务位于:
src/main/java/com/tongji/llm/rag/RagIndexService.java
其中对外最核心的方法是:
public void ensureIndexed(long postId) {
reindexSinglePost(postId);
}
以及:
public int reindexSinglePost(long postId) {
KnowPostDetailRow row = knowPostMapper.findDetailById(postId);
if (row == null) {
return 0;
}
if (!"published".equalsIgnoreCase(String.valueOf(row.getStatus()))
|| !"public".equalsIgnoreCase(String.valueOf(row.getVisible()))) {
return 0;
}
String contentUrl = row.getContentUrl();
if (contentUrl == null || contentUrl.isBlank()) {
return 0;
}
String currentSha = row.getContentSha256();
String currentEtag = row.getContentEtag();
if (isUpToDate(postId, currentSha, currentEtag)) {
return 0;
}
String text = fetchContent(contentUrl);
if (text == null || text.isBlank()) {
return 0;
}
List<String> chunks = chunkMarkdown(text);
if (chunks.isEmpty()) {
return 0;
}
deleteExistingChunks(postId);
List<Document> docs = new ArrayList<>();
for (int i = 0; i < chunks.size(); i++) {
String chunk = chunks.get(i);
Map<String, Object> metadata = new HashMap<>();
metadata.put("postId", String.valueOf(postId));
metadata.put("chunkId", postId + "#" + i);
metadata.put("position", i);
metadata.put("contentEtag", currentEtag);
metadata.put("contentSha256", currentSha);
metadata.put("contentUrl", contentUrl);
metadata.put("title", row.getTitle());
docs.add(new Document(chunk, metadata));
}
vectorStore.add(docs);
return docs.size();
}
这段代码基本就是 RAG 索引构建的主流程。
它没有把所有文章都索引,而是只索引满足条件的知文:
"published".equalsIgnoreCase(row.getStatus())
"public".equalsIgnoreCase(row.getVisible())
也就是说,只有公开且已发布的内容才进入 RAG 问答系统。
这对于内容平台很重要。
草稿、私密文章、未发布内容都不应该被向量化后用于问答。
三、从数据库中读取知文内容信息
索引服务首先通过 Mapper 查询知文详情:
KnowPostDetailRow row = knowPostMapper.findDetailById(postId);
它需要的不是只有标题,还包括:
contentUrlcontentEtagcontentSha256statusvisibletitle
对应 SQL 中会读取知文内容地址和内容指纹:
<select id="findDetailById" resultType="com.tongji.knowpost.model.dto.KnowPostDetailRow">
SELECT
p.id,
p.creator_id AS creatorId,
p.title,
p.description,
p.tags,
p.img_urls AS imgUrls,
p.content_url AS contentUrl,
p.content_etag AS contentEtag,
p.content_sha256 AS contentSha256,
p.visible,
p.type,
p.status
FROM know_posts p
WHERE p.id = #{id}
</select>
这里的几个字段和 RAG 强相关:
| 字段 | 作用 |
|---|---|
content_url |
Markdown 正文在 OSS 中的访问地址 |
content_etag |
对象存储返回的内容版本标识 |
content_sha256 |
前端或服务端计算的内容哈希 |
status |
判断是否已发布 |
visible |
判断是否公开 |
title |
作为向量文档 metadata |
这也说明 RAG 系统不是孤立存在的,它依赖发布系统中保存的内容元数据。
四、为什么要用 contentUrl 拉取 Markdown
发布系统采用的是:
后端生成预签名 URL
↓
前端直传 OSS
↓
后端保存内容地址和元数据
所以 Markdown 正文不是直接存在 MySQL 大字段里,而是存储在 OSS 对象存储中。
RAG 索引构建时,会根据 contentUrl 拉取文章正文:
private String fetchContent(String contentUrl) {
try {
return http.getForObject(contentUrl, String.class);
} catch (Exception e) {
log.warn("Fetch content failed: {}", contentUrl, e);
return null;
}
}
这套设计的好处是:
- MySQL 不存大文本,表更轻;
- 发布系统和 RAG 系统通过
contentUrl解耦; - OSS 中的 Markdown 原文可以直接作为索引来源;
- 内容变更时,可以通过
ETag和SHA256判断是否需要重建索引。
五、分块策略:先按 Markdown 标题切,再按长度切
RAG 系统中,分块非常关键。
如果直接把整篇 Markdown 当作一个向量文档,会导致:
召回粒度太粗
上下文太长
问题定位不准
如果切得太碎,又会导致:
语义不完整
上下文割裂
回答缺少前后信息
项目中采用的是比较务实的分块策略:
private List<String> chunkMarkdown(String text) {
List<String> sections = new ArrayList<>();
StringBuilder current = new StringBuilder();
for (String line : text.split("\\R")) {
if (line.startsWith("#") && current.length() > 0) {
sections.add(current.toString());
current.setLength(0);
}
current.append(line).append('\n');
}
if (current.length() > 0) {
sections.add(current.toString());
}
List<String> chunks = new ArrayList<>();
for (String section : sections) {
chunks.addAll(getChunks(section));
}
return chunks;
}
第一层逻辑是按 Markdown 标题拆分。
比如一篇文章结构是:
# Redis 计数系统
## 一、为什么不用 MySQL 直接计数
## 二、SDS 紧凑计数结构
## 三、Lua 原子更新
那么系统会尽量把每个标题下的内容作为相对独立的语义段落。
这比固定长度硬切更适合技术文档,因为 Markdown 标题本身就代表内容结构。
六、长段落继续按 800 字切片,并保留重叠
如果某个 section 仍然太长,会继续进入 getChunks:
private List<String> getChunks(String section) {
int maxLen = 800;
int overlap = 100;
List<String> result = new ArrayList<>();
if (section.length() <= maxLen) {
result.add(section);
return result;
}
int start = 0;
while (start < section.length()) {
int end = Math.min(section.length(), start + maxLen);
result.add(section.substring(start, end));
if (end == section.length()) {
break;
}
start = Math.max(end - overlap, start + 1);
}
return result;
}
这里的两个参数是:
int maxLen = 800;
int overlap = 100;
也就是说,每个切片最多约 800 个字符,相邻切片之间保留约 100 个字符重叠。
重叠的意义在于避免边界信息丢失。
举个例子:
切片 A 结尾:Lua 脚本可以保证 Redis 中多个操作的原子性...
切片 B 开头:...因此点赞状态和计数更新可以在一个脚本中完成
如果没有重叠,模型可能只看到后半句,语义会不完整。
保留少量 overlap 后,召回到任意一个切片时,模型都能拿到更完整的上下文。
七、写入向量库时保存 metadata
每个切片最终会被包装成 Spring AI 的 Document:
Map<String, Object> metadata = new HashMap<>();
metadata.put("postId", String.valueOf(postId));
metadata.put("chunkId", postId + "#" + i);
metadata.put("position", i);
metadata.put("contentEtag", currentEtag);
metadata.put("contentSha256", currentSha);
metadata.put("contentUrl", contentUrl);
metadata.put("title", row.getTitle());
docs.add(new Document(chunk, metadata));
这里的正文是:
chunk
metadata 则用于后续过滤、排序和版本判断。
| metadata | 作用 |
|---|---|
postId |
问答时过滤当前知文 |
chunkId |
标识某篇文章的某个切片 |
position |
保留切片顺序 |
contentEtag |
判断索引是否对应当前内容版本 |
contentSha256 |
更稳定的内容指纹 |
contentUrl |
追踪原始 Markdown 来源 |
title |
为后续展示或调试提供标题信息 |
其中最核心的是:
postId
contentSha256
contentEtag
postId 用于查询阶段过滤。
contentSha256 和 contentEtag 用于判断索引是否过期。
八、指纹检测:避免重复重建索引
每次用户提问前,系统都会调用:
indexService.ensureIndexed(postId);
如果不做优化,那每一次提问都会重新拉取 Markdown、重新切片、重新写入向量库。
这显然不合理。
所以项目中实现了 isUpToDate:
private boolean isUpToDate(long postId, String currentSha, String currentEtag) {
if (esProps.getIndex() == null || esProps.getIndex().isBlank()) {
return false;
}
try {
SearchResponse<Map> resp = es.search(s -> s
.index(esProps.getIndex())
.size(1)
.query(q -> q.term(t -> t
.field("metadata.postId")
.value(String.valueOf(postId))
)), Map.class);
if (resp.hits().hits().isEmpty()) {
return false;
}
Map source = resp.hits().hits().get(0).source();
if (source == null) {
return false;
}
Object metadataObj = source.get("metadata");
if (!(metadataObj instanceof Map metadata)) {
return false;
}
Object indexedSha = metadata.get("contentSha256");
Object indexedEtag = metadata.get("contentEtag");
if (currentSha != null && indexedSha != null
&& currentSha.equals(String.valueOf(indexedSha))) {
return true;
}
return currentEtag != null && indexedEtag != null
&& currentEtag.equals(String.valueOf(indexedEtag));
} catch (Exception e) {
log.warn("Check RAG index freshness failed for post {}", postId, e);
return false;
}
}
这段逻辑会去 Elasticsearch 中查当前文章任意一个切片,然后比较 metadata 中的内容指纹。
判断优先级是:
优先比较 contentSha256
如果没有 SHA256,则比较 contentEtag
只要发现向量库中的切片已经对应当前内容版本,就直接返回 true,索引构建流程跳过。
这样可以把多次问答时的索引检查成本降到很低。
九、删除旧切片:保证单一版本
如果文章内容发生变化,旧的向量切片必须删除。
否则会出现一个很隐蔽的问题:
用户问新版文章的问题
模型却召回了旧版文章切片
项目中通过 deleteExistingChunks 删除旧切片:
private void deleteExistingChunks(long postId) {
if (esProps.getIndex() == null || esProps.getIndex().isBlank()) {
return;
}
try {
es.deleteByQuery(d -> d
.index(esProps.getIndex())
.query(q -> q.term(t -> t
.field("metadata.postId")
.value(String.valueOf(postId))
))
);
} catch (Exception e) {
log.warn("Delete existing RAG chunks failed for post {}", postId, e);
}
}
删除条件是:
metadata.postId = 当前文章 ID
也就是说,每次重建某篇文章索引前,都会先删除这篇文章的旧切片,再写入新切片。
这样可以保证向量库中一篇文章只保留一个内容版本。
这就是项目中提到的:
幂等删除保持单一版本
十、写入向量库
最后调用:
vectorStore.add(docs);
项目中使用的是 Spring AI 的 VectorStore 抽象,底层接入 Elasticsearch 向量存储。
这样业务层不需要直接关心向量字段、Embedding 写入细节,只需要构造 Document 即可。
索引服务返回写入的切片数量:
return docs.size();
手动重建接口也直接返回这个数字:
@PostMapping("/{id}/rag/reindex")
public int reindex(@PathVariable("id") long id) {
return indexService.reindexSinglePost(id);
}
例如返回:
12
表示当前知文被切成了 12 个向量文档。
如果返回:
0
可能有几种情况:
- 文章不存在;
- 文章不是
published; - 文章不是
public; - 没有
contentUrl; - 内容为空;
- 当前索引已经是最新版本。
十一、这一篇小结
这一篇分析了 RAG 系统的索引构建流程。
核心设计可以总结为:
只索引 public + published 的知文
通过 contentUrl 拉取 OSS 中的 Markdown 正文
按 Markdown 标题优先分块
长段落按 800 字切片并保留 100 字重叠
通过 postId / chunkId / SHA256 / ETag 保存 metadata
重建前删除旧切片,保证单一版本
通过 SHA256 / ETag 判断索引是否已是最新
在 RAG 系统里,回答质量并不只取决于模型本身。
索引构建做得好,模型拿到的上下文就准。
索引版本控制做得好,模型就不会基于旧内容回答。
下一篇继续看工程化部分:平台是如何在发布流程中预索引,减少用户首次提问等待时间,并通过 Elasticsearch、Spring AI 和 DeepSeek 完成完整落地的。
更多推荐



所有评论(0)