Redis 7.2 向量搜索实现 RAG 知识库:从文档分块到相似度检索的完整方案

作者:忆笙智云官方 | 发布时间:2026-06-07 08:30 | 更新时间:2026-06-07 08:30

Redis向量存储实现RAG知识库检索

引言

大语言模型(LLM)虽然拥有海量知识,但存在知识截止日期、无法访问私有数据、容易产生幻觉等问题。RAG(Retrieval-Augmented Generation,检索增强生成)通过在生成回答前先检索相关知识,有效解决了这些问题。

Redis从7.2版本开始原生支持向量搜索,使其不仅可以作为缓存和消息队列,还能作为向量数据库使用。本文将讲解RAG的完整原理,以及如何使用Redis实现知识库的向量存储与相似度检索。

一、RAG原理

1.1 RAG工作流程

RAG的核心思想是:先检索,后生成。将用户问题与知识库中的文档进行语义匹配,将最相关的文档片段作为上下文提供给LLM,让LLM基于真实数据生成回答。

flowchart TB
    A[用户提问] --> B[问题向量化]
    B --> C[向量相似度检索]
    C --> D[获取Top-K相关文档片段]
    D --> E[构建增强Prompt]
    E --> F[LLM生成回答]
    F --> G[返回回答]

    subgraph 知识库构建阶段
        H[原始文档] --> I[文档分块]
        I --> J[文本向量化 Embedding]
        J --> K[存入Redis向量索引]
    end

    K -.-> C

    style G fill:#d5f9d5
    style K fill:#e8f4f8

1.2 为什么需要RAG

问题 无RAG 有RAG
知识时效性 依赖训练数据截止日期 实时检索最新文档
私有数据 无法访问企业内部数据 检索企业知识库
幻觉问题 可能编造不存在的信息 基于真实文档生成
可追溯性 无法提供信息来源 可引用具体文档段落
成本 需要微调大模型 无需重新训练

二、文档分块策略

2.1 为什么需要分块

大模型有上下文窗口限制(如8K、32K token),无法将整篇文档直接输入。同时,向量检索的精度与文本粒度密切相关——过大的块会引入噪声,过小的块会丢失上下文。

2.2 分块方法

/**
 * 文档分块器
 * 支持按字符数、段落、语义等多种分块策略
 */
@Component
public class DocumentChunker {

    /** 默认分块大小 */
    private static final int DEFAULT_CHUNK_SIZE = 500;

    /** 分块重叠字符数(保证上下文连贯) */
    private static final int CHUNK_OVERLAP = 50;

    /**
     * 按固定大小分块(带重叠)
     * @param content 文档内容
     * @param chunkSize 分块大小
     * @return 分块列表
     */
    public List<DocumentChunk> chunkBySize(String content, int chunkSize) {
        List<DocumentChunk> chunks = new ArrayList<>();
        int start = 0;
        int index = 0;

        while (start < content.length()) {
            int end = Math.min(start + chunkSize, content.length());

            // 尝试在句子边界处断开
            int sentenceEnd = findSentenceBoundary(content, start, end);
            if (sentenceEnd > start) {
                end = sentenceEnd;
            }

            String text = content.substring(start, end).trim();
            if (!text.isEmpty()) {
                chunks.add(DocumentChunk.builder()
                        .content(text)
                        .index(index++)
                        .startOffset(start)
                        .endOffset(end)
                        .build());
            }

            // 向前移动,保留重叠部分
            start = end - CHUNK_OVERLAP;
            if (start >= content.length()) break;
        }

        return chunks;
    }

