向量搜索实现原理详解

概述

本文档详细分析了 VectorServiceImpl.searchQuestion 方法的实现原理,重点讲述文档过滤策略、内容截断问题的处理方案,以及如何保证检索结果的连贯性和准确性。

1. 整体架构流程

graph TD A[用户问题] --> B[向量化编码] B --> C[Milvus向量搜索] C --> D[相似度计算] D --> E[topK筛选] E --> F[阈值过滤] F --> G[多策略内容提取] G --> H[内容去重与合并] H --> I[返回最终结果]

2. 文档过滤机制详解

2.1 多层过滤策略

我们的向量搜索采用了多层渐进式过滤机制:

第一层:相似度阈值过滤

.similarityThreshold(0.3)  // 只保留相似度 ≥ 0.3 的文档

设计原理:

  • 0.3 阈值选择:经过实际测试,0.3 是一个平衡点

    • 太高(如0.7):可能过滤掉语义相关但表达方式不同的文档
    • 太低(如0.1):会引入大量噪音文档
  • 向量相似度计算:使用余弦相似度公式

    similarity = (A · B) / (||A|| × ||B||)
    

第二层:topK 数量控制

.topK(5)  // 最多返回5个最相似的文档

为什么选择5个?

  • 性能考虑:避免处理过多文档影响响应速度
  • 质量保证:前5个文档通常包含最相关的信息
  • 上下文限制:LLM 的 context window 有限制

第三层:内容有效性过滤

.filter(content -> content != null && !content.trim().isEmpty())

过滤条件:

  • 非空检查:content != null
  • 空白字符检查:!content.trim().isEmpty()
  • 确保每个返回的内容都是有意义的文本

2.2 文档排序与优先级

搜索结果按以下优先级排序:

  1. 相似度得分(主要排序依据)
  2. 文档完整性(优先选择完整文档)
  3. 内容长度(适中长度的文档更有价值)

3. 内容截断问题的解决方案

3.1 截断问题的产生原因

在向量化过程中,长文档会被切分成多个 chunk:

原始文档: "董事长寄语:我们公司秉承创新、精细、品牌、诚信的理念,致力于为客户提供优质服务..."

切分后:
Chunk1: "董事长寄语:我们公司秉承创新、精细、品牌、诚信的理念"
Chunk2: "致力于为客户提供优质服务,不断追求卓越"
Chunk3: "在未来的发展中,我们将继续坚持这一理念"

3.2 多策略内容提取机制

我们实现了渐进式内容提取策略:

// 策略1:优先使用 Spring AI 标准方法
String content = doc.getText();

// 策略2:尝试 metadata 中的 content 字段
if (content == null || content.trim().isEmpty()) {
    content = doc.getMetadata().getOrDefault("content", "").toString();
}

// 策略3:尝试 metadata 中的 text 字段
if (content == null || content.trim().isEmpty()) {
    content = doc.getMetadata().getOrDefault("text", "").toString();
}

// 策略4:尝试 metadata 中的 data 字段
if (content == null || content.trim().isEmpty()) {
    content = doc.getMetadata().getOrDefault("data", "").toString();
}

每种策略的适用场景:

策略 方法 适用场景 优势
1 doc.getText() 标准 Spring AI 文档 性能最优,API 标准
2 metadata.content 自定义存储格式 灵活性高,支持自定义字段
3 metadata.text 文本类文档 兼容性好,通用性强
4 metadata.data 结构化数据 支持复杂数据结构

3.3 内容连贯性保证机制

方案一:上下文窗口扩展(推荐实现)

public List<String> searchQuestionWithContext(String question) {
    // 1. 执行向量搜索
    List<Document> documents = vectorStore.similaritySearch(searchRequest);
    
    // 2. 为每个文档查找相邻的 chunk
    List<String> enhancedResults = new ArrayList<>();
    
    for (Document doc : documents) {
        String chunkId = doc.getMetadata().get("chunk_id").toString();
        String docId = doc.getMetadata().get("document_id").toString();
        
        // 查找相邻的 chunk
        List<Document> contextChunks = findAdjacentChunks(docId, chunkId);
        
        // 合并内容
        String mergedContent = mergeChunks(contextChunks);
        enhancedResults.add(mergedContent);
    }
    
    return enhancedResults;
}

private List<Document> findAdjacentChunks(String docId, String chunkId) {
    // 查找 chunk_id-1, chunk_id, chunk_id+1
    SearchRequest contextRequest = SearchRequest.builder()
            .filter(new Filter.Expression(
                Filter.ExpressionType.AND,
                List.of(
                    new Filter.Expression(Filter.ExpressionType.EQ, "document_id", docId),
                    new Filter.Expression(Filter.ExpressionType.IN, "chunk_id", 
                        Arrays.asList(chunkId-1, chunkId, chunkId+1))
                )
            ))
            .build();
    
    return vectorStore.similaritySearch(contextRequest);
}

方案二:重叠窗口策略

在文档切分时就考虑连贯性:

