第1845篇:内部知识库的AI搜索——比关键词搜索更懂你意图的企业搜索
第1845篇:内部知识库的AI搜索——比关键词搜索更懂你意图的企业搜索
公司内网搜索,大概是工程师日常最抓狂的体验之一。
你知道某个问题以前有人解决过,你知道某个决策有历史文档,但你就是搜不到。输入"订单超时处理",出来的结果是五年前的需求文档、三年前的邮件记录、去年的一个设计评审——就是没有你要找的那篇Confluence页面。
根本原因在于:关键词搜索匹配的是字面,不是语义。你脑子里想的是"当支付超时的时候应该怎么处理这笔订单",但搜索引擎只认识"订单""超时""处理"这几个词,完全不懂你的上下文。
RAG(检索增强生成)技术的出现,给这个问题提供了真正的解决思路。今天这篇文章,我来讲一套可以真正落地的企业内部知识库AI搜索系统。
关键词搜索 vs 语义搜索 vs RAG
先把概念理清楚,这三个东西经常被混用:
关键词搜索:BM25算法,Elasticsearch的默认模式。通过词频、逆文档频率来匹配。精确词汇匹配效果好,但不理解同义词和语义近似。
语义搜索:把文本转成向量,通过向量相似度(余弦相似度)来匹配。能理解"退款流程"和"钱退回去的步骤"是同一件事,但只能返回原始片段,不能综合多个来源给出完整答案。
RAG(检索增强生成):语义搜索 + LLM生成。先用向量搜索找到相关片段,再用LLM把这些片段综合成一个完整的答案,并注明来源。
三者关系:
对于企业内部知识库,RAG是最合适的选择。
系统整体架构
文档采集与预处理
Confluence文档采集
@Service
@Slf4j
public class ConfluenceDocumentCollector {
private final ConfluenceClient confluenceClient;
private final DocumentRepository documentRepository;
@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨2点同步
public void syncDocuments() {
log.info("开始同步Confluence文档");
List<String> spaceKeys = confluenceClient.getAllSpaces();
spaceKeys.parallelStream().forEach(this::syncSpace);
log.info("Confluence文档同步完成");
}
private void syncSpace(String spaceKey) {
try {
List<ConfluencePage> pages = confluenceClient.getPages(spaceKey);
pages.forEach(page -> {
// 检查是否需要更新
Optional<Document> existing = documentRepository.findBySourceId(page.getId());
if (existing.isPresent() &&
!page.getLastModified().isAfter(existing.get().getLastSyncTime())) {
return; // 没有更新,跳过
}
// 处理并存储
Document doc = processPage(page);
documentRepository.save(doc);
});
} catch (Exception e) {
log.error("同步Space失败: {}", spaceKey, e);
}
}
private Document processPage(ConfluencePage page) {
// 将HTML内容转为纯文本
String plainText = HtmlToTextConverter.convert(page.getBody());
// 提取元数据
DocumentMetadata metadata = DocumentMetadata.builder()
.title(page.getTitle())
.url(confluenceClient.getPageUrl(page.getId()))
.author(page.getLastModifiedBy())
.lastModified(page.getLastModified())
.space(page.getSpace())
.labels(page.getLabels())
.parentTitle(page.getParentTitle())
.build();
return Document.builder()
.sourceId(page.getId())
.sourceType("CONFLUENCE")
.title(page.getTitle())
.content(plainText)
.metadata(metadata)
.lastSyncTime(LocalDateTime.now())
.build();
}
}智能文档分块
这是RAG效果的关键。分块太小,单个片段信息不完整;分块太大,相关性计算不准。
@Service
public class DocumentChunker {
// 目标分块大小(token数)
private static final int TARGET_CHUNK_SIZE = 512;
// 相邻块的重叠token数(保持上下文连续性)
private static final int CHUNK_OVERLAP = 64;
public List<DocumentChunk> chunk(Document document) {
String content = document.getContent();
List<DocumentChunk> chunks = new ArrayList<>();
// 策略1:如果文档有清晰的标题结构,按标题分块
if (hasHeadingStructure(content)) {
chunks = chunkByHeadings(document, content);
} else {
// 策略2:按语义边界分块(段落 > 句子)
chunks = chunkBySemantic(document, content);
}
// 后处理:过滤太短的块,合并太小的块
return postProcessChunks(chunks);
}
private List<DocumentChunk> chunkByHeadings(Document document, String content) {
List<DocumentChunk> chunks = new ArrayList<>();
// 使用正则匹配Markdown/HTML标题
Pattern headingPattern = Pattern.compile(
"^(#{1,4}\\s+.+|<h[1-4][^>]*>.+</h[1-4]>)",
Pattern.MULTILINE
);
Matcher matcher = headingPattern.matcher(content);
List<Integer> headingPositions = new ArrayList<>();
List<String> headings = new ArrayList<>();
while (matcher.find()) {
headingPositions.add(matcher.start());
headings.add(matcher.group().replaceAll("[#<>/h1-6]", "").trim());
}
// 按标题切分内容
for (int i = 0; i < headingPositions.size(); i++) {
int start = headingPositions.get(i);
int end = (i + 1 < headingPositions.size()) ?
headingPositions.get(i + 1) : content.length();
String sectionContent = content.substring(start, end).trim();
// 如果这个section太长,再进行滑动窗口分块
if (estimateTokens(sectionContent) > TARGET_CHUNK_SIZE * 1.5) {
chunks.addAll(slidingWindowChunk(document, sectionContent, headings.get(i)));
} else {
chunks.add(buildChunk(document, sectionContent, headings.get(i), i));
}
}
return chunks;
}
private List<DocumentChunk> slidingWindowChunk(Document document,
String content, String parentHeading) {
List<DocumentChunk> chunks = new ArrayList<>();
// 按句子边界分割
String[] sentences = content.split("(?<=[。!?.!?\\n])");
StringBuilder currentChunk = new StringBuilder();
int chunkIndex = 0;
for (String sentence : sentences) {
if (estimateTokens(currentChunk.toString() + sentence) > TARGET_CHUNK_SIZE) {
if (currentChunk.length() > 0) {
chunks.add(buildChunk(document, currentChunk.toString(),
parentHeading, chunkIndex++));
// 保留最后几句作为重叠
String[] prevSentences = currentChunk.toString()
.split("(?<=[。!?.!?])");
currentChunk = new StringBuilder();
int overlapStart = Math.max(0, prevSentences.length - 2);
for (int i = overlapStart; i < prevSentences.length; i++) {
currentChunk.append(prevSentences[i]);
}
}
}
currentChunk.append(sentence);
}
if (currentChunk.length() > 0) {
chunks.add(buildChunk(document, currentChunk.toString(),
parentHeading, chunkIndex));
}
return chunks;
}
private int estimateTokens(String text) {
// 中文:约1.5字符=1token;英文:约4字符=1token
int chineseCount = (int) text.chars()
.filter(c -> c >= 0x4E00 && c <= 0x9FFF).count();
int otherCount = text.length() - chineseCount;
return (int)(chineseCount / 1.5 + otherCount / 4.0);
}
}向量检索核心实现
使用PostgreSQL + pgvector作为向量数据库(比部署独立向量数据库简单):
-- 数据库Schema
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE document_chunks (
id BIGSERIAL PRIMARY KEY,
document_id BIGINT NOT NULL,
chunk_index INTEGER NOT NULL,
title TEXT,
content TEXT NOT NULL,
embedding vector(1536), -- OpenAI text-embedding-3-small的维度
metadata JSONB,
created_at TIMESTAMP DEFAULT NOW()
);
-- 创建向量索引(HNSW算法,比IVFFlat更适合实时搜索)
CREATE INDEX ON document_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- 全文搜索索引(中文需要jieba分词,这里用简单的GIN索引)
CREATE INDEX ON document_chunks USING gin(to_tsvector('simple', content));@Repository
public class VectorSearchRepository {
@Autowired
private JdbcTemplate jdbcTemplate;
@Autowired
private EmbeddingService embeddingService;
/**
* 混合搜索:向量相似度 + BM25全文搜索,RRF融合
*/
public List<SearchResult> hybridSearch(String query, int topK, SearchFilter filter) {
// 1. 获取查询向量
float[] queryEmbedding = embeddingService.embed(query);
// 2. 向量搜索(语义相关性)
List<SearchResult> vectorResults = vectorSearch(queryEmbedding, topK * 2, filter);
// 3. 关键词搜索(精确匹配)
List<SearchResult> keywordResults = keywordSearch(query, topK * 2, filter);
// 4. RRF(倒数排名融合)算法合并两路结果
return reciprocalRankFusion(vectorResults, keywordResults, topK);
}
private List<SearchResult> vectorSearch(float[] embedding, int topK, SearchFilter filter) {
String sql = """
SELECT
dc.id,
dc.document_id,
dc.title,
dc.content,
dc.metadata,
1 - (dc.embedding <=> ?::vector) AS similarity_score
FROM document_chunks dc
JOIN documents d ON dc.document_id = d.id
WHERE d.source_type = ANY(?)
AND dc.embedding IS NOT NULL
ORDER BY dc.embedding <=> ?::vector
LIMIT ?
""";
String embeddingStr = Arrays.toString(embedding)
.replace("[", "[").replace("]", "]");
return jdbcTemplate.query(sql, (rs, rowNum) -> SearchResult.builder()
.chunkId(rs.getLong("id"))
.documentId(rs.getLong("document_id"))
.title(rs.getString("title"))
.content(rs.getString("content"))
.score(rs.getDouble("similarity_score"))
.metadata(parseMetadata(rs.getString("metadata")))
.build(),
embeddingStr,
filter.getSourceTypes().toArray(new String[0]),
embeddingStr,
topK
);
}
/**
* RRF算法:给每个文档从两路结果中取最好的排名,融合成最终排名
* score = 1/(k + rank_in_list1) + 1/(k + rank_in_list2)
*/
private List<SearchResult> reciprocalRankFusion(
List<SearchResult> vectorResults,
List<SearchResult> keywordResults,
int topK) {
int k = 60; // RRF的平滑参数,通常取60
Map<Long, Double> fusedScores = new HashMap<>();
Map<Long, SearchResult> resultMap = new HashMap<>();
// 向量结果得分
for (int i = 0; i < vectorResults.size(); i++) {
SearchResult result = vectorResults.get(i);
double rrfScore = 1.0 / (k + i + 1);
fusedScores.merge(result.getChunkId(), rrfScore, Double::sum);
resultMap.put(result.getChunkId(), result);
}
// 关键词结果得分
for (int i = 0; i < keywordResults.size(); i++) {
SearchResult result = keywordResults.get(i);
double rrfScore = 1.0 / (k + i + 1);
fusedScores.merge(result.getChunkId(), rrfScore, Double::sum);
resultMap.putIfAbsent(result.getChunkId(), result);
}
// 按融合分数排序,取topK
return fusedScores.entrySet().stream()
.sorted(Map.Entry.<Long, Double>comparingByValue().reversed())
.limit(topK)
.map(entry -> {
SearchResult result = resultMap.get(entry.getKey());
return result.withScore(entry.getValue()); // 更新分数为融合分数
})
.collect(Collectors.toList());
}
}RAG问答引擎
有了检索能力,现在把它和LLM结合起来:
@Service
public class KnowledgeBaseQAService {
private final VectorSearchRepository searchRepository;
private final AnthropicClient anthropicClient;
private final QueryRewriter queryRewriter;
public QAResponse answer(String userQuestion, String userId) {
// 第一步:问题改写(多视角扩展)
List<String> queries = queryRewriter.expand(userQuestion);
// 第二步:多路检索并去重合并
Set<SearchResult> allResults = new LinkedHashSet<>();
queries.forEach(q -> {
List<SearchResult> results = searchRepository.hybridSearch(q, 5,
buildUserFilter(userId));
allResults.addAll(results);
});
// 第三步:相关性过滤(去掉相关性太低的结果)
List<SearchResult> relevantResults = allResults.stream()
.filter(r -> r.getScore() > 0.3)
.limit(8)
.collect(Collectors.toList());
if (relevantResults.isEmpty()) {
return QAResponse.builder()
.answer("抱歉,我在知识库中没有找到与您问题相关的内容。")
.sources(Collections.emptyList())
.build();
}
// 第四步:构建上下文并调用LLM
String context = buildContext(relevantResults);
String prompt = buildPrompt(userQuestion, context);
String answer = anthropicClient.complete(prompt);
// 第五步:提取引用的来源
List<SourceReference> sources = extractSources(relevantResults, answer);
return QAResponse.builder()
.answer(answer)
.sources(sources)
.searchResults(relevantResults)
.build();
}
private String buildContext(List<SearchResult> results) {
StringBuilder context = new StringBuilder();
for (int i = 0; i < results.size(); i++) {
SearchResult result = results.get(i);
context.append(String.format("[来源%d] %s\n", i + 1, result.getTitle()));
context.append(result.getContent());
context.append("\n\n");
}
return context.toString();
}
private String buildPrompt(String question, String context) {
return String.format("""
你是公司的内部知识助手。请根据以下内部文档内容,回答用户的问题。
规则:
1. 只根据提供的文档内容回答,不要使用文档之外的知识
2. 如果文档中没有足够信息,明确告诉用户"文档中没有找到相关信息"
3. 回答时引用来源,格式为[来源N]
4. 回答要简洁准确,不要废话
5. 如果问题涉及多个文档,综合各个来源给出完整答案
参考文档:
%s
用户问题:%s
请回答:
""", context, question);
}
}查询改写——提升召回率的关键
@Service
public class QueryRewriter {
private final AnthropicClient anthropicClient;
/**
* 将一个问题改写成多个视角,提高召回率
*/
public List<String> expand(String originalQuery) {
// 原始查询本身
List<String> queries = new ArrayList<>();
queries.add(originalQuery);
// 让AI生成3个改写版本
String prompt = String.format("""
用户的搜索问题是:"%s"
请生成3个语义相同但表达不同的搜索查询,帮助从不同角度检索相关文档。
要求:
1. 保持原意不变
2. 使用不同的关键词和表达方式
3. 每行一个查询
4. 只输出查询文本,不要编号或解释
""", originalQuery);
try {
String response = anthropicClient.complete(prompt);
List<String> expandedQueries = Arrays.stream(response.split("\n"))
.map(String::trim)
.filter(q -> !q.isEmpty() && q.length() > 3)
.limit(3)
.collect(Collectors.toList());
queries.addAll(expandedQueries);
} catch (Exception e) {
// 如果改写失败,只用原始查询
}
return queries;
}
}效果评估:怎么知道搜索变好了
这一点很多团队忽视了。建了系统,不知道是好是坏。
建立一个评估数据集——收集团队历史上真实的问题和对应的"好答案":
@Service
public class QualityEvaluator {
/**
* 用LLM作为评判者,评估答案质量(LLM as Judge)
*/
public EvaluationResult evaluate(String question, String answer,
String groundTruth) {
String evalPrompt = String.format("""
请评估以下AI回答的质量(满分10分):
问题:%s
参考答案:%s
AI回答:%s
从以下维度评分(每项1-10分):
1. 准确性:答案是否正确
2. 完整性:是否覆盖了关键信息点
3. 简洁性:是否没有废话
4. 引用合理性:是否正确引用了来源
输出JSON格式:
{
"accuracy": 分数,
"completeness": 分数,
"conciseness": 分数,
"citation_quality": 分数,
"overall": 综合分数,
"explanation": "简要说明"
}
""", question, groundTruth, answer);
String evalResult = anthropicClient.complete(evalPrompt);
return parseEvalResult(evalResult);
}
}踩坑:embedding模型的中文表现差异
我在做这套系统时发现一个问题:同样的内容,用OpenAI的text-embedding-ada-002和用国内的text-embedding-v2(阿里云)效果差别很大。
测试场景:搜索"支付失败如何退款"
用OpenAI的模型,能找到标题为"订单退款处理流程"的文档(中文语义相似度很好)。
但某个国产embedding模型,反而找到了"支付接口技术文档"(关键词"支付"匹配了,但语义理解不足)。
如果你的知识库主要是中文内容,务必测试embedding模型的中文语义理解能力,不要直接选用通用排行榜上排名高的模型。