    /**
     * 按段落分块
     * 保留段落完整性,适合结构化文档
     */
    public List<DocumentChunk> chunkByParagraph(String content) {
        List<DocumentChunk> chunks = new ArrayList<>();
        String[] paragraphs = content.split("\n\s*\n");
        int index = 0;

        StringBuilder currentChunk = new StringBuilder();
        for (String para : paragraphs) {
            // 如果当前块加上新段落超过阈值,先保存当前块
            if (currentChunk.length() + para.length() > DEFAULT_CHUNK_SIZE
                    && currentChunk.length() > 0) {
                chunks.add(DocumentChunk.builder()
                        .content(currentChunk.toString().trim())
                        .index(index++)
                        .build());
                currentChunk = new StringBuilder();
            }
            currentChunk.append(para).append("

");
        }

        // 保存最后一块
        if (currentChunk.length() > 0) {
            chunks.add(DocumentChunk.builder()
                    .content(currentChunk.toString().trim())
                    .index(index)
                    .build());
        }

        return chunks;
    }

    /**
     * 查找句子边界
     * 在指定范围内找到最后一个句子结束位置
     */
    private int findSentenceBoundary(String content, int start, int end) {
        // 从end往前查找句子结束标点
        for (int i = end - 1; i > start; i--) {
            char ch = content.charAt(i);
            if (ch == '。' || ch == '!' || ch == '?'
                || ch == '.' || ch == '!' || ch == '?') {
                return i + 1;
            }
        }
        return end;  // 未找到边界,返回原始位置
    }
}

三、文本向量化(Embedding)

3.1 向量化原理

Embedding模型将文本映射为高维向量(如768维、1536维),语义相近的文本在向量空间中距离更近。

graph TB
    A["文本: 如何重置密码"] --> B[Embedding模型]
    B --> C["向量: [0.12, -0.34, 0.56, ..., 0.78]<br/>1536维浮点数组"]

    D["文本: 忘记密码怎么办"] --> E[Embedding模型]
    E --> F["向量: [0.11, -0.33, 0.55, ..., 0.77]<br/>与C余弦相似度=0.95"]

    G["文本: 如何做饭"] --> H[Embedding模型]
    H --> I["向量: [-0.45, 0.23, -0.67, ..., 0.12]<br/>与C余弦相似度=0.12"]

    style C fill:#d5f9d5
    style F fill:#d5f9d5
    style I fill:#f9d5d5

3.2 调用Embedding API

/**
 * 文本向量化服务
 * 调用Embedding模型将文本转为向量
 */
@Service
public class EmbeddingService {

    @Autowired
    private RestTemplate restTemplate;

    @Value("${ai.embedding.api-url}")
    private String apiUrl;

    @Value("${ai.embedding.model}")
    private String model;

    /**
     * 将文本转换为向量
     * @param text 输入文本
     * @return 向量数组
     */
    public float[] embed(String text) {
        Map<String, Object> request = Map.of(
                "model", model,
                "input", text
        );

        ResponseEntity<Map> response = restTemplate.postForEntity(
                apiUrl, request, Map.class);

        List<List<Double>> embeddings = (List<List<Double>>)
                ((Map) ((List) response.getBody().get("data")).get(0))
                        .get("embedding");

        // 转换为float数组
        return embeddings.get(0).stream()
                .mapToDouble(Double::doubleValue)
                .collect(() -> new float[embeddings.get(0).size()],
                        (arr, val) -> arr[(int) val] = (float) val,
                        (arr1, arr2) -> {});
    }

    /**
     * 批量向量化
     * @param texts 文本列表
     * @return 向量列表
     */
    public List<float[]> embedBatch(List<String> texts) {
        Map<String, Object> request = Map.of(
                "model", model,
                "input", texts
        );

        ResponseEntity<Map> response = restTemplate.postForEntity(
                apiUrl, request, Map.class);

        List<Map> dataList = (List<Map>) response.getBody().get("data");
        return dataList.stream()
                .map(data -> {
                    List<Double> embedding = (List<Double>) data.get("embedding");
                    float[] vector = new float[embedding.size()];
                    for (int i = 0; i < embedding.size(); i++) {
                        vector[i] = embedding.get(i).floatValue();
                    }
                    return vector;
                })
                .collect(Collectors.toList());
    }
}

四、Redis向量存储与检索

4.1 创建向量索引

/**
 * Redis向量存储服务
 * 使用Redis的向量搜索功能实现知识库检索
 */
@Service
public class RedisVectorStore {

    @Autowired
    private JedisPooled jedis;

    /** 向量维度(与Embedding模型输出维度一致) */
    private static final int VECTOR_DIM = 1536;

    /** 索引名称 */
    private static final String INDEX_NAME = "knowledge_base_idx";

    /** 键前缀 */
    private static final String KEY_PREFIX = "kb:doc:";

