如何设计可维护的银行交易分类 fallback pipeline?
结论
采用 chain/fallback pipeline 是合理的,尤其适合不同银行字段完整度不一的场景。银行类别、MCC、商户别名、关键词以及后续的 ML 模型都可以拆成独立策略,避免 service 最终变成难以维护的大型 if/else。
但 classifier 最好不要只返回 Optional<TransactionCategory>。实际系统往往还需要记录:
- 分类依据;
- 命中的规则;
- 结果的可信度;
- 是否存在冲突;
- 使用的规则或模型版本。
另外,“第一个命中即结束”只适合优先级明确、误判率可控的分类器。银行类别未必总比 MCC 或商户规则可靠,所以执行顺序应根据数据质量配置,不能把它写死为业务常识。
先把标准化与分类分开
银行原始行解析、文本标准化和类别判断是三个不同的职责:
Bank row
↓
Bank-specific parser
↓
ParsedTransaction
↓
Normalization / feature extraction
↓
Classification pipeline
↓
ClassificationResult
ParsedTransaction 应尽量保留解析后字段的原始含义。不要为了方便匹配而直接覆盖 merchantName 或 description,可以另外生成分类上下文:
public record ClassificationContext(
ParsedTransaction transaction,
String normalizedMerchant,
String normalizedDescription,
Set<String> merchantTokens,
String sourceBank
) {}
标准化组件可以统一处理:
- 大小写及 Unicode;
- 多余的空白和标点;
- 银行固定前缀;
- 脱敏卡号;
- 支付参考号;
- 可识别的日期和地点后缀。
去噪需要有针对性,不能简单删除所有数字。门牌号、商户分店号、账单编号,甚至部分交易类型都可能包含有效数字。
返回可解释的分类结果
相比 Optional<TransactionCategory>,更适合的返回值是带有证据的候选结果:
public enum ClassificationSource {
USER_OVERRIDE,
BANK_CATEGORY,
MCC,
MERCHANT_ALIAS,
KEYWORD_RULE,
MACHINE_LEARNING
}
public record ClassificationCandidate(
TransactionCategory category,
ClassificationSource source,
String ruleId,
double confidence,
String explanation
) {}
classifier 接口仍然可以很简单:
public interface TransactionClassifier {
Optional<ClassificationCandidate> classify(
ClassificationContext context
);
}
最终结果还可以保存 pipeline 和规则版本:
public record ClassificationResult(
TransactionCategory category,
ClassificationSource source,
String ruleId,
double confidence,
String explanation,
String rulesetVersion
) {}
这些信息不只是为了方便排查。后续加入人工修正、规则灰度发布、历史数据重算和 ML 训练时,同样会用到它们。
如何组织 fallback chain
如果各 classifier 的优先级已经明确约定,可以按顺序执行并采用第一个有效结果:
public final class TransactionClassificationService {
private final List<TransactionClassifier> classifiers;
private final String rulesetVersion;
public TransactionClassificationService(
List<TransactionClassifier> classifiers,
String rulesetVersion
) {
this.classifiers = List.copyOf(classifiers);
this.rulesetVersion = rulesetVersion;
}
public ClassificationResult classify(ClassificationContext context) {
return classifiers.stream()
.map(classifier -> classifier.classify(context))
.flatMap(Optional::stream)
.findFirst()
.map(candidate -> new ClassificationResult(
candidate.category(),
candidate.source(),
candidate.ruleId(),
candidate.confidence(),
candidate.explanation(),
rulesetVersion
))
.orElse(new ClassificationResult(
TransactionCategory.UNKNOWN,
null,
null,
0.0,
"No classifier produced an acceptable result",
rulesetVersion
));
}
}
在 Spring 中,可以显式构造执行顺序,也可以通过 @Order 管理 classifier 顺序。不过,分类优先级本身属于业务规则,显式配置通常比依赖组件扫描顺序更容易审查。
初始顺序可以设为:
- 用户或人工确认的覆盖规则;
- 银行及账户范围内的高精度规则;
- MCC 映射;
- 商户别名;
- 关键词或交易类型规则;
- 达到置信度阈值的 ML 结果;
UNKNOWN。
这个顺序只是起点,不能直接套用到所有银行。某家银行的 bankCategoryName 可能稳定可靠,另一家银行却可能大量使用语义模糊的类别。因此,映射至少要包含 sourceBank,必要时还要区分账户类型和数据格式版本。
第一命中与候选评分如何选择
第一命中模型简单且容易预测,适合以下情况:
- 规则已经按精度严格排序;
- 高优先级规则的误判率在可接受范围内;
- classifier 能在证据不足时不返回结果;
- 规则之间很少冲突。
如果多个来源经常给出不同结论,可以让所有 classifier 都返回候选项,再交给统一的决策组件:
public interface TransactionClassifier {
List<ClassificationCandidate> classify(
ClassificationContext context
);
}
聚合器可以同时考虑来源优先级、置信度、规则特异性和冲突情况。例如,精确的商户别名通常应排在宽泛的 description 关键词之前。
不要默认所有来源的 confidence 都是可以直接比较的概率。手工规则的 0.9 与模型输出的 0.9 未必含义相同。若要统一排序,就需要定义评分语义,并使用真实标注数据进行校准。否则,采用”来源优先级 + 来源内部评分”通常更稳妥。
映射数据放在哪里
数据库、配置文件和代码各有用途,不必三选一。
适合放在代码中的内容
稳定的领域结构和算法适合留在代码中,例如:
TransactionCategory;- classifier 接口;
- 标准化算法;
- 候选结果的决策流程;
- 规则校验逻辑。
经常变化的商户名单不适合写成大量 switch 或 if。
适合放在配置文件中的内容
如果映射规模不大,由开发人员维护,并且希望通过代码审查和部署发布,可以使用 YAML、JSON 或 CSV。例如:
mccMappings:
"5411": GROCERIES
"4111": TRANSPORT
"5812": RESTAURANTS
merchantAliases:
- id: merchant-ah
canonicalName: ALBERT_HEIJN
aliases:
- AH
- ALBERT HEIJN
- AH TO GO
category: GROCERIES
配置文件简单、可以纳入版本控制,也容易回滚。但修改通常需要经过发布流程,不适合大量在线编辑或按租户维护。
适合放在数据库中的内容
出现以下需求时,更适合使用数据库:
- 运营人员需要直接维护规则;
- 映射必须在不发布应用的情况下更新;
- 规则因银行、国家、账户或租户而异;
- 需要设置生效和失效时间、优先级及审批状态;
- 需要审计、版本、回滚或灰度发布;
- 规则数量较大。
表结构不应只有”字符串到类别”两个字段,至少可以考虑:
rule_id
rule_type
source_bank
match_value
match_mode
category
priority
enabled
valid_from
valid_to
ruleset_version
created_by
updated_at
其中 match_mode 可以区分:
EXACT;PREFIX;TOKEN;REGEX;MCC_EXACT;BANK_CATEGORY_EXACT。
精确匹配、别名匹配和正则匹配需要明确区分。否则,规则增多后很难判断交易为什么会命中某条规则。
实践中常采用混合方案:内置的基础 MCC 映射放在版本化文件中,变化较频繁的商户别名和人工覆盖规则则存入数据库。
商户别名不要只做字符串替换
AH、Albert Heijn 和 AH TO GO 可以指向同一个 canonical merchant,但别名规则必须考虑匹配边界。
例如,直接使用 description.contains("AH") 很容易误匹配无关单词。对于短别名,尤其应该采用:
- 标准化后的 merchant 字段,而不是任意 description;
- 完整字段匹配;
- token 边界匹配;
- 银行或国家范围限制;
- 更高的最小匹配长度。
商户识别和类别映射也可以拆开:
Raw merchant text
↓
Canonical merchant: ALBERT_HEIJN
↓
Merchant category: GROCERIES
这样,每个别名只负责解析到 canonical merchant,类别规则不必为同一商户的每个别名重复维护。
同一商户也未必始终属于同一类别,大型电商平台、商场、支付聚合商和钱包类描述尤其如此。遇到这类商户时,强行分配固定类别可能比返回 UNKNOWN 风险更高。
银行类别和 MCC 也需要上下文
银行提供的 category 通常属于外部分类体系。即使名称相同,也不能认为它与内部类别的语义完全一致。映射时建议使用组合键:
(sourceBank, bankCategoryName) → internalCategory
而不是只使用:
bankCategoryName → internalCategory
MCC 同样不是绝对事实。它通常描述受理商户的类型,不一定代表某笔交易的实际用途,而且可能缺失、错误或范围过宽。MCC 映射可以作为较强的证据,但仍要保存来源和原始值。
交易方向也应该纳入上下文。相同的描述可能对应付款、退款、收入或转账,至少要保留:
- 金额正负或 debit/credit 方向;
- 交易类型;
- 是否冲正;
- 是否退款;
- 是否为账户间转账。
否则,Transfer 之类的关键词很容易把工资、外部汇款和本人账户之间的转账混在一起。
后续加入 ML 不需要改写流程
只要 ML classifier 遵循同一个接口,就可以把它作为低优先级策略加入现有 pipeline:
public final class MachineLearningClassifier
implements TransactionClassifier {
private static final double ACCEPTANCE_THRESHOLD = 0.85;
@Override
public Optional<ClassificationCandidate> classify(
ClassificationContext context
) {
ModelPrediction prediction = predict(context);
if (prediction.confidence() < ACCEPTANCE_THRESHOLD) {
return Optional.empty();
}
return Optional.of(new ClassificationCandidate(
prediction.category(),
ClassificationSource.MACHINE_LEARNING,
prediction.modelVersion(),
prediction.confidence(),
"Prediction accepted above configured threshold"
));
}
private ModelPrediction predict(ClassificationContext context) {
throw new UnsupportedOperationException("Model integration omitted");
}
}
这里的 0.85 只是结构示例,并非通用推荐值。实际阈值要根据独立验证数据、各类别的误判成本以及置信度校准结果确定。
从一开始,ML 就应该遵守以下约束:
- 允许拒绝分类并回退到
UNKNOWN; - 保存模型版本和输入特征版本;
- 分开存储预测结果与最终人工修正;
- 防止未经确认的历史预测再次被当作训练标签;
- 按时间切分训练集和验证集,避免同一交易或近似重复交易发生泄漏;
- 分类别评估,而不只看整体 accuracy。
例如,大量 GROCERIES 样本可能让整体 accuracy 看起来不错,但系统仍可能几乎无法识别 RENT 或 TRANSFER。
应尽早处理的常见陷阱
把缺失值和空字符串混为一谈
不同解析器可能分别产生 null、"" 或只包含空格的字符串。应在边界层统一缺失值的语义,否则各 classifier 的行为会不一致。
过度信任来源顺序
字段存在不代表字段可靠。classifier 只有在证据足够时才应返回候选,不能看到非空字段就强制给出类别。
关键词规则过宽
rent、gas、care 等词可能出现在商户名、地点或参考文本中。规则需要明确作用字段、词边界、排除条件和优先级。
没有负向规则
只支持”出现某词就归类”通常不够。例如,一条规则可能需要表达:
包含 "NS"
且 merchant 字段精确匹配
但不包含特定退款或账户验证标记
规则模型应允许表达必要的排除条件,不能依靠不断调整执行顺序来掩盖冲突。
丢失原始数据
标准化结果和原始字段都要保留。否则,规则发生误判时无法复现,标准化算法升级后也不能重新计算。
处理卡号、账户号或付款参考号时,还要遵守数据最小化原则,只保留分类和审计确实需要的信息,并对日志中的相关数据进行脱敏。
没有版本信息
规则变化后,同一笔交易在不同时间可能得到不同结果。系统应保存:
- 分类时间;
- ruleset version;
- classifier source;
- rule ID 或 model version;
- 是否经过人工覆盖。
有了这些信息,才能解释历史结果,并判断是否需要批量重新分类。
把人工修正覆盖掉
人工确认的结果通常应具有最高优先级。重新运行 pipeline 时,不能静默覆盖用户修正。更稳妥的做法是分别保存:
predictedCategory
confirmedCategory
classificationStatus
最终展示类别可以优先使用 confirmedCategory。
只测试单个 classifier
除了为每个 classifier 编写单元测试,还需要准备 pipeline 级别的回归样本。测试数据应覆盖:
- 字段缺失;
- 来源冲突;
- 大小写和 Unicode 差异;
- 退款和冲正;
- 正负金额;
- 短商户别名;
- 噪声文本;
- 完全未知的商户;
- 新规则是否意外改变旧结果。
可以维护一组版本化的”黄金交易样本”,每次修改标准化逻辑或分类规则时都运行回归测试。
推荐的落地方式
可以先实现一个简单但信息完整的版本:
- 保留银行原始字段,并生成统一的
ParsedTransaction。 - 使用独立组件生成
ClassificationContext。 - 让 classifier 返回包含来源、规则 ID 和可信度的候选结果。
- 初期采用显式排序的 first-match pipeline。
- 将基础 MCC 映射放入版本化配置,把频繁变化的商户规则和人工规则放入数据库。
- 保存最终结果、命中证据和 ruleset version。
- 无法可靠判断的交易返回
UNKNOWN。 - 积累经过人工确认的标签后,再接入能够拒绝预测的 ML classifier。
- 规则冲突逐渐增多后,再把 first-match 升级为候选聚合,不必一开始就构建复杂的评分引擎。
这个方案整体可行。系统能否长期维护,不仅取决于是否把 classifier 拆成多个类,还取决于是否把标准化、规则数据、优先级、解释信息、人工覆盖和版本管理建模为明确的一等概念。
备注:内容仅供参考。
