feat(wiki): route cheap ingest steps to a configurable light model

This commit is contained in:
matevip 2026-06-29 14:10:57 +08:00
parent 812daae0fc
commit 64b5587f56
8 changed files with 216 additions and 14 deletions

View File

@ -31,6 +31,16 @@ public class WikiKbConfig {
*/
private Long wikiDefaultModelId;
/**
* KB-level lightweight chat model for the cheap, high-volume steps
* (route / enrich / summary / entity extraction). When set, those steps
* run on this cheaper model instead of the KB/system default, cutting token
* spend without touching page generation quality. {@code null} falls back to
* the system-level light model, then to normal routing. Strong steps
* (create_page / merge_page) ignore this field.
*/
private Long wikiLightModelId;
/** Per-step model overrides: "heavy_ingest.create_page" → modelId */
private Map<String, Long> stepModels;

View File

@ -52,6 +52,9 @@ public final class WikiKbConfigParser {
} else if ("wikiDefaultModelId".equals(key)) {
Long parsed = parseLong(value);
if (parsed != null) config.setWikiDefaultModelId(parsed);
} else if ("wikiLightModelId".equals(key)) {
Long parsed = parseLong(value);
if (parsed != null) config.setWikiLightModelId(parsed);
} else if (key.startsWith("stepModels.")) {
Long parsed = parseLong(value);
if (parsed != null) {

View File

@ -9,12 +9,13 @@ import vip.mate.wiki.job.model.WikiProcessingJobEntity;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
/**
* RFC-030: Final fallback strategy uses the system default model.
* Cheap steps (ROUTE, ENRICH, SUMMARY) prefer a lighter/cheaper model;
* strong steps (CREATE_PAGE, MERGE_PAGE) use the default model.
* Final fallback strategy uses the system default model for every step.
* Cheap steps that want a lighter model are handled earlier by
* {@link WikiLightModelStrategy} (Order 2) when a light model is configured;
* this strategy is the last resort and keeps all steps on the system default.
*/
@Component
@Order(3)
@Order(4)
@RequiredArgsConstructor
public class GlobalDefaultStepModelStrategy implements WikiStepModelStrategy {
@ -25,9 +26,6 @@ public class GlobalDefaultStepModelStrategy implements WikiStepModelStrategy {
@Override
public Long selectModelId(WikiProcessingJobEntity job, WikiKnowledgeBaseEntity kb, WikiJobStep step) {
// RFC-030: cheap steps (ROUTE, ENRICH, SUMMARY) should ideally use a lighter model,
// but ModelConfigService has no "cheapest chat model" concept yet.
// When per-step pricing metadata is added, this switch can differentiate.
return modelConfigService.getDefaultModel().getId();
}
}

View File

@ -12,14 +12,14 @@ import vip.mate.wiki.job.model.WikiProcessingJobEntity;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
/**
* RFC-051 PR-1a: middle-priority strategy that resolves the KB-level default
* chat model ({@link WikiKbConfig#getWikiDefaultModelId()}). Sits between the
* per-step override ({@link KbConfigStepModelStrategy}, Order 1) and the
* system-wide default ({@link GlobalDefaultStepModelStrategy}, Order 3),
* yielding the chain prescribed by RFC-051 §10.1:
* RFC-051 PR-1a: resolves the KB-level default chat model
* ({@link WikiKbConfig#getWikiDefaultModelId()}). Sits below the per-step
* override ({@link KbConfigStepModelStrategy}, Order 1) and the cheap-step light
* model ({@link WikiLightModelStrategy}, Order 2), and above the system-wide
* default ({@link GlobalDefaultStepModelStrategy}, Order 4), yielding the chain:
*
* <pre>
* stepModels[step] -&gt; wikiDefaultModelId -&gt; system default
* stepModels[step] -&gt; (light model for cheap steps) -&gt; wikiDefaultModelId -&gt; system default
* </pre>
*
* The frontend has long written {@code wikiDefaultModelId} into the KB config
@ -27,7 +27,7 @@ import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
*/
@Slf4j
@Component
@Order(2)
@Order(3)
@RequiredArgsConstructor
public class KbDefaultModelStrategy implements WikiStepModelStrategy {

View File

@ -0,0 +1,92 @@
package vip.mate.wiki.job.strategy;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import vip.mate.system.service.SystemSettingService;
import vip.mate.wiki.job.WikiJobStep;
import vip.mate.wiki.job.WikiKbConfig;
import vip.mate.wiki.job.WikiKbConfigParser;
import vip.mate.wiki.job.model.WikiProcessingJobEntity;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
import java.util.EnumSet;
import java.util.Set;
/**
* Routes the cheap, high-volume wiki steps (route / enrich / summary / entity
* extraction) to a lightweight chat model so they don't bill at the premium
* page-generation model's rate.
*
* <p>The light model is resolved as: per-KB {@code wikiLightModelId}
* system-level {@code wiki.lightModelId} setting. When neither is configured the
* strategy returns {@code null} and routing falls through to the existing chain
* ({@code wikiDefaultModelId} system default), so behavior is unchanged until
* an admin opts in.
*
* <p>Order is between the explicit per-step override
* ({@link KbConfigStepModelStrategy}, Order 1) and the KB default
* ({@link KbDefaultModelStrategy}, Order 3): once a light model is configured it
* takes precedence over the KB default for the cheap steps, but a KB can still
* pin a specific model on any step via {@code stepModels.<step>}. Strong steps
* (create_page / merge_page) are not handled here.
*/
@Slf4j
@Component
@Order(2)
public class WikiLightModelStrategy implements WikiStepModelStrategy {
/** System-setting key for the global lightweight wiki model id. */
static final String SETTING_KEY = "wiki.lightModelId";
/** Cheap, high-volume steps eligible for the lightweight model. */
private static final Set<WikiJobStep> CHEAP_STEPS = EnumSet.of(
WikiJobStep.ROUTE, WikiJobStep.ENRICH, WikiJobStep.SUMMARY, WikiJobStep.ENTITY_EXTRACTION);
private final ObjectMapper objectMapper;
private final SystemSettingService systemSettingService;
public WikiLightModelStrategy(ObjectMapper objectMapper, SystemSettingService systemSettingService) {
this.objectMapper = objectMapper;
this.systemSettingService = systemSettingService;
}
@Override
public boolean supports(WikiJobStep step) {
return CHEAP_STEPS.contains(step);
}
@Override
public Long selectModelId(WikiProcessingJobEntity job, WikiKnowledgeBaseEntity kb, WikiJobStep step) {
if (!CHEAP_STEPS.contains(step)) {
return null;
}
Long perKb = perKbLightModel(kb);
if (perKb != null) {
return perKb;
}
return systemLightModel();
}
private Long perKbLightModel(WikiKnowledgeBaseEntity kb) {
if (kb == null || kb.getConfigContent() == null) {
return null;
}
WikiKbConfig config = WikiKbConfigParser.parse(objectMapper, kb.getConfigContent());
return config != null ? config.getWikiLightModelId() : null;
}
private Long systemLightModel() {
String raw = systemSettingService.getString(SETTING_KEY, null);
if (raw == null || raw.isBlank()) {
return null;
}
try {
return Long.parseLong(raw.trim());
} catch (NumberFormatException e) {
log.warn("[WikiLightModel] Invalid {} setting (not a model id): {}", SETTING_KEY, raw);
return null;
}
}
}

View File

@ -95,6 +95,17 @@ Eager ingest runs in two phases for an order-of-magnitude speedup:
**Resumable**: interrupted mid-import? Hit "Reprocess" and only the unfinished pages re-run; everything already produced stays put. Documents larger than the embedding model's context get mean-pool sub-segmented automatically.
#### Save tokens: a light model for the cheap steps
Digest runs several kinds of LLM step: route, merge generation, enrich, summary, entity extraction. The **route / enrich / summary / entity-extraction** steps are high-volume but lightweight — no need to run them on the same premium model as page merging.
Point them at a cheaper model to cut token spend without touching page-generation quality:
- **System-wide** — set `wiki.lightModelId` (a model id) in system settings; it applies to the cheap steps of every KB.
- **Per-KB override** — set `wikiLightModelId` in the KB config to override the system-wide value.
Leave both unset and nothing changes (the cheap steps keep using the KB / system default). Precedence: `stepModels.<step>` (pin a step) → light model (cheap steps only) → `wikiDefaultModelId` → system default.
### Lazy: index now, compile later
The pipeline collapses to four steps:

View File

@ -95,6 +95,17 @@ eager 模式分两阶段,速度提了一个数量级:
**可恢复**:中途断了?点"重新处理",只重跑未完成的页面,已生成的不动。超过模型上下文限制的文档,系统自动做 mean-pool 子段切分——你不用管。
#### 省 token给廉价步骤配轻量模型
消化会跑好几类 LLM 步骤:路由、合并生成、富化、摘要、实体抽取。其中 **路由 / 富化 / 摘要 / 实体抽取** 是高频但轻量的活,没必要和「合并生成」用同一个高价模型。
给它们指定一个便宜模型即可显著省 token页面生成质量不受影响
- **系统级** —— 系统设置里配 `wiki.lightModelId`(一个模型 id对所有知识库的廉价步骤生效
- **每库覆盖** —— 知识库配置里写 `wikiLightModelId`,覆盖系统级设置。
不配则一切照旧(廉价步骤仍走 KB 默认 / 系统默认模型)。优先级:`stepModels.<步骤>`(钉死某步)→ 轻量模型(仅廉价步骤)→ `wikiDefaultModelId` → 系统默认。
### Lazy 模式:先入索引,按需出页面
链路缩成四步:

View File

@ -0,0 +1,77 @@
package vip.mate.wiki.job.strategy;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import vip.mate.system.service.SystemSettingService;
import vip.mate.wiki.job.WikiJobStep;
import vip.mate.wiki.model.WikiKnowledgeBaseEntity;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class WikiLightModelStrategyTest {
private SystemSettingService settings;
private WikiLightModelStrategy strategy;
@BeforeEach
void setUp() {
settings = mock(SystemSettingService.class);
strategy = new WikiLightModelStrategy(new ObjectMapper(), settings);
}
private WikiKnowledgeBaseEntity kb(String configContent) {
WikiKnowledgeBaseEntity kb = new WikiKnowledgeBaseEntity();
kb.setConfigContent(configContent);
return kb;
}
@Test
@DisplayName("supports() only the cheap, high-volume steps")
void supportsOnlyCheapSteps() {
assertTrue(strategy.supports(WikiJobStep.ROUTE));
assertTrue(strategy.supports(WikiJobStep.ENRICH));
assertTrue(strategy.supports(WikiJobStep.SUMMARY));
assertTrue(strategy.supports(WikiJobStep.ENTITY_EXTRACTION));
assertFalse(strategy.supports(WikiJobStep.CREATE_PAGE));
assertFalse(strategy.supports(WikiJobStep.MERGE_PAGE));
}
@Test
@DisplayName("No light model configured anywhere → null (behavior unchanged)")
void noLightModelConfigured() {
when(settings.getString(WikiLightModelStrategy.SETTING_KEY, null)).thenReturn(null);
assertNull(strategy.selectModelId(null, null, WikiJobStep.ROUTE));
assertNull(strategy.selectModelId(null, kb("{}"), WikiJobStep.SUMMARY));
}
@Test
@DisplayName("System light model applies to cheap steps")
void systemLightModelApplies() {
when(settings.getString(WikiLightModelStrategy.SETTING_KEY, null)).thenReturn("777");
assertEquals(777L, strategy.selectModelId(null, null, WikiJobStep.ENRICH));
// Strong steps are never routed here even if asked directly.
assertNull(strategy.selectModelId(null, null, WikiJobStep.CREATE_PAGE));
}
@Test
@DisplayName("Per-KB light model overrides the system light model")
void perKbOverridesSystem() {
when(settings.getString(WikiLightModelStrategy.SETTING_KEY, null)).thenReturn("777");
Long picked = strategy.selectModelId(null, kb("{\"wikiLightModelId\": 555}"), WikiJobStep.SUMMARY);
assertEquals(555L, picked);
}
@Test
@DisplayName("Invalid system setting is ignored (null, no crash)")
void invalidSystemSetting() {
when(settings.getString(WikiLightModelStrategy.SETTING_KEY, null)).thenReturn("not-a-number");
assertNull(strategy.selectModelId(null, null, WikiJobStep.ROUTE));
}
}