    /**
     * 创建向量索引
     * 使用HNSW(层次导航小世界图)算法实现近似最近邻搜索
     */
    public void createIndex() {
        try {
            // 检查索引是否已存在
            jedis.ftInfo(INDEX_NAME);
            return;
        } catch (Exception e) {
            // 索引不存在,创建新索引
        }

        // 定义Schema
        Schema schema = new Schema()
                .addTextField("content", 5.0)      // 文档内容(文本字段,权重5.0)
                .addTextField("doc_name", 1.0)      // 文档名称
                .addTextField("doc_type", 1.0)      // 文档类型
                .addNumericField("chunk_index")     // 分块索引
                .addVectorField("embedding",        // 向量字段
                        Schema.VectorField.VectorAlgo.HNSW,
                        Map.of(
                            "TYPE", "FLOAT32",
                            "DIM", String.valueOf(VECTOR_DIM),
                            "DISTANCE_METRIC", "COSINE",
                            "M", "16",           // HNSW每层最大连接数
                            "EF_CONSTRUCTION", "200"  // 构建时搜索宽度
                        ));

        // 创建索引
        IndexDefinition definition = new IndexDefinition(IndexDefinition.Type.HASH)
                .setPrefixes(KEY_PREFIX);

        jedis.ftCreate(INDEX_NAME, IndexOptions.defaultOptions().setDefinition(definition), schema);
    }
}

4.2 存储文档向量

/**
 * 存储文档分块及其向量
 * @param docId 文档ID
 * @param chunk 分块信息
 * @param embedding 向量
 */
public void storeDocumentChunk(String docId, DocumentChunk chunk, float[] embedding) {
    String key = KEY_PREFIX + docId + ":" + chunk.getIndex();

    Map<String, String> fields = new HashMap<>();
    fields.put("content", chunk.getContent());
    fields.put("doc_name", chunk.getDocName());
    fields.put("doc_type", chunk.getDocType());
    fields.put("chunk_index", String.valueOf(chunk.getIndex()));
    fields.put("doc_id", docId);

    // 将float数组转为字节存储
    fields.put("embedding", vectorToBytes(embedding));

    jedis.hset(key, fields);
}

/**
 * float数组转字节数组
 * Redis向量字段要求FLOAT32的二进制格式
 */
private String vectorToBytes(float[] vector) {
    ByteBuffer buffer = ByteBuffer.allocate(vector.length * 4);
    buffer.order(ByteOrder.LITTLE_ENDIAN);
    for (float v : vector) {
        buffer.putFloat(v);
    }
    return new String(buffer.array(), StandardCharsets.ISO_8859_1);
}

4.3 向量相似度检索

/**
 * 向量相似度检索
 * @param queryVector 查询向量
 * @param topK 返回最相似的K个结果
 * @return 检索结果列表
 */
public List<SearchResult> similaritySearch(float[] queryVector, int topK) {
    // 构建向量查询
    String queryVectorBytes = vectorToBytes(queryVector);

    // 使用KNN查询进行向量搜索
    Query query = new Query("*=>[KNN $K @embedding $query_vec AS score]")
            .addParam("K", String.valueOf(topK))
            .addParam("query_vec", queryVectorBytes)
            .returnFields("content", "doc_name", "doc_type", "chunk_index", "score")
            .setSortBy("score", true)
            .limit(0, topK)
            .dialect(2);  // 使用DIALECT 2支持向量查询

    SearchResult result = jedis.ftSearch(INDEX_NAME, query);

    // 解析结果
    List<SearchResult> results = new ArrayList<>();
    for (Document doc : result.getDocuments()) {
        results.add(SearchResult.builder()
                .content((String) doc.get("content"))
                .docName((String) doc.get("doc_name"))
                .docType((String) doc.get("doc_type"))
                .score(Double.parseDouble((String) doc.get("score")))
                .build());
    }

    return results;
}

五、完整RAG流程

5.1 知识库构建流程

flowchart TB
    A[上传文档] --> B[解析文档内容<br/>PDF/Word/TXT/Markdown]
    B --> C[文档分块<br/>按500字分块+50字重叠]
    C --> D[批量向量化<br/>调用Embedding API]
    D --> E[存入Redis<br/>HASH + 向量索引]

