在这里插入图片描述

一、索引构建是 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);

它需要的不是只有标题,还包括:

  • contentUrl
  • contentEtag
  • contentSha256
  • status
  • visible
  • title

对应 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;
    }
}

这套设计的好处是:

  1. MySQL 不存大文本,表更轻;
  2. 发布系统和 RAG 系统通过 contentUrl 解耦;
  3. OSS 中的 Markdown 原文可以直接作为索引来源;
  4. 内容变更时,可以通过 ETagSHA256 判断是否需要重建索引。

五、分块策略:先按 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 用于查询阶段过滤。

contentSha256contentEtag 用于判断索引是否过期。


八、指纹检测:避免重复重建索引

每次用户提问前,系统都会调用:

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

可能有几种情况:

  1. 文章不存在;
  2. 文章不是 published
  3. 文章不是 public
  4. 没有 contentUrl
  5. 内容为空;
  6. 当前索引已经是最新版本。

十一、这一篇小结

这一篇分析了 RAG 系统的索引构建流程。

核心设计可以总结为:

只索引 public + published 的知文
通过 contentUrl 拉取 OSS 中的 Markdown 正文
按 Markdown 标题优先分块
长段落按 800 字切片并保留 100 字重叠
通过 postId / chunkId / SHA256 / ETag 保存 metadata
重建前删除旧切片,保证单一版本
通过 SHA256 / ETag 判断索引是否已是最新

在 RAG 系统里,回答质量并不只取决于模型本身。

索引构建做得好,模型拿到的上下文就准。

索引版本控制做得好,模型就不会基于旧内容回答。

下一篇继续看工程化部分:平台是如何在发布流程中预索引,减少用户首次提问等待时间,并通过 Elasticsearch、Spring AI 和 DeepSeek 完成完整落地的。

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