AI系统设计面试:知识库问答系统完整设计方案
2026/4/30大约 9 分钟
AI系统设计面试:知识库问答系统完整设计方案
适读人群:有1-5年Java开发经验,想向AI工程师方向转型的开发者 阅读时长:约22分钟 文章价值:
- 掌握知识库问答系统的完整架构设计思路
- 学会在面试中有条理地拆解AI系统设计题
- 获得可直接用于面试的完整设计方案
面试官的那道"开放题"
老赵是我朋友,在某互联网公司做了五年后端,年初拿到了字节AI基础设施团队的三面机会。
三面是系统设计轮。面试官给了他一道题:"设计一个企业内部知识库问答系统,要求支持10万份文档,日均查询量100万次,P99延迟不超过3秒。说说你的设计。"
老赵沉默了大概30秒,然后开始说……结果他的思路直接从技术实现跳进去了,上来就讲用什么向量数据库、用什么Embedding模型。
面试官听了两分钟,打断他:"你先说说系统需要解决哪些核心问题?"
老赵再次蒙了。
事后他跟我说,那一刻他意识到:自己会写代码,但不会设计系统。
AI系统设计面试,考察的不是你能不能背API文档,而是你能不能像一个架构师一样,把一个模糊的需求拆解成可落地的方案。
今天这篇文章,我用知识库问答系统这道经典题,给你演示完整的系统设计过程。
第一步:需求澄清(面试中必做)
拿到题目别急着设计,先澄清需求。这个动作本身就是加分项,说明你有工程师的系统思维。
功能性需求:
- 用户能上传文档(PDF/Word/Markdown等)
- 用户能用自然语言提问,系统基于知识库回答
- 支持多轮对话(记住上下文)
- 支持答案来源展示(引用哪份文档哪段话)
非功能性需求(面试官给的):
- 文档规模:10万份,平均每份100页
- 查询量:日均100万次,峰值QPS约500
- 延迟:P99 < 3秒
- 可用性:99.9%
边界条件(要主动问):
- 文档是否需要实时更新?更新频率如何?
- 是否需要权限控制(某些文档只有特定人能看)?
- 是否需要多语言支持?
整体架构设计
文档处理流水线
这是整个系统的基础,文档处理质量直接决定问答效果。
文档分块策略(这个细节面试官会追问)
/**
* 智能文档分块服务
* 核心策略:语义感知分块,不在句子中间截断
*/
@Service
@Slf4j
public class DocumentSplitterService {
// 每个Chunk的目标Token数
private static final int CHUNK_SIZE = 512;
// 相邻Chunk的重叠Token数(用于保持上下文连贯)
private static final int CHUNK_OVERLAP = 64;
/**
* 将文档文本分割为Chunks
* 策略:优先按段落分,其次按句子分,最后按Token数截断
*/
public List<DocumentChunk> split(String documentId, String content) {
List<DocumentChunk> chunks = new ArrayList<>();
// 1. 先按段落分割(保留语义完整性)
String[] paragraphs = content.split("\n\n+");
StringBuilder currentChunk = new StringBuilder();
int currentTokenCount = 0;
int chunkIndex = 0;
for (String paragraph : paragraphs) {
int paragraphTokens = estimateTokenCount(paragraph);
// 段落本身超过CHUNK_SIZE,需要进一步按句子拆分
if (paragraphTokens > CHUNK_SIZE) {
// 先保存当前缓冲区
if (currentChunk.length() > 0) {
chunks.add(createChunk(documentId, chunkIndex++,
currentChunk.toString()));
currentChunk = new StringBuilder();
currentTokenCount = 0;
}
// 按句子拆分大段落
chunks.addAll(splitBySentence(documentId, paragraph,
chunkIndex));
chunkIndex += chunks.size();
continue;
}
// 加入当前段落后超过CHUNK_SIZE,先保存当前chunk
if (currentTokenCount + paragraphTokens > CHUNK_SIZE
&& currentChunk.length() > 0) {
chunks.add(createChunk(documentId, chunkIndex++,
currentChunk.toString()));
// 添加重叠内容(取上一个chunk的最后几句话)
String overlap = extractOverlap(currentChunk.toString());
currentChunk = new StringBuilder(overlap);
currentTokenCount = estimateTokenCount(overlap);
}
currentChunk.append(paragraph).append("\n\n");
currentTokenCount += paragraphTokens;
}
// 保存最后一个chunk
if (currentChunk.length() > 0) {
chunks.add(createChunk(documentId, chunkIndex,
currentChunk.toString().trim()));
}
log.info("文档 {} 分割完成,共 {} 个Chunk", documentId, chunks.size());
return chunks;
}
private DocumentChunk createChunk(String docId, int index, String content) {
return DocumentChunk.builder()
.documentId(docId)
.chunkIndex(index)
.content(content)
.tokenCount(estimateTokenCount(content))
.build();
}
/**
* 估算Token数(简单实现:按词数 * 1.3估算)
* 生产环境建议使用tiktoken库精确计算
*/
private int estimateTokenCount(String text) {
return (int) (text.split("\\s+").length * 1.3);
}
private String extractOverlap(String text) {
// 取最后CHUNK_OVERLAP个token对应的文本作为重叠部分
String[] words = text.split("\\s+");
int overlapWords = (int) (CHUNK_OVERLAP / 1.3);
if (words.length <= overlapWords) return text;
return String.join(" ",
Arrays.copyOfRange(words, words.length - overlapWords, words.length));
}
private List<DocumentChunk> splitBySentence(String docId,
String text, int startIndex) {
// 按句号/问号/感叹号分句
String[] sentences = text.split("(?<=[。!?.!?])");
// 类似按段落的逻辑,按句子组合chunks
// 代码略,逻辑与按段落一致
return new ArrayList<>();
}
}查询处理流程
核心查询服务实现
/**
* 知识库问答核心服务
* 整合向量检索、关键词检索、重排序、LLM生成
*/
@Service
@Slf4j
@RequiredArgsConstructor
public class KnowledgeQAService {
private final EmbeddingService embeddingService;
private final VectorSearchService vectorSearch;
private final KeywordSearchService keywordSearch;
private final RerankerService reranker;
private final ChatModel chatModel;
private final ChatHistoryService chatHistory;
private final RedisTemplate<String, Object> redisTemplate;
private static final int CACHE_TTL_SECONDS = 3600;
private static final String CACHE_KEY_PREFIX = "qa:cache:";
/**
* 问答主流程
*/
public QAResponse answer(QARequest request) {
String cacheKey = CACHE_KEY_PREFIX +
DigestUtils.md5Hex(request.getQuestion() + request.getUserId());
// 1. 缓存检查(完全相同的问题直接返回)
QAResponse cached = (QAResponse) redisTemplate.opsForValue().get(cacheKey);
if (cached != null) {
log.debug("缓存命中: {}", cacheKey);
return cached;
}
// 2. 问题向量化
float[] questionEmbedding = embeddingService.embed(request.getQuestion());
// 3. 并行双路召回
CompletableFuture<List<RetrievedChunk>> vecFuture =
CompletableFuture.supplyAsync(() ->
vectorSearch.search(questionEmbedding, 20));
CompletableFuture<List<RetrievedChunk>> kwFuture =
CompletableFuture.supplyAsync(() ->
keywordSearch.search(request.getQuestion(), 20));
List<RetrievedChunk> vectorResults = vecFuture.join();
List<RetrievedChunk> keywordResults = kwFuture.join();
// 4. RRF融合
List<RetrievedChunk> merged = rrfMerge(vectorResults, keywordResults);
// 5. 精排(取Top5用于生成)
List<RetrievedChunk> reranked = reranker.rerank(
request.getQuestion(), merged, 5);
// 6. 构建Prompt(含对话历史)
String prompt = buildPrompt(
request.getQuestion(),
reranked,
chatHistory.getHistory(request.getSessionId(), 5) // 最近5轮对话
);
// 7. 调用LLM生成答案
ChatResponse llmResponse = chatModel.call(new Prompt(prompt));
String answer = llmResponse.getResult().getOutput().getContent();
// 8. 构建响应(含来源引用)
QAResponse response = QAResponse.builder()
.answer(answer)
.sources(buildSources(reranked))
.sessionId(request.getSessionId())
.build();
// 9. 存入缓存
redisTemplate.opsForValue().set(cacheKey, response,
Duration.ofSeconds(CACHE_TTL_SECONDS));
// 10. 更新对话历史
chatHistory.addTurn(request.getSessionId(),
request.getQuestion(), answer);
return response;
}
/**
* 构建包含上下文和历史的Prompt
*/
private String buildPrompt(String question,
List<RetrievedChunk> chunks,
List<ChatTurn> history) {
StringBuilder sb = new StringBuilder();
sb.append("你是一个企业内部知识库助手,请基于以下参考资料回答问题。");
sb.append("如果资料中没有相关信息,请如实说明,不要编造答案。\n\n");
sb.append("【参考资料】\n");
for (int i = 0; i < chunks.size(); i++) {
RetrievedChunk chunk = chunks.get(i);
sb.append(String.format("[%d] 来自《%s》第%d页:\n%s\n\n",
i + 1, chunk.getDocumentTitle(),
chunk.getPageNumber(), chunk.getContent()));
}
if (!history.isEmpty()) {
sb.append("【对话历史】\n");
for (ChatTurn turn : history) {
sb.append("用户:").append(turn.getQuestion()).append("\n");
sb.append("助手:").append(turn.getAnswer()).append("\n");
}
sb.append("\n");
}
sb.append("【当前问题】\n").append(question);
return sb.toString();
}
private List<RetrievedChunk> rrfMerge(List<RetrievedChunk> list1,
List<RetrievedChunk> list2) {
// RRF实现(参考article-018中的代码)
Map<String, Double> scores = new HashMap<>();
Map<String, RetrievedChunk> chunkMap = new HashMap<>();
int k = 60;
for (int i = 0; i < list1.size(); i++) {
RetrievedChunk c = list1.get(i);
scores.merge(c.getId(), 1.0 / (k + i + 1), Double::sum);
chunkMap.put(c.getId(), c);
}
for (int i = 0; i < list2.size(); i++) {
RetrievedChunk c = list2.get(i);
scores.merge(c.getId(), 1.0 / (k + i + 1), Double::sum);
chunkMap.putIfAbsent(c.getId(), c);
}
return scores.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.map(e -> chunkMap.get(e.getKey()))
.collect(Collectors.toList());
}
private List<SourceReference> buildSources(List<RetrievedChunk> chunks) {
return chunks.stream()
.map(c -> SourceReference.builder()
.documentId(c.getDocumentId())
.documentTitle(c.getDocumentTitle())
.pageNumber(c.getPageNumber())
.excerpt(c.getContent().substring(0,
Math.min(100, c.getContent().length())) + "...")
.build())
.collect(Collectors.toList());
}
}关键设计决策与取舍
| 设计点 | 方案A | 方案B | 我的选择 | 原因 |
|---|---|---|---|---|
| 向量数据库 | Milvus | pgvector | Milvus | 10万文档×每文档约20 chunk=200万向量,需要专业向量库 |
| Embedding模型 | OpenAI text-embedding-3-small | 本地BGE-M3 | 混用 | 在线文档用OpenAI,敏感文档用本地模型 |
| Reranker | BGE-Reranker | Cross-Encoder | BGE-Reranker | 性能和效果均衡,支持中文 |
| 缓存策略 | 精确缓存 | 语义缓存 | 精确缓存+语义缓存 | 精确缓存命中率高,语义缓存覆盖相似问题 |
| 对话历史存储 | Redis | PostgreSQL | Redis(短期)+ PG(长期) | 活跃会话在Redis,历史归档到PG |
容量估算(面试必备)
文档规模估算:
- 10万份文档 × 100页/份 = 1000万页
- 每页约500 tokens → 每份文档约50,000 tokens
- 按512 tokens/chunk分块 → 每份约100 chunks
- 总Chunks数 = 10万 × 100 = 1000万个Chunk
- 每个向量1536维(OpenAI ada-002),float32 = 4字节
- 向量存储 = 1000万 × 1536 × 4 bytes ≈ 60GB
QPS估算:
- 日均100万次查询
- 按峰值 = 平均值 × 3计算
- 平均QPS = 100万 / 86400 ≈ 12 QPS
- 峰值QPS ≈ 36 QPS
延迟分析(P99 < 3秒目标):
- 问题Embedding: ~100ms
- 并行双路检索: ~200ms(并行执行)
- Reranker: ~300ms
- LLM生成: ~1500ms(最大变量)
- 其他: ~100ms
- 总计: ~2200ms,有800ms余量面试答题策略
- 先画大图:不要一上来就深入细节,先把整体架构画出来,让面试官看到你的全局思维
- 主动说取舍:每个设计决策都说明为什么这样选,放弃了什么
- 做容量估算:主动计算数据量和QPS,说明你的方案能支撑
- 提延伸考虑:说完基本方案,主动提"如果规模再大10倍怎么办"——这是亮点