    subgraph Redis存储结构
        F["kb:doc:{docId}:{chunkIdx}"]
        F --> G["content: 分块文本"]
        F --> H["doc_name: 文档名"]
        F --> I["embedding: 向量数据"]
    end

    E --> F

    style E fill:#d5f9d5
    style F fill:#e8f4f8

5.2 RAG查询流程

/**
 * RAG检索增强生成服务
 */
@Service
public class RagService {

    @Autowired
    private EmbeddingService embeddingService;

    @Autowired
    private RedisVectorStore vectorStore;

    @Autowired
    private LlmClient llmClient;

    /** 检索Top-K相关文档 */
    private static final int TOP_K = 5;

    /** 相似度阈值 */
    private static final double SCORE_THRESHOLD = 0.7;

    /**
     * RAG问答
     * @param question 用户问题
     * @return AI回答
     */
    public String ask(String question) {
        // 1. 问题向量化
        float[] queryVector = embeddingService.embed(question);

        // 2. 向量相似度检索
        List<SearchResult> searchResults = vectorStore.similaritySearch(queryVector, TOP_K);

        // 3. 过滤低相似度结果
        List<SearchResult> filtered = searchResults.stream()
                .filter(r -> r.getScore() >= SCORE_THRESHOLD)
                .collect(Collectors.toList());

        if (filtered.isEmpty()) {
            return "抱歉,知识库中未找到与您问题相关的内容。";
        }

        // 4. 构建增强Prompt
        String context = filtered.stream()
                .map(r -> "【来源:" + r.getDocName() + "】
" + r.getContent())
                .collect(Collectors.joining("

---

"));

        String prompt = buildRagPrompt(question, context);

        // 5. 调用LLM生成回答
        return llmClient.chat(prompt);
    }

    /**
     * 构建RAG提示词
     */
    private String buildRagPrompt(String question, String context) {
        return """
            你是一个专业的知识库助手。请根据以下参考资料回答用户的问题。

            要求:
            1. 只根据参考资料中的信息回答,不要编造内容
            2. 如果参考资料中没有相关信息,请明确告知
            3. 回答时引用信息来源

            参考资料:
            %s

            用户问题:%s
            """.formatted(context, question);
    }
}

六、性能优化

6.1 向量索引选择

算法 特点 适用场景
FLAT 暴力搜索,精度最高 文档量<10万
HNSW 近似搜索,速度快 文档量10万-百万
IVFFLAT 聚类后搜索 文档量>百万

6.2 缓存优化

/**
 * 向量化结果缓存
 * 避免重复调用Embedding API
 */
@Service
public class CachedEmbeddingService {

    @Autowired
    private RedisTemplate<String, byte[]> redisTemplate;

    @Autowired
    private EmbeddingService embeddingService;

    /** 缓存前缀 */
    private static final String CACHE_PREFIX = "emb:";

    /**
     * 带缓存的向量化
     */
    public float[] embedWithCache(String text) {
        String cacheKey = CACHE_PREFIX + DigestUtils.md5Hex(text);

        // 先查缓存
        byte[] cached = redisTemplate.opsForValue().get(cacheKey);
        if (cached != null) {
            return bytesToVector(cached);
        }

        // 缓存未命中,调用API
        float[] vector = embeddingService.embed(text);

        // 写入缓存(7天过期)
        redisTemplate.opsForValue().set(cacheKey, vectorToBytes(vector),
                7, TimeUnit.DAYS);

        return vector;
    }
}

结论与建议

核心要点

  1. RAG = 检索 + 生成:先从知识库检索相关文档,再让LLM基于真实数据生成回答
  2. 文档分块是关键:分块粒度直接影响检索精度,建议500字左右,带50字重叠
  3. Redis向量搜索:7.2+版本原生支持,适合中小规模知识库(百万级以内)

实践建议

  1. Embedding模型选择:中文场景推荐text-embedding-3-small或bge-large-zh
  2. 相似度阈值:根据实际效果调整,一般0.6-0.8之间
  3. 文档更新:文档更新时需要重新分块和向量化,可使用增量更新策略
  4. 混合检索:结合关键词检索(BM25)和向量检索,提高召回率
  5. 监控指标:跟踪检索命中率、回答准确率、用户满意度等指标

相关资源