跳过正文

企业级RAG知识库系统:从文档到智能问答的全链路实现

·568 字·3 分钟
黄振宇
作者
黄振宇
从文档上传到智能问答,拆解企业级 RAG 系统的每一步技术实现。

🤖 RAG架构 📊 Elasticsearch 📨 Kafka ⚡ 200ms响应


项目概述
#

智库RAG 是我独立设计并开发的企业级智能知识库管理系统。用户可以上传各种格式的文档(Word、PDF、TXT等),系统会自动解析、向量化并建立索引,然后通过自然语言对话的方式查询知识库,获得基于自身文档的 AI 生成回答。

技术栈
#

层级 技术选型
后端框架 Spring Boot 3.4.2 (Java 17)
搜索引擎 Elasticsearch 8.10 + IK分词器
向量模型 豆包 Embedding(2048维)
大语言模型 DeepSeek API / 本地 Ollama
消息队列 Apache Kafka
缓存 Redis
文件存储 MinIO
文档解析 Apache Tika
安全认证 Spring Security + JWT
实时通信 WebSocket
前端 Vue 3 + TypeScript + Naive UI

系统架构
#

graph TB
    subgraph 用户端
        A[Vue3 + Naive UI]
    end

    subgraph API层
        B[Spring Boot REST API]
        C[WebSocket Handler]
    end

    subgraph 异步流水线
        D[Kafka Producer]
        E[Tika 文档解析]
        F[豆包 Embedding 向量化]
        G[Elasticsearch 索引]
    end

    subgraph RAG检索
        H[用户提问]
        I[Embedding转换]
        J[KNN向量召回]
        K[BM25关键词匹配]
        L[分数融合重排序]
        M[增强Prompt构建]
        N[DeepSeek生成回答]
    end

    subgraph 基础设施
        O[(MySQL)]
        P[(Redis)]
        Q[(MinIO)]
        R[(Elasticsearch)]
    end

    A --> B
    A --> C
    B --> D
    D --> E --> F --> G --> R
    H --> I --> J
    H --> K
    J --> L --> M --> N
    B --> O
    B --> P
    B --> Q

核心实现
#

一、BitMap 分片状态管理
#

这是我在这个项目中最满意的设计之一。

痛点:大文件上传时,需要记录每个分片的上传状态。如果用 HashMap,1000个分片大约需要 40KB。

方案:使用 Redis BitMap,每个分片只用1个bit表示:

@Service
public class ChunkUploadService {

    private static final String UPLOAD_KEY = "upload:";

    /**
     * 标记分片已上传
     * 1000个分片仅需 125 字节
     */
    public void markUploaded(String fileId, int chunkIndex) {
        String key = UPLOAD_KEY + fileId;
        redisTemplate.opsForValue().setBit(key, chunkIndex, true);
    }

    /**
     * 检查分片状态(秒传判断)
     */
    public boolean isUploaded(String fileId, int chunkIndex) {
        return Boolean.TRUE.equals(
            redisTemplate.opsForValue()
                .getBit(UPLOAD_KEY + fileId, chunkIndex)
        );
    }

    /**
     * 统计已上传分片数
     */
    public long countUploaded(String fileId) {
        return redisTemplate.execute(
            (RedisCallback<Long>) con ->
                con.stringCommands()
                    .bitCount(
                        (UPLOAD_KEY + fileId).getBytes()
                    )
        );
    }
}

结合 Redis 缓存 + MinIO 断点续传,1GB 文件上传耗时从 15s 降至 3s

⚠️ BitMap 的 setBit 操作是 O(1) 的,但 bitCount 是 O(N) 的。对于分片数量在万级以内的场景,性能完全没问题。如果分片数量达到百万级,建议使用 HyperLogLog 做近似统计。

二、双引擎检索:关键词 + 语义融合
#

RAG 系统最核心的部分——如何准确召回相关文档。单纯用关键词搜索,同义词和语义相似的问题召回差;单纯用向量搜索,精确匹配又不如关键词。两者结合效果最好:

graph LR
    A[用户提问] --> B[豆包Embedding<br/>转为2048维向量]
    A --> C[IK分词器<br/>提取关键词]
    B --> D[Elasticsearch KNN<br/>向量语义召回]
    C --> E[ES Match Query<br/>关键词匹配]
    D --> F[分数融合 + 重排序]
    E --> F
    F --> G[Top-K 相关片段]
SearchRequest request = SearchRequest.of(sr -> sr
    .index("knowledge_base")
    .size(10)
    .query(q -> q.bool(b -> b
        // 向量语义搜索
        .should(s -> s.knn(k -> k
            .field("embedding")
            .queryVector(embeddingVector)
            .numCandidates(50)
            .k(10)
        ))
        // BM25 关键词搜索
        .should(s -> s.multiMatch(m -> m
            .fields("title", "content")
            .query(queryText)
            .type(TextQueryType.BestFields)
        ))
        .minimumShouldMatch("1")
    ))
);
💡 为什么是 2048 维?豆包 Embedding 模型在 2048 维时效果和性能的平衡点最好。维度太低语义信息丢失,太高则检索延迟增加且存储成本翻倍。

三、Kafka 异步文档处理流水线
#

文档上传后不需要同步等待处理完成,通过 Kafka 解耦:

sequenceDiagram
    participant 用户
    participant API
    participant Kafka
    participant Consumer
    participant ES

    用户->>API: 上传文档
    API->>Kafka: 发送处理事件
    API-->>用户: 200 OK(响应 < 200ms)
    Note over Kafka,ES: 异步流水线
    Kafka->>Consumer: 消费事件
    Consumer->>Consumer: Tika 解析文档
    Consumer->>Consumer: 豆包 Embedding 向量化
    Consumer->>ES: 写入索引
// Producer:上传即返回
@PostMapping("/upload")
public Result<String> upload(MultipartFile file) {
    String fileId = generateId();
    minioService.upload(file, fileId);
    kafkaTemplate.send("doc-process",
        new DocProcessEvent(fileId, file.getName()));
    return Result.ok(fileId); // < 200ms
}

// Consumer:异步处理
@KafkaListener(topics = "doc-process")
public void process(DocProcessEvent event) {
    File file = minioService.download(event.getFileId());
    String text = tikaParser.parse(file);
    float[] vector = embeddingClient.embed(text);
    esService.index(event.getFileId(), text, vector);
}

经测试,500MB 文件上传响应仅需 200ms(之前是阻塞等待处理完成的)。

四、多轮对话上下文管理
#

用户和知识库对话时,需要保持上下文连贯性。使用 Redis List 存储对话历史,7天自动过期:

@Service
public class ChatHistoryService {

    private static final Duration TTL = Duration.ofDays(7);

    public void append(String sessionId, ChatMessage msg) {
        String key = "chat:" + sessionId;
        redisTemplate.opsForList().rightPush(key, msg);
        redisTemplate.expire(key, TTL);
    }

    public List<ChatMessage> getRecent(String sessionId, int rounds) {
        String key = "chat:" + sessionId;
        Long size = redisTemplate.opsForList().size(key);
        if (size == null || size == 0) return List.of();
        int end = size.intValue() - 1;
        int start = Math.max(0, end - rounds * 2 + 1);
        return redisTemplate.opsForList().range(key, start, end);
    }
}

性能数据
#

指标 优化前 优化后 提升
1GB文件上传 15s 3s 5x
500MB上传响应 阻塞等待 200ms 异步化
分片状态存储 Hash ~40KB BitMap 125B 99%↓
对话上下文 每次查库 Redis缓存 实时

总结
#

这个项目让我从「会用Spring Boot写接口」跨越到了「能独立设计一个完整系统」。最大的收获不是学会某个具体技术,而是理解了如何用异步、缓存、消息队列这些手段来解决真实问题——BitMap 不是炫技而是真的省了 99% 的存储,Kafka 不是跟风而是真的把响应从秒级压到了毫秒级。