瀏覽代碼

fix: 降低小说向量检索噪音

Mochocyang 2 月之前
父節點
當前提交
372e142b7a

+ 72 - 0
src/lib/novel/community-summary.spec.ts

@@ -0,0 +1,72 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+import { searchByEmbedding } from "@/lib/embedding"
+import { useWikiStore } from "@/stores/wiki-store"
+import { searchCommunitySummaries } from "./community-summary"
+
+vi.mock("@/commands/fs", () => ({
+  readFile: vi.fn(),
+  writeFile: vi.fn(),
+  createDirectory: vi.fn(),
+}))
+vi.mock("@/lib/wiki-graph", () => ({ buildWikiGraph: vi.fn() }))
+vi.mock("@/lib/llm-client", () => ({
+  streamChat: vi.fn(),
+  DEFAULT_LLM_REQUEST_TIMEOUT_MS: 45000,
+}))
+vi.mock("@/lib/novel/model-resolver", () => ({ resolveNovelModel: vi.fn() }))
+vi.mock("@/lib/embedding", () => ({
+  embedPage: vi.fn(),
+  searchByEmbedding: vi.fn(),
+}))
+
+const mockSearchByEmbedding = vi.mocked(searchByEmbedding)
+
+describe("community summary vector noise control", () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+    useWikiStore.setState({
+      embeddingConfig: {
+        enabled: true,
+        endpoint: "http://localhost:11434/api/embeddings",
+        apiKey: "",
+        model: "nomic-embed-text",
+      },
+    })
+  })
+
+  it("returns only community summaries with a strong raw chunk match", async () => {
+    mockSearchByEmbedding.mockResolvedValue([
+      {
+        id: "community:1",
+        score: 0.99,
+        matchedChunks: [{ text: "Weak neighboring faction", headingPath: "Weak", score: 0.4 }],
+      },
+      {
+        id: "community:2",
+        score: 0.88,
+        matchedChunks: [{
+          text: "The northern faction controls the seal and opposes Lin.",
+          headingPath: "Northern faction",
+          score: 0.8,
+        }],
+      },
+    ])
+
+    const output = await searchCommunitySummaries("/project", "who controls the seal", 3)
+
+    expect(output).toContain("社区2")
+    expect(output).toContain("Northern faction")
+    expect(output).not.toContain("社区1")
+    expect(output).not.toContain("Weak neighboring faction")
+  })
+
+  it("returns empty context when every community match is weak", async () => {
+    mockSearchByEmbedding.mockResolvedValue([{
+      id: "community:1",
+      score: 0.95,
+      matchedChunks: [{ text: "Weak neighboring faction", headingPath: "Weak", score: 0.4 }],
+    }])
+
+    await expect(searchCommunitySummaries("/project", "unrelated chapter task", 3)).resolves.toBe("")
+  })
+})

+ 10 - 5
src/lib/novel/community-summary.ts

@@ -6,6 +6,10 @@ import { resolveNovelModel } from "@/lib/novel/model-resolver"
 import { embedPage, searchByEmbedding } from "@/lib/embedding"
 import { useWikiStore, type NovelConfig, type LlmConfig } from "@/stores/wiki-store"
 import { normalizePath } from "@/lib/path-utils"