// 文档切分时使用重叠窗口
public List<String> splitDocumentWithOverlap(String document, int chunkSize, int overlapSize) {
    List<String> chunks = new ArrayList<>();
    int start = 0;
    
    while (start < document.length()) {
        int end = Math.min(start + chunkSize, document.length());
        String chunk = document.substring(start, end);
        
        // 确保在句子边界切分
        if (end < document.length()) {
            int lastPeriod = chunk.lastIndexOf('。');
            int lastExclamation = chunk.lastIndexOf('!');
            int lastQuestion = chunk.lastIndexOf('?');
            
            int sentenceEnd = Math.max(Math.max(lastPeriod, lastExclamation), lastQuestion);
            if (sentenceEnd > chunk.length() * 0.7) { // 如果句子边界在后70%位置
                end = start + sentenceEnd + 1;
                chunk = document.substring(start, end);
            }
        }
        
        chunks.add(chunk);
        start = end - overlapSize; // 重叠部分
    }
    
    return chunks;
}

4. 高级优化策略

4.1 智能去重机制

private List<String> deduplicateResults(List<String> results) {
    Set<String> seen = new HashSet<>();
    List<String> deduplicated = new ArrayList<>();
    
    for (String content : results) {
        // 计算内容的哈希值或使用编辑距离
        String signature = calculateContentSignature(content);
        if (!seen.contains(signature)) {
            seen.add(signature);
            deduplicated.add(content);
        }
    }
    
    return deduplicated;
}

private String calculateContentSignature(String content) {
    // 移除标点符号和空格,计算核心内容的哈希
    String normalized = content.replaceAll("[\\p{Punct}\\s]+", "");
    return Integer.toString(normalized.hashCode());
}

4.2 语义相关性增强

private List<String> enhanceSemanticRelevance(String question, List<String> results) {
    return results.stream()
            .map(content -> {
                // 计算与问题的语义相关度
                double relevanceScore = calculateSemanticRelevance(question, content);
                return new ScoredContent(content, relevanceScore);
            })
            .filter(scored -> scored.score > 0.5) // 过滤低相关度内容
            .sorted((a, b) -> Double.compare(b.score, a.score)) // 按相关度排序
            .map(scored -> scored.content)
            .collect(Collectors.toList());
}

4.3 动态阈值调整

private double calculateDynamicThreshold(String question) {
    // 根据问题的复杂度和长度动态调整阈值
    int questionLength = question.length();
    int complexityScore = calculateQuestionComplexity(question);
    
    double baseThreshold = 0.3;
    
    // 问题越复杂,阈值越低(更宽松)
    if (complexityScore > 5) {
        baseThreshold -= 0.1;
    }
    
    // 问题越短,阈值越高(更严格)
    if (questionLength < 10) {
        baseThreshold += 0.1;
    }
    
    return Math.max(0.1, Math.min(0.8, baseThreshold));
}

5. 性能优化与监控

5.1 缓存策略

@Cacheable(value = "vectorSearch", key = "#question")
public List<String> searchQuestion(String question) {
    // 实际搜索逻辑
}

5.2 异步处理

@Async
public CompletableFuture<List<String>> searchQuestionAsync(String question) {
    return CompletableFuture.completedFuture(searchQuestion(question));
}

5.3 监控指标

// 记录关键性能指标
log.info("向量搜索性能指标 - 问题长度: {}, 返回文档数: {}, 耗时: {}ms", 
         question.length(), documents.size(), duration);

// 记录搜索质量指标
log.info("搜索质量指标 - 平均相似度: {}, 内容完整率: {}%", 
         averageSimilarity, contentCompletenessRate);

6. 实际应用场景

6.1 董事长寄语查询示例

用户问题: "董事长寄语是什么"

处理流程:

  1. 问题向量化: [0.1, 0.8, -0.3, 0.5, ...]

  2. 向量搜索: 在 Milvus 中查找相似文档

  3. 结果筛选:

    文档1: 相似度 0.85 - "董事长寄语:我们公司秉承..."
    文档2: 相似度 0.72 - "创新、精细、品牌、诚信是我们的核心理念..."
    文档3: 相似度 0.45 - "公司发展历程中,董事长多次强调..."
    
  4. 内容提取与合并

  5. 返回完整答案

6.2 技术问题查询示例

用户问题: "如何实现 Spring Boot 自动配置"

处理策略:

  • 使用更严格的阈值 (0.4) 确保技术准确性
  • 优先返回代码示例和配置文件
  • 合并相关的多个技术文档片段

7. 总结

我们的向量搜索实现通过以下机制保证了高质量的检索结果:

  1. 多层过滤: 相似度阈值 + topK限制 + 内容有效性检查
  2. 容错机制: 多策略内容提取,适应不同数据格式
  3. 连贯性保证: 上下文窗口扩展 + 重叠切分策略
  4. 智能优化: 去重、语义增强、动态阈值调整
  5. 性能监控: 缓存、异步处理、关键指标记录

这种设计确保了即使在复杂的企业知识库环境中,也能准确、快速地检索到用户需要的信息,为后续的 LLM 生成提供高质量的上下文支持。

posted @ 2025-11-24 11:34  了解化  阅读(0)  评论(0)    收藏  举报