在做知识库问答时,我遇到一个看似很小、但很影响后续维护的问题:
单篇文章问答和全库问答,要不要共用一个接口?
最简单的写法是把 postId 做成可选参数。
有 postId:查单篇文章
没有 postId:查全库
但我最后没有这么设计,而是倾向于把两个场景拆成两个明确入口。
1. 两个场景的语义不一样
单篇文章问答的含义是:
只围绕当前这篇文章回答
全库问答的含义是:
从整个知识库里找相关资料回答
它们看起来只是过滤条件不同,但产品语义其实不一样。
如果把 postId 设计成可选参数,前端或调用方很容易误用:
- 明明想问单篇,却漏传了
postId,结果查了全库。 - 明明想问全库,却传了错误的
postId,结果检索范围被限制。 - 后端逻辑里到处判断
postId == null,接口含义变模糊。
所以我更喜欢让路径表达语义。
2. 更清晰的接口拆分
可以设计成这样:
| 场景 | 接口 | 含义 |
|---|---|---|
| 单篇问答 | GET /api/v1/knowposts/{id}/qa/stream |
只检索指定文章 |
| 全库问答 | GET /api/v1/knowposts/qa/stream |
检索整个知识库 |
这样前端看到接口就知道自己在调用什么模式。
后端 Controller 也不需要根据可选参数猜意图。
3. 服务层也应该表达意图
接口拆开之后,服务层也可以拆成两个入口:
streamPostAnswer(postId, question, topK)
streamGlobalAnswer(question, topK)
检索层也是一样:
retrieveForPost(postId, question, topK)
retrieveGlobal(question, topK)
这不是为了多写几个方法,而是为了让调用链更可读。
单篇链路:
Controller 单篇入口
-> QueryService 单篇回答
-> RetrievalService 单篇检索
-> 添加 postId 过滤器
-> RRF 融合
-> 生成回答
全库链路:
Controller 全库入口
-> QueryService 全库回答
-> RetrievalService 全库检索
-> 不添加 postId 过滤器
-> RRF 融合
-> 生成回答
差异集中在检索范围,其他逻辑继续复用。
4. 为什么不用“一个方法全包”
初学时我会觉得:能复用就尽量复用,一个方法里判断参数不是更简单吗?
后来发现,复用不等于把所有逻辑塞进一个方法。
如果一个方法同时代表两种业务语义,它的名字就会越来越抽象,后续改动也越来越危险。
比如:
search(question, postId, topK)
这个方法看不出来:
- 是否会触发单篇索引检查。
- 是否允许全库检索。
- postId 为空是不是合法。
- 未来加入权限时该怎么判断。
相比之下,两个命名明确的方法更长,但更不容易误用。
5. topK 保留给前端,但要限制范围
topK 是一个值得保留的参数。
因为它直接影响检索观察:
- 太小:可能漏掉正确 chunk。
- 太大:上下文噪声变多,模型更容易跑偏。
所以可以让前端传 topK,但后端要限制范围,例如 1-20。
这样既能调试,也不会把检索规模放飞。
6. 这次设计给我的经验
接口设计不是只看“能不能少写代码”。
更重要的是:
路径能不能表达业务语义?
调用方会不会误用?
服务层方法名能不能说明意图?
后续加权限、记忆、debug 时会不会越来越混乱?
对 RAG 来说,单篇问答和全库问答是两个不同产品能力。
所以我更愿意让它们在接口上分开,在内部复用共同的检索和回答流程。
这是一种更稳的工程选择。