+import {
+  buildNovelVectorSnippet,
+  selectRelevantNovelVectorResults,
+} from "./vector-relevance"
 
 /** 社区摘要持久化结构 */
 export interface CommunitySummaryRecord {
@@ -173,14 +177,15 @@ export async function searchCommunitySummaries(
   try {
     const results = await searchByEmbedding(pp, query, embCfg, topK * 3)
     // 只保留 community: 前缀的结果
-    const communityResults = results.filter(r => r.id.startsWith("community:"))
+    const communityResults = selectRelevantNovelVectorResults(
+      results.filter(r => r.id.startsWith("community:")),
+      topK,
+    )
     if (communityResults.length === 0) return ""
 
-    // 取 Top-K
-    const top = communityResults.slice(0, topK)
-    return top.map(r => {
+    return communityResults.map(r => {
       const communityId = r.id.replace("community:", "")
-      const snippet = r.matchedChunks?.[0]?.text?.slice(0, 400) ?? ""
+      const snippet = buildNovelVectorSnippet(r, 400)
       return `- 【社区摘要·社区${communityId}】: ${snippet}`
     }).join("\n")
   } catch {

+ 81 - 0
src/lib/novel/context-engine.retrieval.spec.ts

@@ -0,0 +1,81 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+import { searchWiki } from "@/lib/search"
+import { searchByEmbedding } from "@/lib/embedding"
+import { useWikiStore } from "@/stores/wiki-store"
+import { novelMixedSearch } from "./search-adapter"
+import { searchRelevantContentUnified } from "./context-engine"
+
+vi.mock("@/commands/fs", () => ({
+  readFile: vi.fn(),
+  listDirectory: vi.fn(async () => []),
+}))
+vi.mock("@/lib/search", () => ({
+  tokenizeQuery: (query: string) => query.toLowerCase().split(/\s+/).filter(Boolean),
+  searchWiki: vi.fn(),
+}))
+vi.mock("@/lib/embedding", () => ({ searchByEmbedding: vi.fn() }))
+vi.mock("@/lib/rerank", () => ({
+  rerankCandidates: vi.fn(async (_query, candidates, options) =>
+    candidates.slice(0, options?.topK ?? candidates.length)),
+}))
+vi.mock("./search-adapter", () => ({
+  novelMixedSearch: vi.fn(),
+  isHistoricalProjectionSnippet: vi.fn(() => false),
+  isAuthoritativeGenerationPath: vi.fn((path: string) => path.includes("/wiki/memory/")),
+}))
+
+const mockSearchWiki = vi.mocked(searchWiki)
+const mockSearchByEmbedding = vi.mocked(searchByEmbedding)
+const mockNovelMixedSearch = vi.mocked(novelMixedSearch)
+
+describe("searchRelevantContentUnified retrieval noise control", () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+    useWikiStore.setState({
+      embeddingConfig: {
+        enabled: true,
+        endpoint: "http://localhost:11434/api/embeddings",
+        apiKey: "",
+        model: "nomic-embed-text",
+      },
+    })
+  })
+
+  it("uses one vector branch and emits one result per normalized path", async () => {
+    mockNovelMixedSearch.mockResolvedValue([{
+      type: "vector",
+      path: "/project/wiki/memory/shared.md",
+      title: "Shared Memory",
+      snippet: "Matched semantic chunk",
+      relevance: 0.9,
+    }])
+    mockSearchWiki.mockResolvedValue([{
+      path: "/project/wiki/memory/shared.md",
+      title: "Shared Memory",
+      snippet: "Different index excerpt",
+      titleMatch: false,
+      score: 0.5,
+      images: [],
+    }])
+    mockSearchByEmbedding.mockResolvedValue([])
+
+    const output = await searchRelevantContentUnified(
+      "/project",
+      "continue the northern faction plot",
+      12,
+      5,
+    )
+
+    expect(output.match(/- Shared Memory:/g)).toHaveLength(1)
+    expect(mockSearchWiki).toHaveBeenCalledWith(
+      "/project",
+      expect.stringContaining("continue the northern faction plot"),
+      expect.objectContaining({
+        includeVector: false,
+        rerank: true,
+        topK: 5,
+      }),
+    )
+    expect(mockSearchByEmbedding).not.toHaveBeenCalled()
+  })
+})

+ 34 - 16
src/lib/novel/context-engine.ts

@@ -20,6 +20,10 @@ import {
 } from "./context-data-source"
 import { getAllDataSources, getDataSourcesForCategories } from "./context-data-sources"
 import type { DataSourceCategory } from "./classification"
+import {
+  buildNovelVectorSnippet,
+  selectRelevantNovelVectorResults,
+} from "./vector-relevance"
 
 const FIELD_PRIORITY: Record<string, number> = {
   sectionBriefing: 0,
@@ -827,7 +831,7 @@ export async function searchRelevantContentUnified(
   }
   const query = queryParts.join(" ")
 
-  const [semanticResults, indexResults, vectorResults] = await Promise.all([
+  const [semanticResults, indexResults] = await Promise.all([
     novelMixedSearch({
       projectPath: pp,
       query,
@@ -841,11 +845,11 @@ export async function searchRelevantContentUnified(
       includeCanon: true,
     }).catch(() => []),
     searchWiki(pp, `关键词索引 向量索引 ${task}`, {
+      includeVector: false,
       rerank: true,
       topK: Math.max(limit, 4),
       rerankPurpose: "用于补充剧情上下文中的索引和记忆条目。",
     }).catch(() => []),
-    runVectorSearchForContext(pp, query, limit).catch(() => []),
   ])
 
   const candidates = [
@@ -863,13 +867,6 @@ export async function searchRelevantContentUnified(
       snippet: result.snippet ?? "",
       source: "index",
     })),
-    ...vectorResults.map((result, index) => ({
-      id: `vector-context:${index}:${result.title}`,
-      path: result.path,
-      title: result.title,
-      snippet: result.snippet,
-      source: "vector_context",
-    })),
   ].filter((item) => {
     const path = typeof (item as { path?: unknown }).path === "string"
       ? (item as { path?: string }).path ?? ""
@@ -879,15 +876,25 @@ export async function searchRelevantContentUnified(
     return isAuthoritativeGenerationPath(path)
   })
 
-  const reranked = await rerankCandidates(query, candidates, {
+  const deduplicatedCandidates = candidates.filter((item, index, all) => {
+    const path = typeof item.path === "string" ? normalizePath(item.path) : ""
+    if (!path) return all.findIndex((candidate) => candidate.id === item.id) === index
+    return all.findIndex((candidate) => (
+      typeof candidate.path === "string" && normalizePath(candidate.path) === path
+    )) === index
+  })
+
+  const reranked = await rerankCandidates(query, deduplicatedCandidates, {
     topK: Math.max(limit * 2, limit),
     purpose: "用于构建小说写作上下文,优先保留最能支撑当前章节任务的记忆、设定、伏笔和正史约束。",
-  }).catch(() => candidates)
+  }).catch(() => deduplicatedCandidates)
 
   const merged: string[] = []
   const seen = new Set<string>()
   for (const result of reranked) {
-    const key = `${result.title}|${result.snippet.slice(0, 50)}`
+    const key = result.path
+      ? normalizePath(result.path)
+      : `${result.title}|${result.snippet.slice(0, 50)}`
     if (seen.has(key)) continue
     seen.add(key)
     merged.push(`- ${result.title}: ${result.snippet}`)
@@ -907,12 +914,13 @@ async function runVectorSearchForContext(
   try {
     const { searchByEmbedding } = await import("@/lib/embedding")
     const vectorResults = await searchByEmbedding(pp, query, embCfg, Math.max(limit * 2, 10))
-    if (vectorResults.length === 0) return []
+    const relevantResults = selectRelevantNovelVectorResults(vectorResults, limit)
+    if (relevantResults.length === 0) return []
 
     const items: { title: string; snippet: string; path: string }[] = []
     const dirs = ["entities", "concepts", "sources", "synthesis", "comparison", "queries"]
 
-    for (const vr of vectorResults.slice(0, limit)) {
+    for (const vr of relevantResults) {
       let found = false
       for (const dir of dirs) {
         const tryPath = `${pp}/wiki/${dir}/${vr.id}.md`
@@ -921,7 +929,12 @@ async function runVectorSearchForContext(
           const title = content.match(/^#\s+(.+)/m)?.[1]?.trim()
             ?? content.match(/^---\ntitle:\s*(.+)/m)?.[1]?.trim()
             ?? vr.id
-          items.push({ title, snippet: content.slice(0, 300).replace(/\n/g, " "), path: tryPath })
+          const matchedSnippet = buildNovelVectorSnippet(vr)
+          items.push({
+            title,
+            snippet: matchedSnippet || content.slice(0, 300).replace(/\n/g, " "),
+            path: tryPath,
+          })
           found = true
           break
         } catch {}
@@ -930,7 +943,12 @@ async function runVectorSearchForContext(
         const tryPath = `${pp}/wiki/${vr.id}.md`
         try {
           const content = await readFile(tryPath)
-          items.push({ title: vr.id, snippet: content.slice(0, 300).replace(/\n/g, " "), path: tryPath })
+          const matchedSnippet = buildNovelVectorSnippet(vr)
+          items.push({
+            title: vr.id,
+            snippet: matchedSnippet || content.slice(0, 300).replace(/\n/g, " "),
+            path: tryPath,
+          })
         } catch {}
       }
     }

+ 110 - 0
src/lib/novel/search-adapter.spec.ts

@@ -0,0 +1,110 @@
+import { beforeEach, describe, expect, it, vi } from "vitest"
+import { readFile } from "@/commands/fs"
+import { searchWiki } from "@/lib/search"
+import { searchByEmbedding } from "@/lib/embedding"
+import { useWikiStore } from "@/stores/wiki-store"
+import { novelMixedSearch } from "./search-adapter"
+
+vi.mock("@/commands/fs", () => ({ readFile: vi.fn() }))
+vi.mock("@/lib/search", () => ({ searchWiki: vi.fn() }))
+vi.mock("@/lib/embedding", () => ({ searchByEmbedding: vi.fn() }))
+vi.mock("@/lib/rerank", () => ({
+  rerankCandidates: vi.fn(async (_query, candidates, options) =>
+    candidates.slice(0, options?.topK ?? candidates.length)),
+}))
+vi.mock("./chapter-ingest", () => ({
+  listSnapshots: vi.fn(async () => []),
+  loadSnapshot: vi.fn(async () => null),
+}))
+
+const mockReadFile = vi.mocked(readFile)
+const mockSearchWiki = vi.mocked(searchWiki)
+const mockSearchByEmbedding = vi.mocked(searchByEmbedding)
+
+describe("novelMixedSearch vector noise control", () => {
+  beforeEach(() => {
+    vi.clearAllMocks()
+    useWikiStore.setState({
+      embeddingConfig: {
+        enabled: true,
+        endpoint: "http://localhost:11434/api/embeddings",
+        apiKey: "",
+        model: "nomic-embed-text",
+      },
+    })
+    mockSearchWiki.mockResolvedValue([])
+  })
+
+  it("keeps the keyword branch lexical-only", async () => {
+    mockSearchByEmbedding.mockResolvedValue([])
+
+    await novelMixedSearch({
+      projectPath: "/project",
+      query: "semantic memory",
+      topK: 2,
+      includeKeyword: true,
+      includeVector: true,
+      includeGraph: false,
+      includeRecentChapters: false,
+      includeCanon: false,
+    })
+
+    expect(mockSearchWiki).toHaveBeenCalledWith(
+      "/project",
+      "semantic memory",
+      { includeVector: false },
+    )
+  })
+
+  it("rejects a high blended page score when the best raw chunk score is weak", async () => {
+    mockSearchByEmbedding.mockResolvedValue([{
+      id: "noisy-hit",
+      score: 0.99,
+      matchedChunks: [{ text: "weak semantic neighbor", headingPath: "Noise", score: 0.4 }],
+    }])
+    mockReadFile.mockResolvedValue("# Noisy Hit\n\nUnrelated page text.")
+
+    const results = await novelMixedSearch({
+      projectPath: "/project",
+      query: "current chapter goal",
+      topK: 2,
+      includeKeyword: true,
+      includeVector: true,
+      includeGraph: false,
+      includeRecentChapters: false,
+      includeCanon: false,
+    })
+
+    expect(results).toEqual([])
+    expect(mockReadFile).not.toHaveBeenCalled()
+  })
+
+  it("uses the actual matched chunk as the vector result snippet", async () => {
+    mockSearchByEmbedding.mockResolvedValue([{
+      id: "seal-memory",
+      score: 0.92,
+      matchedChunks: [{
+        text: "Lin discovers that the northern faction owns the seal.",
+        headingPath: "Chapter 12 / Seal",
+        score: 0.82,
+      }],
+    }])
+    mockReadFile.mockResolvedValue("# Unrelated Page Introduction\n\nThe relevant detail appears much later.")
+
+    const results = await novelMixedSearch({
+      projectPath: "/project",
+      query: "who controls the seal",
+      topK: 2,
+      includeKeyword: true,
+      includeVector: true,
+      includeGraph: false,
+      includeRecentChapters: false,
+      includeCanon: false,
+    })
+
+    expect(results).toHaveLength(1)
+    expect(results[0].snippet).toContain("Chapter 12 / Seal")
+    expect(results[0].snippet).toContain("northern faction owns the seal")
+    expect(results[0].snippet).not.toContain("Unrelated Page Introduction")
+  })
+})

+ 12 - 4
src/lib/novel/search-adapter.ts

@@ -4,6 +4,10 @@ import { normalizePath } from "@/lib/path-utils"
 import { rerankCandidates } from "@/lib/rerank"
 import { useWikiStore } from "@/stores/wiki-store"
 import { loadSnapshot, listSnapshots } from "./chapter-ingest"
+import {
+  buildNovelVectorSnippet,
+  selectRelevantNovelVectorResults,
+} from "./vector-relevance"
 
 export interface NovelSearchParams {
   projectPath: string
@@ -67,7 +71,9 @@ export async function novelMixedSearch(params: NovelSearchParams): Promise<Novel
   const promises: Promise<void>[] = []
 
   if (params.includeKeyword !== false) {
-    const pKeyword = runSearchBranch("keyword", searchWiki(pp, params.query)).then(items => {
+    const pKeyword = runSearchBranch("keyword", searchWiki(pp, params.query, {
+      includeVector: false,
+    })).then(items => {
       console.log("[novelMixedSearch] keyword done, got", items.length)
       results.push(...items.slice(0, topK).map((item, sourceRank) => ({
         type: "keyword" as const,
@@ -191,10 +197,11 @@ async function runVectorSearch(
   try {
     const { searchByEmbedding } = await import("@/lib/embedding")
     const vectorResults = await searchByEmbedding(pp, query, embCfg, Math.max(topK * 2, 10))
-    if (vectorResults.length === 0) return []
+    const relevantResults = selectRelevantNovelVectorResults(vectorResults, topK)
+    if (relevantResults.length === 0) return []
 
     const items: NovelSearchResult[] = []
-    for (const vr of vectorResults.slice(0, topK)) {
+    for (const vr of relevantResults) {
       try {
         const dirs = ["entities", "concepts", "sources", "synthesis", "comparison", "queries"]
         let content = ""
@@ -216,11 +223,12 @@ async function runVectorSearch(
         }
         if (foundPath && content) {
           const title = extractTitle(content, vr.id)
+          const matchedSnippet = buildNovelVectorSnippet(vr)
           items.push({
             type: "vector",
             path: foundPath,
             title,
-            snippet: content.slice(0, 300).replace(/\n/g, " "),
+            snippet: matchedSnippet || content.slice(0, 300).replace(/\n/g, " "),
             relevance: vr.score,
           })
         }

+ 68 - 0
src/lib/novel/vector-relevance.spec.ts

@@ -0,0 +1,68 @@
+import { describe, expect, it } from "vitest"
+import type { PageSearchResult } from "@/lib/embedding"
+import {
+  NOVEL_VECTOR_MIN_MATCH_SCORE,
+  buildNovelVectorSnippet,
+  getNovelVectorMatchScore,
+  selectRelevantNovelVectorResults,
+} from "./vector-relevance"
+
+function result(overrides: Partial<PageSearchResult> = {}): PageSearchResult {
+  return {
+    id: "memory-page",
+    score: 0.95,
+    ...overrides,
+  }
+}
+
+describe("novel vector relevance", () => {
+  it("uses the best raw chunk score instead of the blended page score", () => {
+    const candidate = result({
+      matchedChunks: [
+        { text: "weak match", headingPath: "Memory", score: 0.4 },
+      ],
+    })
+
+    expect(getNovelVectorMatchScore(candidate)).toBe(0.4)
+    expect(selectRelevantNovelVectorResults([candidate], 5)).toEqual([])
+  })
+
+  it("keeps strong matches in their existing order and respects topK", () => {
+    const first = result({
+      id: "first",
+      matchedChunks: [{ text: "first", headingPath: "A", score: 0.8 }],
+    })
+    const second = result({
+      id: "second",
+      matchedChunks: [{ text: "second", headingPath: "B", score: 0.7 }],
+    })
+
+    expect(NOVEL_VECTOR_MIN_MATCH_SCORE).toBe(0.45)
+    expect(selectRelevantNovelVectorResults([first, second], 1)).toEqual([first])
+  })
+
+  it("falls back to the page score for legacy results without matched chunks", () => {
+    const strongLegacy = result({ id: "strong", score: 0.8 })
+    const weakLegacy = result({ id: "weak", score: 0.4 })
+
+    expect(getNovelVectorMatchScore(strongLegacy)).toBe(0.8)
+    expect(selectRelevantNovelVectorResults([strongLegacy, weakLegacy], 5)).toEqual([strongLegacy])
+  })
+
+  it("builds a bounded snippet from qualifying matched chunks", () => {
+    const candidate = result({
+      matchedChunks: [
+        { text: "  Lin   discovers the seal.  ", headingPath: "Chapter 12 / Seal", score: 0.82 },
+        { text: "The seal belongs to the northern faction.", headingPath: "Faction", score: 0.68 },
+        { text: "unrelated tail", headingPath: "Tail", score: 0.3 },
+      ],
+    })
+
+    const snippet = buildNovelVectorSnippet(candidate)
+
+    expect(snippet).toContain("Chapter 12 / Seal: Lin discovers the seal.")
+    expect(snippet).toContain("Faction: The seal belongs to the northern faction.")
+    expect(snippet).not.toContain("unrelated tail")
+    expect(snippet.length).toBeLessThanOrEqual(800)
+  })
+})

+ 38 - 0
src/lib/novel/vector-relevance.ts

@@ -0,0 +1,38 @@
+import type { PageSearchResult } from "@/lib/embedding"
+
+export const NOVEL_VECTOR_MIN_MATCH_SCORE = 0.45
+
+export function getNovelVectorMatchScore(result: PageSearchResult): number {
+  const chunkScores = result.matchedChunks?.map((chunk) => chunk.score) ?? []
+  return chunkScores.length > 0 ? Math.max(...chunkScores) : result.score
+}
+
+export function selectRelevantNovelVectorResults(
+  results: PageSearchResult[],
+  topK: number,
+): PageSearchResult[] {
+  if (topK <= 0) return []
+  return results
+    .filter((result) => getNovelVectorMatchScore(result) >= NOVEL_VECTOR_MIN_MATCH_SCORE)
+    .slice(0, topK)
+}
+
+export function buildNovelVectorSnippet(
+  result: PageSearchResult,
+  maxChars: number = 800,
+): string {
+  if (maxChars <= 0) return ""
+
+  const snippet = (result.matchedChunks ?? [])
+    .filter((chunk) => chunk.score >= NOVEL_VECTOR_MIN_MATCH_SCORE)
+    .slice(0, 2)
+    .map((chunk) => {
+      const text = chunk.text.replace(/\s+/g, " ").trim()
+      const heading = chunk.headingPath.replace(/\s+/g, " ").trim()
+      return heading ? `${heading}: ${text}` : text
+    })
+    .filter(Boolean)
+    .join("\n")
+
+  return snippet.slice(0, maxChars)
+}

+ 45 - 0
xiangliangzaoyinzhili-分支说明.md

@@ -0,0 +1,45 @@
+# 向量噪音治理分支说明
+
+## 分支用途
+
+- 分支名:`xiangliangzaoyinzhili`
+- 基线版本:`91bcaeb`
+- 目标:降低小说章节生成时的向量检索噪音,保留结构化记忆、TOKEN 检索、图谱和正史链路。
+
+## 使用要求
+
+1. 只处理小说写作上下文中的向量召回质量,不改变资料库搜索页的现有行为。
+2. 不删除已有函数,不删除已有向量数据。
+3. 不改变结构化记忆的生成、保存和读取协议。
+4. 先写失败测试,再写最小实现。
+5. 完成后运行源码、旧功能测试、相关测试、typecheck、build 和打包。
+
+## 预计改动
+
+- 新增小说向量相关性门控辅助模块。
+- 小说向量召回使用真正命中的 chunk,而不是默认页面开头。
+- 移除小说混合检索内部重复的向量请求。
+- 按路径合并跨来源结果。
+- 社区摘要只注入达到相关性门槛的命中。
+
+## 更新记录
+
+### 20260718-124614
+
+- 创建独立分支和隔离工作树。
+- 基线 `npm run test:mocks` 通过:345 个测试文件,2496 个用例通过,6 个既有 todo。
+- 已完成设计确认,尚未开始代码实现。
+- Git 状态:未提交。
+
+### 20260718-131731
+
+- 新增小说向量相关性门控,最低原始命中 chunk 分数为 `0.45`。
+- 小说向量结果使用真正命中片段,低分结果不进入写作上下文。
+- TOKEN 与向量分支已分离,统一上下文移除重复向量调用并按路径去重。
+- 社区摘要已接入相关性门控。
+- TDD 聚焦测试:5 个文件、21 项通过。
+- 完整离线回归:349 个文件、2506 项通过、6 项既有 todo。
+- `typecheck`、`build`、源码 HTTP 健康检查和 `build:portable` 通过。
+- 便携产物:`release-portable/QMaiWrite.exe`,版本 `2.2.37`,149139456 字节。
+- 已更新本地 `GenxinLOG/更新日志.md`。
+- Git 状态:已经用户明确要求提交并合并至 `main`;未推送。