Redis 7.2 向量搜索实现 RAG 知识库:从文档分块到相似度检索的完整方案
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;
}
}
结论与建议
核心要点
- RAG = 检索 + 生成:先从知识库检索相关文档,再让LLM基于真实数据生成回答
- 文档分块是关键:分块粒度直接影响检索精度,建议500字左右,带50字重叠
- Redis向量搜索:7.2+版本原生支持,适合中小规模知识库(百万级以内)
实践建议
- Embedding模型选择:中文场景推荐text-embedding-3-small或bge-large-zh
- 相似度阈值:根据实际效果调整,一般0.6-0.8之间
- 文档更新:文档更新时需要重新分块和向量化,可使用增量更新策略
- 混合检索:结合关键词检索(BM25)和向量检索,提高召回率
- 监控指标:跟踪检索命中率、回答准确率、用户满意度等指标