diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts
index b4d1ce2c..c3f34ba7 100755
--- a/frontend/src/i18n/locales/en-US.ts
+++ b/frontend/src/i18n/locales/en-US.ts
@@ -2760,6 +2760,8 @@ export default {
embeddingDesc: 'Embedding model used for text vectorization',
embeddingPlaceholder: 'Select an embedding model',
embeddingLocked: 'Knowledge base already has files. Embedding model cannot be modified',
+ embeddingOptional: '(Optional)',
+ embeddingWikiOptionalDesc: 'Optional. When set, it powers similarity matching for Wiki directory classification so new pages reuse existing folders better; otherwise the full directory tree is used.',
rerankLabel: 'ReRank Model',
rerankDesc: 'Model for re-ranking search results (optional)',
rerankPlaceholder: 'Select a ReRank model (optional)'
diff --git a/frontend/src/i18n/locales/ko-KR.ts b/frontend/src/i18n/locales/ko-KR.ts
index 15cd55c3..1e1ea8d9 100755
--- a/frontend/src/i18n/locales/ko-KR.ts
+++ b/frontend/src/i18n/locales/ko-KR.ts
@@ -3631,6 +3631,8 @@ export default {
embeddingDesc: "텍스트 벡터화를 위한 임베딩 모델",
embeddingPlaceholder: "Embedding 모델을 선택해주세요",
embeddingLocked: "지식베이스에 이미 파일이 있어 Embedding 모델을 변경할 수 없습니다",
+ embeddingOptional: "(선택)",
+ embeddingWikiOptionalDesc: "선택 사항입니다. 설정하면 Wiki 디렉터리 분류의 유사도 매칭에 사용되어 새 페이지가 기존 폴더를 더 잘 재사용합니다. 설정하지 않으면 전체 디렉터리 트리를 기준으로 분류합니다.",
rerankLabel: "ReRank 재정렬 모델",
rerankDesc: "검색 결과 재정렬을 위한 모델 (선택)",
rerankPlaceholder: "ReRank 모델을 선택해주세요 (선택)",
diff --git a/frontend/src/i18n/locales/ru-RU.ts b/frontend/src/i18n/locales/ru-RU.ts
index 300ae3d6..5ad93d41 100755
--- a/frontend/src/i18n/locales/ru-RU.ts
+++ b/frontend/src/i18n/locales/ru-RU.ts
@@ -3117,6 +3117,8 @@ export default {
embeddingDesc: 'Модель встраивания для векторизации текста',
embeddingPlaceholder: 'Выберите модель встраивания',
embeddingLocked: 'В базе знаний уже есть файлы. Модель встраивания нельзя изменить',
+ embeddingOptional: '(необязательно)',
+ embeddingWikiOptionalDesc: 'Необязательно. Если задано, используется для сопоставления по сходству при классификации каталога Wiki, чтобы новые страницы лучше повторно использовали существующие папки; иначе классификация выполняется по полному дереву каталога.',
rerankLabel: 'Модель ReRank',
rerankDesc: 'Модель для переранжирования результатов поиска (необязательно)',
rerankPlaceholder: 'Выберите модель ReRank (необязательно)'
diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts
index cfd1caf1..67c3f0ee 100755
--- a/frontend/src/i18n/locales/zh-CN.ts
+++ b/frontend/src/i18n/locales/zh-CN.ts
@@ -3624,6 +3624,8 @@ export default {
embeddingDesc: "用于文本向量化的嵌入模型",
embeddingPlaceholder: "请选择 Embedding 模型",
embeddingLocked: "知识库中已有文件,无法修改 Embedding 模型",
+ embeddingOptional: "(可选)",
+ embeddingWikiOptionalDesc: "可选。配置后用于 Wiki 目录归类的相似度匹配,让新页面更好地复用已有目录;不配置则按完整目录树归类。",
rerankLabel: "ReRank 重排序模型",
rerankDesc: "用于搜索结果重排序的模型(可选)",
rerankPlaceholder: "请选择 ReRank 模型(可选)",
diff --git a/frontend/src/views/knowledge/settings/KBModelConfig.vue b/frontend/src/views/knowledge/settings/KBModelConfig.vue
index 4207da04..1e6a440b 100644
--- a/frontend/src/views/knowledge/settings/KBModelConfig.vue
+++ b/frontend/src/views/knowledge/settings/KBModelConfig.vue
@@ -25,13 +25,21 @@
-
-
{
color: var(--td-error-color);
margin-left: 2px;
}
+
+ .optional {
+ color: var(--td-text-color-placeholder);
+ font-size: 12px;
+ font-weight: 400;
+ margin-left: 4px;
+ }
}
.desc {
diff --git a/internal/agent/prompts_wiki.go b/internal/agent/prompts_wiki.go
index 6fa0c05d..c375240f 100644
--- a/internal/agent/prompts_wiki.go
+++ b/internal/agent/prompts_wiki.go
@@ -7,7 +7,7 @@ package agent
// batch, when the KB still has no folders to anchor on). The result is applied
// in reduce only to pages that don't already have a category, so user edits and
// previously-filed pages are never churned.
-const WikiTaxonomyPlanPrompt = `You are organizing a wiki knowledge base into a navigation directory. Assign each item below to a directory path (category) so the whole set lands on ONE coherent tree that reuses existing folders.
+const WikiTaxonomyPlanPrompt = `You are organizing a wiki knowledge base into a navigation directory. Assign each item below to a directory path (category) so the whole set lands on ONE coherent tree.
{{.ExistingTaxonomy}}
@@ -20,12 +20,16 @@ const WikiTaxonomyPlanPrompt = `You are organizing a wiki knowledge base into a
For every item, output a category path: an array of folder labels from broad to narrow (at most 2 levels). The category classifies WHAT the item fundamentally IS (the stable library "shelf" it always sits on), never the role it plays in one document.
-Rules:
-- Reuse the EXACT folder labels listed in (character-for-character) whenever an item belongs under the same domain. Do NOT invent synonym folders (e.g. do NOT create "春节习俗" when "春节 / 传统习俗" already fits).
+How to choose a path for each item:
+1. If an existing folder in fits, REUSE its EXACT label (character-for-character). Do NOT invent a synonym folder (e.g. do NOT create "春节习俗" when "春节 / 传统习俗" already fits).
+2. If NO existing folder fits, CREATE a new, broad, durable folder for it (e.g. an organization → "组织", a legal idea → "法律概念", a place → "地点"). The directory does not have to stay small — most items DO have a natural home, so coin a sensible top-level folder rather than leaving them unfiled. Group items of the SAME kind under the SAME new folder so the tree stays coherent.
+3. Only give an empty path [] when an item genuinely belongs to NO durable subject at all. This must be RARE. The absence of a matching existing folder is NOT a reason for []; create a folder instead.
+
+Other rules:
- Group items of the SAME kind under the SAME folder at the SAME depth. Do not file one equivalent item a level deeper than its siblings (e.g. avoid "地点 / 地址 / Address1" next to "地点 / Address2" — pick one consistent depth for equivalent items).
- Prefer a single broad top-level folder; add a second level only for a genuinely durable sub-domain shared by several items.
- Do NOT use the item type ("entity"/"concept") as a folder. Do NOT put slashes inside a single label.
-- Every item slug in MUST appear exactly once in the output. If an item truly cannot be classified under any durable folder, give it an empty path [].
+- Every item slug in MUST appear exactly once in the output.
- Write ALL folder labels in {{.Language}}.
### JSON Formatting Rules
diff --git a/internal/application/service/wiki_ingest.go b/internal/application/service/wiki_ingest.go
index afb340e2..591d291b 100644
--- a/internal/application/service/wiki_ingest.go
+++ b/internal/application/service/wiki_ingest.go
@@ -1536,50 +1536,6 @@ func splitSummaryLine(raw string) (summary string, content string) {
return "", raw
}
-// splitCategoryLine extracts a leading "CATEGORY: a / b" line from content
-// (the page body AFTER the SUMMARY line has been stripped) and returns the
-// parsed folder labels plus the remaining content with that line removed.
-//
-// Category assignment lives in the page-modify output (reduce time) rather
-// than per-document extraction so it reflects WHAT the whole entity IS, not
-// the role it played in one source document. An empty/absent CATEGORY line
-// yields nil so callers keep the page's existing category instead of wiping it.
-func splitCategoryLine(content string) (category []string, rest string) {
- trimmed := strings.TrimLeft(content, " \t\r\n")
- if !strings.HasPrefix(trimmed, "CATEGORY:") && !strings.HasPrefix(trimmed, "CATEGORY:") {
- return nil, content
- }
- var line, remainder string
- if idx := strings.IndexByte(trimmed, '\n'); idx < 0 {
- line = trimmed
- } else {
- line = trimmed[:idx]
- remainder = trimmed[idx+1:]
- }
- line = strings.TrimPrefix(line, "CATEGORY:")
- line = strings.TrimPrefix(line, "CATEGORY:")
- return parseCategoryPath(line), strings.TrimSpace(remainder)
-}
-
-// parseCategoryPath splits a "a / b" breadcrumb string into clean folder
-// labels. Separator variants and wrapping quotes/brackets are normalized; the
-// deeper structural cleaning (type-label stripping, depth cap, wiki_path) is
-// left to normalizeWikiHierarchy on the page.
-func parseCategoryPath(line string) []string {
- line = strings.NewReplacer("/", "/", "|", "/", "|", "/", "›", "/", "»", "/", ">", "/").Replace(line)
- parts := strings.Split(line, "/")
- out := make([]string, 0, len(parts))
- for _, p := range parts {
- label := strings.TrimSpace(p)
- label = strings.Trim(label, `"'“”‘’[]()()`)
- label = strings.TrimSpace(label)
- if label != "" {
- out = append(out, label)
- }
- }
- return out
-}
-
// buildLogEntry builds a WikiLogEntry struct for the current batch. It is
// pure (no DB access) so callers can accumulate entries cheaply under their
// lock and flush them in a single AppendBatch call at the end of the batch.
diff --git a/internal/application/service/wiki_ingest_batch.go b/internal/application/service/wiki_ingest_batch.go
index 74454d82..4be3ebdb 100644
--- a/internal/application/service/wiki_ingest_batch.go
+++ b/internal/application/service/wiki_ingest_batch.go
@@ -1610,11 +1610,6 @@ func (s *wikiIngestService) reduceSlugUpdates(
if err == nil && updatedContent != "" {
updatedSummary, updatedBody := splitSummaryLine(updatedContent)
- // Category now comes from the batch taxonomy plan, not this prompt.
- // Defensively strip any stray leading CATEGORY line the model may
- // still emit so it never leaks into the page body; discard the value.
- _, bodyAfterCategory := splitCategoryLine(updatedBody)
- updatedBody = bodyAfterCategory
if updatedBody != "" {
page.Content = updatedBody
} else {
diff --git a/internal/application/service/wiki_ingest_taxonomy.go b/internal/application/service/wiki_ingest_taxonomy.go
index edb34745..4b3c19b9 100644
--- a/internal/application/service/wiki_ingest_taxonomy.go
+++ b/internal/application/service/wiki_ingest_taxonomy.go
@@ -132,7 +132,10 @@ func (s *wikiIngestService) selectRelevantFolders(
}
}
- if !kb.NeedsEmbeddingModel() || strings.TrimSpace(kb.EmbeddingModelID) == "" || len(deeper) == 0 {
+ // Gate purely on whether an embedding model is configured — NOT on
+ // NeedsEmbeddingModel(), which is false for wiki-only KBs that may still
+ // opt into an embedding model purely for directory/taxonomy similarity.
+ if strings.TrimSpace(kb.EmbeddingModelID) == "" || len(deeper) == 0 {
return capFolders(pool, wikiTaxonomyPromptMaxPaths)
}
diff --git a/internal/application/service/wiki_ingest_taxonomy_test.go b/internal/application/service/wiki_ingest_taxonomy_test.go
index 85440cdb..21465dc3 100644
--- a/internal/application/service/wiki_ingest_taxonomy_test.go
+++ b/internal/application/service/wiki_ingest_taxonomy_test.go
@@ -7,58 +7,6 @@ import (
"github.com/Tencent/WeKnora/internal/types"
)
-func TestSplitCategoryLine(t *testing.T) {
- tests := []struct {
- name string
- content string
- wantCat []string
- wantRest string
- }{
- {
- name: "two level category",
- content: "CATEGORY: 组织 / 企业\n# 标题\n正文",
- wantCat: []string{"组织", "企业"},
- wantRest: "# 标题\n正文",
- },
- {
- name: "single level category",
- content: "CATEGORY: 人物\n# 陈洋",
- wantCat: []string{"人物"},
- wantRest: "# 陈洋",
- },
- {
- name: "empty category keeps existing (nil) and strips line",
- content: "CATEGORY:\n# 标题",
- wantCat: nil,
- wantRest: "# 标题",
- },
- {
- name: "no category line left untouched",
- content: "# 标题\n正文",
- wantCat: nil,
- wantRest: "# 标题\n正文",
- },
- {
- name: "fullwidth colon and separators",
- content: "CATEGORY:组织|企业\n正文",
- wantCat: []string{"组织", "企业"},
- wantRest: "正文",
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- cat, rest := splitCategoryLine(tt.content)
- if strings.Join(cat, "/") != strings.Join(tt.wantCat, "/") {
- t.Fatalf("splitCategoryLine() cat = %v, want %v", cat, tt.wantCat)
- }
- if rest != tt.wantRest {
- t.Fatalf("splitCategoryLine() rest = %q, want %q", rest, tt.wantRest)
- }
- })
- }
-}
-
func TestFormatExistingTaxonomyForPrompt(t *testing.T) {
got := formatExistingTaxonomyForPrompt([][]string{
{"春节", "传统习俗"},