腾讯 AI应用开发
1. 自我介绍
2. 智能合同审阅平台里为什么要同时使用向量检索和全文检索?单一检索方式为什么不够?
答案:
在合同审阅场景里,用户的问题或者待审条款通常不是简单关键词匹配,而是存在大量语义相近但表达不同的内容,比如“提前解除合同”“单方终止协议”“无责解约”语义接近,但关键词并不完全一致。
如果只用全文检索,比如 Elasticsearch 的 BM25,优点是关键词匹配精确、可解释性强,对合同编号、金额、日期、公司名称、条款标题这类结构化或半结构化字段效果很好。但它对同义表达、语义改写、隐含风险点的召回能力较弱。
如果只用向量检索,优点是语义召回能力强,但是容易召回一些语义相似但法律含义不完全一致的内容,而且对精确字段、专有名词、数字、时间、编号的处理不如全文检索稳定。
所以实际会采用混合检索:
最终候选集 = 向量召回结果 ∪ 全文召回结果 最终排序分 = α * 向量相似度 + β * BM25分数 + γ * 业务规则分
权重一般不是固定拍脑袋,而是结合离线评测和线上反馈调出来的。比如合同风险点召回更看重召回率,可以提高向量权重;如果是查询具体条款编号、合同名称、金额,则提高全文检索权重。
代码上可以先分别召回,再做归一化和融合:
public List<SearchResult> hybridSearch(String query) {
List<SearchResult> vectorResults = vectorStore.search(query, 100);
List<SearchResult> textResults = elasticSearchService.search(query, 100);
Map<String, SearchResult> merged = new HashMap<>();
for (SearchResult r : vectorResults) {
merged.computeIfAbsent(r.getDocId(), k -> r)
.setVectorScore(normalize(r.getVectorScore()));
}
for (SearchResult r : textResults) {
merged.computeIfAbsent(r.getDocId(), k -> r)
.setTextScore(normalize(r.getTextScore()));
}
return merged.values().stream()
.peek(r -> {
double finalScore =
0.55 * r.getVectorScore()
+ 0.35 * r.getTextScore()
+ 0.10 * r.getBusinessScore();
r.setFinalScore(finalScore);
})
.sorted(Comparator.comparing(SearchResult::getFinalScore).reversed())
.limit(20)
.toList();
}
3. 混合检索里的权重怎么确定?如果线上召回结果变差,你怎么定位?
答案:
权重不能只靠经验值。一般会先准备一批标注数据,包括 query、期望命中的合同片段、风险类型、人工标注相关性等级,然后用 NDCG、Recall@K、MRR 这类指标做离线评测。
比如:
Recall@20:正确答案是否出现在前 20 个结果中 MRR:第一个正确答案出现得越靠前越好 NDCG@10:考虑排序位置和相关性等级
权重调优一般分三层:
第一层是粗调,例如:
vectorScore: 0.6 bm25Score: 0.3 businessScore: 0.1
第二层是按查询类型动态调整,如果 query 中包含合同编号、日期、金额、公司名,就提高全文检索权重;如果是自然语言问题,就提高向量检索权重。
第三层是 rerank,比如使用交叉编码模型或者业务规则对 Top100 重新排序。
如果线上召回变差,我会按链路排查:
- 先看 query 分布是否变了,是不是用户问法变化或者新业务文档进入。
- 看向量模型版本是否变更,embedding 维度、归一化方式是否一致。
- 看索引是否完整,是否有文档解析失败、切片失败、入库失败。
- 看 Elasticsearch 分词器、同义词词典、字段 boost 是否变更。
- 看召回阶段和重排阶段日志,确认是没召回,还是召回了但排序靠后。
- 抽样 badcase,判断是数据问题、模型问题、分词问题还是融合策略问题。
4. Kafka 为什么适合做文档解析和向量入库的异步解耦?为什么不用数据库轮询?
答案:
文档解析和向量入库链路通常比较长,比如上传文件、OCR、PDF 解析、段落切分、敏感信息清洗、embedding 生成、向量库写入、ES 写入、状态回写。这里每一步耗时都不稳定,如果同步处理,请求很容易超时,也会把用户请求线程拖死。
Kafka 适合用在这里,主要原因是:
- 削峰填谷,上传高峰时先把任务写入消息队列。
- 解耦上下游,解析服务、向量服务、索引服务可以独立扩缩容。
- 支持消费位点管理,失败后可以重试。
- 分区机制可以保证同一文档相关消息按 key 有序。
- 吞吐能力比数据库轮询更适合大规模异步任务。
数据库轮询的问题是延迟和压力不可控。任务多的时候,轮询 SQL 会频繁扫表,即使加索引,也会给主库带来压力;任务少的时候,又会有空轮询。并且数据库天然不是消息系统,在重试、位点、消费组、积压监控方面都不如 Kafka。
实际设计时,我会让上传接口只负责落库和投递消息:
@Transactional
public Long uploadContract(MultipartFile file) {
ContractDoc doc = storageService.save(file);
contractMapper.insert(doc);
kafkaTemplate.send(
"contract-parse-topic",
doc.getId().toString(),
new ParseMessage(doc.getId(), doc.getFileUrl())
);
return doc.getId();
}
消费者异步解析:
@KafkaListener(topics = "contract-parse-topic", groupId = "contract-parser")
public void consume(ParseMessage message, Acknowledgment ack) {
try {
parseService.parseAndIndex(message.getDocId(), message.getFileUrl());
ack.acknowledge();
} catch (RetryableException e) {
throw e;
} catch (Exception e) {
deadLetterService.send(message, e);
ack.acknowledge();
}
}
5. Kafka 消息如何保证不丢、不重、不乱?如果消费者处理一半宕机怎么办?
答案:
严格说,分布式系统里很难同时做到绝对不丢、不重、不乱,通常是通过机制把问题控制在业务可接受范围内。
不丢主要靠:
- Producer 开启
acks=all - 设置合理的
retries - Broker 端配置多副本和
min.insync.replicas - 消费者手动提交 offset
- 业务处理成功后再提交 offset
不重主要靠幂等:
Kafka 可以降低重复,但不能完全指望 MQ 不重复。比如消费者处理完业务后,还没提交 offset 就宕机,重启后这条消息会再次消费。所以业务必须做幂等。
在合同解析任务里,可以给每个任务一个唯一 taskId,并且状态机流转:
INIT -> PARSING -> PARSED -> INDEXING -> DONE
处理前先判断状态,如果已经完成就直接 ack。
public void handle(ParseMessage msg) {
ContractTask task = taskMapper.selectById(msg.getTaskId());
if (task.getStatus() == TaskStatus.DONE) {
return;
}
boolean locked = taskMapper.updateStatus(
msg.getTaskId(),
TaskStatus.INIT,
TaskStatus.PARSING
) == 1;
if (!locked) {
return;
}
List<Chunk> chunks = parser.parse(msg.getFileUrl());
chunkService.batchUpsert(msg.getDocId(), chunks);
taskMapper.updateStatus(msg.getTaskId(), TaskStatus.PARSING, TaskStatus.DONE);
}
不乱主要靠同一业务 key 发到同一个 partition,比如同一个 docId 的消息都用 docId 作为 key。Kafka 只能保证单分区内有序,不能保证多分区全局有序。
消费者处理一半宕机,如果 offset 没提交,会重新消费。此时靠状态机和幂等写入恢复。如果某一步已经写入部分数据,比如写了部分 chunk,重试时要么先清理旧数据再重建,要么使用唯一键做 upsert。
6. 合同文档切片为什么不能简单按固定 1MB 切?父子文档怎么设计?
答案:
固定按 1MB 切片的问题是语义会被截断。合同文档通常有章节、条款、子条款,比如“违约责任”下面有多个细则,如果切片刚好把一个条款切断,向量语义就不完整,召回结果也会变差。
更合理的是按结构切:
合同
- 章节
- 条款
- 子条款
切片时要兼顾两个目标:
- 单个 chunk 不能太长,否则 embedding 模型截断,召回噪声变大。
- 单个 chunk 不能太短,否则缺少上下文,语义不完整。
父子文档设计一般是:
parent_doc:完整合同或章节级别 child_chunk:用于检索的条款片段
检索时先搜 child_chunk,命中后返回 parent_doc 或相邻上下文。
表结构可以这样设计:
CREATE TABLE contract_doc (
id BIGINT PRIMARY KEY,
title VARCHAR(255),
file_url VARCHAR(512),
status VARCHAR(32),
created_at DATETIME
);
CREATE TABLE contract_chunk (
id BIGINT PRIMARY KEY,
doc_id BIGINT NOT NULL,
parent_section_id BIGINT,
chunk_index INT,
content TEXT,
token_count INT,
embedding_id VARCHAR(128),
section_path VARCHAR(512),
UNIQUE KEY uk_doc_chunk(doc_id, chunk_index)
);
切片策略可以使用“结构优先 + token 限制 + overlap”:
public List<Chunk> split(List<Section> sections, int maxTokens, int overlapTokens) {
List<Chunk> result = new ArrayList<>();
for (Section section : sections) {
List<String> paragraphs = section.getParagraphs();
StringBuilder buffer = new StringBuilder();
for (String p : paragraphs) {
if (tokenizer.count(buffer + p) > maxTokens) {
result.add(new Chunk(section.getPath(), buffer.toString()));
buffer = new StringBuilder(tail(buffer.toString(), overlapTokens));
}
buffer.append("\n").append(p);
}
if (!buffer.isEmpty()) {
result.add(new Chunk(section.getPath(), buffer.toString()));
}
}
return result;
}
7. 为什么召回窗口设为 30?这个值怎么评估,不是越大越好吗?
答案:
召回窗口指的是从检索阶段拿多少候选结果进入后续重排或生成阶段。设为 30 通常是性能、成本和效果之间的折中。
窗口太小,比如 Top5,容易漏掉真正相关的内容,尤其在合同审阅这种长文档场景里,正确条款可能因为表达差异排在第十几名。
窗口太大,比如 Top200,会带来几个问题:
- rerank 计算成本变高。
- 传给大模型的上下文变长,成本和延迟上升。
- 噪声内容增加,可能干扰最终答案。
- 线上 P99 延迟不稳定。
评估时一般会看 Recall@K 曲线。比如:
K=5 Recall=72% K=10 Recall=83% K=20 Recall=90% K=30 Recall=93% K=50 Recall=94%
如果从 30 到 50 只提升 1%,但是延迟和成本明显增加,就没必要继续扩大窗口。
工程上还可以做动态窗口。如果 query 很短且命中置信度高,就用小窗口;如果 query 是复杂自然语言问题,或者前几名分数差距很小,就扩大窗口。
public int decideRecallWindow(QueryFeature feature) {
if (feature.hasExactTerm() && feature.getTop1Score() > 0.85) {
return 10;
}
if (feature.isComplexQuestion() || feature.getScoreGap() < 0.05) {
return 50;
}
return 30;
}
8. TCP 滑动窗口和拥塞控制分别解决什么问题?线上接口延迟抖动时怎么用这些知识定位?
答案:
TCP 滑动窗口主要解决接收方处理能力的问题。发送方不能无限发数据,否则接收方缓冲区会被打爆,所以接收方会通过窗口大小告诉发送方还能接收多少数据。
拥塞控制主要解决网络链路承载能力的问题。即使接收方能处理,也不代表网络中间链路能承载这么多数据。TCP 通过慢启动、拥塞避免、快速重传、快速恢复等机制,根据丢包、RTT、ACK 情况调整发送速率。
两者区别是:
滑动窗口:接收方限速 拥塞控制:网络链路限速
线上接口延迟抖动时,如果怀疑网络问题,我会看几个指标:
- RTT 是否升高。
- 是否存在重传。
- TCP 连接数是否异常。
- 服务端 accept 队列、send queue、receive queue 是否堆积。
- 是否存在跨机房访问。
- 是否有网卡丢包、带宽打满。
例如 Linux 上可以看:
ss -tin netstat -s | grep retransmit sar -n DEV 1 ethtool -S eth0
如果发现大量重传,说明可能是网络拥塞、丢包或链路质量问题。如果 send queue 长期堆积,可能是对端读得慢或网络发不出去。如果 receive queue 堆积,可能是本机应用层消费慢。
9. CompletableFuture 异步编排如何设计?线程池参数怎么定?异常怎么收敛?
答案:
CompletableFuture 适合把多个可以并行的 I/O 操作拆开,比如合同详情、风险规则、相似案例、用户权限、历史审阅记录可以并发查。
但不能直接用默认的 ForkJoinPool,因为默认线程池是全局共享的,里面如果混入阻塞 I/O,容易影响其他任务。
应该为不同类型任务配置隔离线程池:
@Bean
public ThreadPoolExecutor ioExecutor() {
return new ThreadPoolExecutor(
32,
128,
60,
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000),
new ThreadFactoryBuilder().setNameFormat("contract-io-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy()
);
}
异步编排示例:
public ReviewContext buildContext(Long contractId) {
CompletableFuture<Contract> contractFuture =
Completabl
剩余60%内容,订阅专栏后可继续查看/也可单篇购买
本专栏聚焦 AI-Agent 面试高频考点,内容来自真实面试与项目实践。系统覆盖大模型基础、Prompt工程、RAG、Agent架构、工具调用、多Agent协作、记忆机制、评测、安全与部署优化等核心模块。以“原理+场景+实战”为主线,提供高频题解析、标准答题思路与工程落地方法,帮助你高效查漏补缺.
