Browse Source

feat: 召回维度多选 + 综合得分排序

- Tab2 召回维度: 单选 → 多选 (空选 = 全部); 合并 onSubmit 单/多分支统一走 allSettled
- URL ?configCode=A,B,C 多维度自动召回; 旧 __ALL__ 兼容映射成空选
- 新增综合得分: composite = c × (α × sim_norm + (1-α) × rov_norm)
  * 默认 α=0.6, c=1.2 (VIDEO_*), simThreshold=0.65, rovP5=0, rovP95=0.07
  * sim < 阈值的项剔除并展示提示条
  * rov 缺失视作 0
- 新列"综合得分"插在向量相似度前, hover 看分解, 点列头排序
- "综合排序"按钮 toggle desc / off, 与列头联动
- "排序参数"浮层: 全部参数可调, 按维度阈值覆盖, 重置默认, localStorage 持久化
刘立冬 1 day ago
parent
commit
05ec30f625

+ 4 - 3
src/api/configCodes.ts

@@ -97,7 +97,10 @@ export function getConfigDisplayLabel(
   return raw
 }
 
-/** Tab2 文本召回 dropdown: 按前缀分组, 顶部加"全部"快捷项 */
+/**
+ * Tab2 文本召回 dropdown: 按前缀分组
+ * 多选模式下"全部"用空选数组表达, 不再放哨兵项
+ */
 export function buildGroupedConfigOptions(codes: Record<string, string>) {
   const video: { label: string; value: string }[] = []
   const result: { label: string; value: string }[] = []
@@ -114,8 +117,6 @@ export function buildGroupedConfigOptions(codes: Record<string, string>) {
   type Item = { label: string; value: string }
   type Group = { label: string; options: Item[] }
   const items: (Item | Group)[] = []
-  // 顶部独立"全部"项 — 无 group
-  items.push({ label: '全部', value: ALL_CONFIG_CODE })
   if (video.length) items.push({ label: '视频解构维度', options: video })
   if (result.length) items.push({ label: '内容理解-旧', options: result })
   if (other.length) items.push({ label: '其他', options: other })

+ 219 - 0
src/components/RankingSettingsButton.tsx

@@ -0,0 +1,219 @@
+import { useMemo } from 'react'
+import {
+  Button,
+  Popover,
+  Slider,
+  InputNumber,
+  Space,
+  Typography,
+  Divider,
+  Collapse,
+  Tooltip,
+} from 'antd'
+import { SettingOutlined, QuestionCircleOutlined, ReloadOutlined } from '@ant-design/icons'
+import {
+  DEFAULT_RANKING_PARAMS,
+  type RankingParams,
+} from '../utils/scoring'
+import { getConfigDisplayLabel } from '../api/configCodes'
+
+const { Text } = Typography
+
+interface Props {
+  params: RankingParams
+  onChange: (next: RankingParams) => void
+  /** 当前结果集中出现过的 configCode 列表, 用于 "按维度阈值覆盖" 折叠面板 */
+  configCodesInResult: string[]
+  /** 后端字典 (configCode → 中文标签) */
+  configCodes: Record<string, string>
+}
+
+const FORMULA_TEXT = `综合得分 = c × (α × sim_norm + (1-α) × rov_norm)
+  sim_norm = clip((sim - simThreshold) / (1 - simThreshold), 0, 1)
+  rov_norm = clip((rov - rovP5) / (rovP95 - rovP5), 0, 1)
+  c = deconstructBoost (VIDEO_*) / 1.0 (其他维度)
+先按 simThreshold 硬筛 (sim < 阈值剔除), 再按综合分排序.`
+
+export default function RankingSettingsButton({
+  params,
+  onChange,
+  configCodesInResult,
+  configCodes,
+}: Props) {
+  const update = (patch: Partial<RankingParams>) => onChange({ ...params, ...patch })
+
+  const updateCodeThreshold = (code: string, value: number | null) => {
+    const next = { ...params.simThresholdsByCode }
+    if (value == null) delete next[code]
+    else next[code] = value
+    update({ simThresholdsByCode: next })
+  }
+
+  const codeRows = useMemo(
+    () =>
+      configCodesInResult.map((c) => ({
+        code: c,
+        label: getConfigDisplayLabel(c, configCodes),
+        override: params.simThresholdsByCode[c],
+      })),
+    [configCodesInResult, configCodes, params.simThresholdsByCode],
+  )
+
+  const content = (
+    <div style={{ width: 380 }}>
+      <Space direction="vertical" size={12} style={{ width: '100%' }}>
+        <Row label="相似度阈值 simThreshold" tip="低于此值的结果被剔除, 同时作为 sim_norm 的下界">
+          <InputNumber
+            min={0}
+            max={1}
+            step={0.01}
+            value={params.simThreshold}
+            onChange={(v) => update({ simThreshold: typeof v === 'number' ? v : 0 })}
+            style={{ width: 120 }}
+          />
+        </Row>
+
+        <Row label="α 相关性权重" tip="α 越大越看重相关性, 越小越看重 ROV 质量">
+          <div style={{ display: 'flex', alignItems: 'center', gap: 8, width: '100%' }}>
+            <Slider
+              min={0}
+              max={1}
+              step={0.05}
+              value={params.alpha}
+              onChange={(v) => update({ alpha: v })}
+              style={{ flex: 1 }}
+            />
+            <InputNumber
+              min={0}
+              max={1}
+              step={0.05}
+              value={params.alpha}
+              onChange={(v) => update({ alpha: typeof v === 'number' ? v : 0.6 })}
+              style={{ width: 80 }}
+            />
+          </div>
+        </Row>
+
+        <Row label="ROV 归一化下界 rovP5">
+          <InputNumber
+            min={0}
+            max={1}
+            step={0.001}
+            value={params.rovP5}
+            onChange={(v) => update({ rovP5: typeof v === 'number' ? v : 0 })}
+            style={{ width: 120 }}
+          />
+        </Row>
+
+        <Row label="ROV 归一化上界 rovP95">
+          <InputNumber
+            min={0}
+            max={1}
+            step={0.001}
+            value={params.rovP95}
+            onChange={(v) => update({ rovP95: typeof v === 'number' ? v : 0.07 })}
+            style={{ width: 120 }}
+          />
+        </Row>
+
+        <Row label="解构加权 c" tip="VIDEO_* 维度的额外加权系数, 其他维度恒为 1">
+          <InputNumber
+            min={0.5}
+            max={3}
+            step={0.05}
+            value={params.deconstructBoost}
+            onChange={(v) => update({ deconstructBoost: typeof v === 'number' ? v : 1.2 })}
+            style={{ width: 120 }}
+          />
+        </Row>
+
+        {codeRows.length > 0 && (
+          <Collapse
+            size="small"
+            ghost
+            items={[
+              {
+                key: 'override',
+                label: (
+                  <Text style={{ fontSize: 12 }}>
+                    按维度覆盖阈值 (
+                    {Object.keys(params.simThresholdsByCode).length} / {codeRows.length} 已设)
+                  </Text>
+                ),
+                children: (
+                  <Space direction="vertical" size={6} style={{ width: '100%' }}>
+                    {codeRows.map((r) => (
+                      <div
+                        key={r.code}
+                        style={{ display: 'flex', alignItems: 'center', gap: 8 }}
+                      >
+                        <Text style={{ flex: 1, fontSize: 12 }}>{r.label}</Text>
+                        <InputNumber
+                          size="small"
+                          min={0}
+                          max={1}
+                          step={0.01}
+                          placeholder={`默认 ${params.simThreshold}`}
+                          value={r.override}
+                          onChange={(v) =>
+                            updateCodeThreshold(r.code, typeof v === 'number' ? v : null)
+                          }
+                          style={{ width: 110 }}
+                        />
+                      </div>
+                    ))}
+                  </Space>
+                ),
+              },
+            ]}
+          />
+        )}
+
+        <Divider style={{ margin: '4px 0' }} />
+        <div style={{ display: 'flex', justifyContent: 'flex-end' }}>
+          <Button
+            size="small"
+            icon={<ReloadOutlined />}
+            onClick={() => onChange(DEFAULT_RANKING_PARAMS)}
+          >
+            重置默认
+          </Button>
+        </div>
+      </Space>
+    </div>
+  )
+
+  const title = (
+    <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
+      <span>排序参数</span>
+      <Tooltip
+        overlayStyle={{ maxWidth: 480 }}
+        title={<pre style={{ margin: 0, whiteSpace: 'pre-wrap', fontSize: 12 }}>{FORMULA_TEXT}</pre>}
+      >
+        <QuestionCircleOutlined style={{ color: 'rgba(0,0,0,0.45)' }} />
+      </Tooltip>
+    </div>
+  )
+
+  return (
+    <Popover trigger="click" placement="bottomRight" title={title} content={content}>
+      <Button icon={<SettingOutlined />}>排序参数</Button>
+    </Popover>
+  )
+}
+
+function Row({ label, tip, children }: { label: string; tip?: string; children: React.ReactNode }) {
+  return (
+    <div>
+      <div style={{ marginBottom: 4, fontSize: 12, display: 'flex', alignItems: 'center', gap: 4 }}>
+        <Text style={{ fontSize: 12 }}>{label}</Text>
+        {tip && (
+          <Tooltip title={tip}>
+            <QuestionCircleOutlined style={{ fontSize: 11, color: 'rgba(0,0,0,0.45)' }} />
+          </Tooltip>
+        )}
+      </div>
+      {children}
+    </div>
+  )
+}

+ 50 - 2
src/components/RecallResultList.tsx

@@ -1,7 +1,11 @@
 import { useMemo, useState } from 'react'
-import { Empty, Tabs } from 'antd'
+import { Button, Empty, Space, Tabs, Tooltip } from 'antd'
+import { SortDescendingOutlined } from '@ant-design/icons'
 import type { Modality, RecallResultVO } from '../api/types'
 import RecallResultTable from './RecallResultTable'
+import RankingSettingsButton from './RankingSettingsButton'
+import { useRankingParams } from '../utils/scoring'
+import { useConfigCodes } from '../api/configCodes'
 
 interface Props {
   result: RecallResultVO | null
@@ -18,9 +22,14 @@ const MODALITY_LABEL: Record<Modality | 'ALL', string> = {
 /**
  * 召回结果壳:模态切换 Tabs + 当前模态对应的表格
  * Tabs 复用现有 ALL/VIDEO/MATERIAL/ARTICLE 计数,内容用 RecallResultTable 渲染
+ * 右侧按钮: 综合排序 toggle + 排序参数 popover
  */
 export default function RecallResultList({ result, loading }: Props) {
   const [active, setActive] = useState<'ALL' | Modality>('VIDEO')
+  const [rankingParams, setRankingParams] = useRankingParams()
+  /** null = 后端原始顺序; 'desc'/'asc' = 按综合分排序 */
+  const [compositeSort, setCompositeSort] = useState<'descend' | 'ascend' | null>(null)
+  const configCodes = useConfigCodes()
 
   const filtered = useMemo(() => {
     if (!result) return []
@@ -28,6 +37,16 @@ export default function RecallResultList({ result, loading }: Props) {
     return result.items.filter((x) => x.modality === active)
   }, [result, active])
 
+  const codesInResult = useMemo(() => {
+    const set = new Set<string>()
+    if (result) {
+      result.items.forEach((it) => {
+        if (it.configCode) set.add(it.configCode)
+      })
+    }
+    return Array.from(set)
+  }, [result])
+
   if (!result) {
     return (
       <div style={{ padding: 32 }}>
@@ -49,6 +68,29 @@ export default function RecallResultList({ result, loading }: Props) {
     { key: 'ARTICLE', label: renderLabel(MODALITY_LABEL.ARTICLE, result.articleCount) },
   ]
 
+  const hasItems = result.total > 0
+  const compositeActive = compositeSort != null
+
+  const tabBarExtra = hasItems ? (
+    <Space size={8}>
+      <Tooltip title={compositeActive ? '已按综合分倒排, 点击恢复默认顺序' : '按综合得分倒排'}>
+        <Button
+          type={compositeActive ? 'primary' : 'default'}
+          icon={<SortDescendingOutlined />}
+          onClick={() => setCompositeSort(compositeActive ? null : 'descend')}
+        >
+          综合排序
+        </Button>
+      </Tooltip>
+      <RankingSettingsButton
+        params={rankingParams}
+        onChange={setRankingParams}
+        configCodesInResult={codesInResult}
+        configCodes={configCodes}
+      />
+    </Space>
+  ) : null
+
   return (
     <div>
       <Tabs
@@ -56,8 +98,14 @@ export default function RecallResultList({ result, loading }: Props) {
         activeKey={active}
         onChange={(k) => setActive(k as 'ALL' | Modality)}
         items={items}
+        tabBarExtraContent={tabBarExtra}
+      />
+      <RecallResultTable
+        items={filtered}
+        rankingParams={rankingParams}
+        compositeSort={compositeSort}
+        onCompositeSortChange={setCompositeSort}
       />
-      <RecallResultTable items={filtered} />
     </div>
   )
 }

+ 152 - 30
src/components/RecallResultTable.tsx

@@ -10,8 +10,12 @@ import {
   Button,
   Checkbox,
 } from 'antd'
-import { PlayCircleFilled, FilterFilled } from '@ant-design/icons'
-import type { ColumnsType } from 'antd/es/table'
+import {
+  PlayCircleFilled,
+  FilterFilled,
+  QuestionCircleOutlined,
+} from '@ant-design/icons'
+import type { ColumnsType, SorterResult } from 'antd/es/table/interface'
 import type { EssenceWord, VideoMatchEnrichedVO } from '../api/types'
 import { useConfigCodes, getConfigDisplayLabel } from '../api/configCodes'
 import { toHttps } from '../utils/url'
@@ -21,11 +25,15 @@ import {
   getScoreStyle,
   parseNum,
 } from '../utils/format'
+import {
+  computeCompositeScore,
+  type RankingParams,
+  type ScoreBreakdown,
+} from '../utils/scoring'
 
 const { Text, Paragraph } = Typography
 
 interface Filters {
-  score: number | null
   configCodes: string[]
   pv: number | null
   hl: number | null
@@ -33,13 +41,15 @@ interface Filters {
 }
 
 const EMPTY_FILTERS: Filters = {
-  score: null,
   configCodes: [],
   pv: null,
   hl: null,
   rov: null,
 }
 
+/** 派生:item + 综合得分分解, 渲染时统一用这个壳 */
+type RowItem = VideoMatchEnrichedVO & { _breakdown: ScoreBreakdown | null }
+
 const ACTIVE_ICON_COLOR = '#1677ff'
 const IDLE_ICON_COLOR = '#bfbfbf'
 
@@ -55,32 +65,53 @@ const METRIC_CELL_STYLE: React.CSSProperties = {
 
 interface Props {
   items: VideoMatchEnrichedVO[]
+  rankingParams: RankingParams
+  /** 受控的"综合得分"列 sort 状态 — 由外部按钮 + 列头联动 */
+  compositeSort: 'descend' | 'ascend' | null
+  onCompositeSortChange: (next: 'descend' | 'ascend' | null) => void
 }
 
 /**
- * 召回结果表格 — 14 列横向并列, 缺字段统一 "--", 长文案 ellipsis + hover tooltip
- * 列顺序按业务约定: 标题/封面/召回维度/相似度/4个解构/3个旧AI/3个量级
+ * 召回结果表格
+ * 列顺序: 标题/封面/召回维度/综合得分/向量相似度/解构/旧AI/指标
+ * 综合得分 = c × (α × sim_norm + (1-α) × rov_norm), 参数走 rankingParams (浮层可改)
+ * sim < simThreshold 的 item 直接剔除
  */
-export default function RecallResultTable({ items }: Props) {
+export default function RecallResultTable({
+  items,
+  rankingParams,
+  compositeSort,
+  onCompositeSortChange,
+}: Props) {
   const configCodes = useConfigCodes()
   const [filters, setFilters] = useState<Filters>(EMPTY_FILTERS)
 
+  /** 给每条 item 挂上综合得分分解 */
+  const rowItems: RowItem[] = useMemo(
+    () => items.map((it) => ({ ...it, _breakdown: computeCompositeScore(it, rankingParams) })),
+    [items, rankingParams],
+  )
+
+  /** 阈值剔除 — sim 缺失或 < simThreshold 的项不进入展示 */
+  const thresholdFiltered = useMemo(
+    () => rowItems.filter((it) => it._breakdown != null && it._breakdown.passesThreshold),
+    [rowItems],
+  )
+  const thresholdRejected = rowItems.length - thresholdFiltered.length
+
   const codeOptions = useMemo(() => {
     const set = new Set<string>()
-    items.forEach((it) => {
+    thresholdFiltered.forEach((it) => {
       if (it.configCode) set.add(it.configCode)
     })
     return Array.from(set).map((code) => ({
       label: getConfigDisplayLabel(code, configCodes),
       value: code,
     }))
-  }, [items, configCodes])
+  }, [thresholdFiltered, configCodes])
 
   const filteredItems = useMemo(() => {
-    return items.filter((it) => {
-      if (filters.score != null) {
-        if (it.score == null || it.score <= filters.score) return false
-      }
+    return thresholdFiltered.filter((it) => {
       if (filters.configCodes.length > 0) {
         if (!it.configCode || !filters.configCodes.includes(it.configCode)) return false
       }
@@ -98,10 +129,9 @@ export default function RecallResultTable({ items }: Props) {
       }
       return true
     })
-  }, [items, filters])
+  }, [thresholdFiltered, filters])
 
   const hasFilter =
-    filters.score != null ||
     filters.configCodes.length > 0 ||
     filters.pv != null ||
     filters.hl != null ||
@@ -112,7 +142,7 @@ export default function RecallResultTable({ items }: Props) {
   }
 
   const thresholdDropdown = (
-    field: 'score' | 'pv' | 'hl' | 'rov',
+    field: 'pv' | 'hl' | 'rov',
     placeholder: string,
     step?: number,
   ) =>
@@ -158,7 +188,7 @@ export default function RecallResultTable({ items }: Props) {
       )
     }
 
-  const columns: ColumnsType<VideoMatchEnrichedVO> = [
+  const columns: ColumnsType<RowItem> = [
     {
       title: '标题',
       key: 'title',
@@ -240,18 +270,38 @@ export default function RecallResultTable({ items }: Props) {
         )
       },
     },
+    {
+      title: (
+        <Tooltip
+          overlayStyle={{ maxWidth: 360 }}
+          title={
+            <div style={{ fontSize: 12 }}>
+              点击列头按综合得分倒排,公式见右上角"排序参数"。
+            </div>
+          }
+        >
+          <span>
+            综合得分{' '}
+            <QuestionCircleOutlined style={{ color: 'rgba(0,0,0,0.45)' }} />
+          </span>
+        </Tooltip>
+      ),
+      key: 'composite',
+      width: 130,
+      align: 'center',
+      fixed: 'left',
+      sorter: (a, b) =>
+        (a._breakdown?.composite ?? -Infinity) - (b._breakdown?.composite ?? -Infinity),
+      sortDirections: ['descend', 'ascend'],
+      sortOrder: compositeSort ?? null,
+      render: (_v, item) => <CompositeScoreCell breakdown={item._breakdown} />,
+    },
     {
       title: '向量相似度',
       key: 'score',
       width: 130,
       align: 'center',
       fixed: 'left',
-      filterIcon: () => (
-        <FilterFilled
-          style={{ color: filters.score != null ? ACTIVE_ICON_COLOR : IDLE_ICON_COLOR }}
-        />
-      ),
-      filterDropdown: thresholdDropdown('score', '相似度大于', 0.01),
       render: (_v, item) => <ScoreCell score={item.score} />,
     },
     deconstructTopicCol(280),
@@ -332,6 +382,22 @@ export default function RecallResultTable({ items }: Props) {
 
   return (
     <div>
+      {thresholdRejected > 0 && (
+        <div
+          style={{
+            marginBottom: 8,
+            padding: '6px 10px',
+            background: '#fff7e6',
+            border: '1px solid #ffd591',
+            borderRadius: 4,
+          }}
+        >
+          <Text style={{ fontSize: 12, color: '#d46b08' }}>
+            相似度阈值 ≥ {rankingParams.simThreshold} (剔除 <b>{thresholdRejected}</b> 条);
+            如需调整请点右上角"排序参数"
+          </Text>
+        </div>
+      )}
       {hasFilter && (
         <div
           style={{
@@ -346,7 +412,7 @@ export default function RecallResultTable({ items }: Props) {
           }}
         >
           <Text style={{ fontSize: 12 }}>
-            已应用筛选: 显示 <b>{filteredItems.length}</b> / {items.length}
+            已应用筛选: 显示 <b>{filteredItems.length}</b> / {thresholdFiltered.length}
           </Text>
           <ActiveFilterTags
             filters={filters}
@@ -360,14 +426,23 @@ export default function RecallResultTable({ items }: Props) {
           </Button>
         </div>
       )}
-      <Table<VideoMatchEnrichedVO>
+      <Table<RowItem>
         size="small"
         bordered
         rowKey={(r) => `${r.modality}-${r.id}-${r.configCode ?? ''}`}
         dataSource={filteredItems}
         columns={columns}
         pagination={false}
-        scroll={{ x: 2300 }}
+        scroll={{ x: 2430 }}
+        onChange={(_pagination, _filters, sorter) => {
+          const s = sorter as SorterResult<RowItem>
+          if (s && s.columnKey === 'composite') {
+            onCompositeSortChange((s.order as 'descend' | 'ascend' | undefined) ?? null)
+          } else {
+            // 点了别的列的 sorter (例如 ROV) → 取消综合排序
+            onCompositeSortChange(null)
+          }
+        }}
       />
     </div>
   )
@@ -383,7 +458,6 @@ function ActiveFilterTags({
   onClear: (field: keyof Filters) => void
 }) {
   const tags: { key: keyof Filters; text: string }[] = []
-  if (filters.score != null) tags.push({ key: 'score', text: `相似度>${filters.score}` })
   if (filters.configCodes.length > 0) {
     const labels = filters.configCodes.map((c) => getConfigDisplayLabel(c, configCodes)).join('/')
     tags.push({ key: 'configCodes', text: `维度: ${labels}` })
@@ -412,7 +486,7 @@ function textCol(
   key: string,
   width: number,
   wrap = false,
-): ColumnsType<VideoMatchEnrichedVO>[number] {
+): ColumnsType<RowItem>[number] {
   return {
     title,
     key,
@@ -443,7 +517,7 @@ function textCol(
 }
 
 /** 解构:选题 列 - 文本 ellipsis + tooltip,数据源 videoDetail.deconstruct.topic */
-function deconstructTopicCol(width: number): ColumnsType<VideoMatchEnrichedVO>[number] {
+function deconstructTopicCol(width: number): ColumnsType<RowItem>[number] {
   return {
     title: '解构:选题',
     key: 'deconstruct.topic',
@@ -471,7 +545,7 @@ function pointsCol(
   title: string,
   type: '灵感点' | '关键点' | '目的点',
   width: number,
-): ColumnsType<VideoMatchEnrichedVO>[number] {
+): ColumnsType<RowItem>[number] {
   const essenceKey = `${type}-实质` as const
   return {
     title,
@@ -641,3 +715,51 @@ function ScoreCell({ score }: { score: number | null }) {
     </span>
   )
 }
+
+/** 综合得分单元格 — hover Tooltip 展示 sim_norm/rov_norm/c 分解 */
+function CompositeScoreCell({ breakdown }: { breakdown: ScoreBreakdown | null }) {
+  if (!breakdown) {
+    return <Text type="secondary">--</Text>
+  }
+  const { composite, simNorm, rovNorm, boost } = breakdown
+  // 用与向量相似度同款配色 — 但映射区间不同, 综合得分 [0, c] 内, 走 0.6/0.45/0.3 三档
+  const styleScore =
+    composite >= 0.6
+      ? { bg: '#f6ffed', border: '#b7eb8f', text: '#389e0d' }
+      : composite >= 0.45
+        ? { bg: '#fcffe6', border: '#eaff8f', text: '#7cb305' }
+        : composite >= 0.3
+          ? { bg: '#fff7e6', border: '#ffd591', text: '#d46b08' }
+          : { bg: '#fff1f0', border: '#ffa39e', text: '#cf1322' }
+  const text = composite.toFixed(4)
+  const tip = (
+    <div style={{ fontSize: 12, lineHeight: 1.6 }}>
+      <div>sim_norm = {simNorm.toFixed(3)}</div>
+      <div>rov_norm = {rovNorm.toFixed(3)}</div>
+      <div>c = {boost.toFixed(2)}</div>
+      <div style={{ borderTop: '1px solid rgba(255,255,255,0.3)', marginTop: 4, paddingTop: 4 }}>
+        composite = {boost.toFixed(2)} × (α·sim_norm + (1-α)·rov_norm) = <b>{text}</b>
+      </div>
+    </div>
+  )
+  return (
+    <Tooltip title={tip} overlayStyle={{ maxWidth: 360 }}>
+      <span
+        style={{
+          display: 'inline-block',
+          padding: '2px 8px',
+          background: styleScore.bg,
+          border: `1px solid ${styleScore.border}`,
+          color: styleScore.text,
+          borderRadius: 4,
+          fontWeight: 600,
+          fontVariantNumeric: 'tabular-nums',
+          fontSize: 12,
+          cursor: 'help',
+        }}
+      >
+        {text}
+      </span>
+    </Tooltip>
+  )
+}

+ 77 - 72
src/pages/RecallTestPage.tsx

@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useRef, useState } from 'react'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
 import {
   Tabs,
   Card,
@@ -75,12 +75,18 @@ function readUrlQueryText(): string | null {
   return trimmed.length > 0 ? trimmed : null
 }
 
-/** 从 URL ?configCode=xxx 读取,后端字典动态加载,这里只做形态校验(非空+大写) */
-function readUrlConfigCode(): string | null {
+/**
+ * 从 URL ?configCode=xxx[,yyy,...] 读取多维度
+ * 兼容旧值 __ALL__ → 空数组 (= 多选下 "全部")
+ * 空数组语义化表达 "全部"
+ */
+function readUrlConfigCodes(): string[] {
   const raw = new URLSearchParams(window.location.search).get('configCode')
-  if (!raw) return null
-  const upper = raw.trim().toUpperCase()
-  return upper.length > 0 ? upper : null
+  if (!raw) return []
+  return raw
+    .split(',')
+    .map((s) => s.trim().toUpperCase())
+    .filter((s) => s.length > 0 && s !== ALL_CONFIG_CODE)
 }
 
 /**
@@ -627,13 +633,13 @@ function RecallTitle({
 // Tab2: 文本输入 → 文本召回
 // ============================================================================
 function TextRecallTab() {
-  /** URL ?queryText=xxx&configCode=yyy 落地参数,缺省走原有空白态 */
+  /** URL ?queryText=xxx&configCode=yyy[,zzz] 落地参数,缺省走原有空白态 */
   const urlQueryText = readUrlQueryText()
-  const urlConfigCode = readUrlConfigCode()
+  const urlConfigCodes = useMemo(readUrlConfigCodes, [])
 
   const [queryText, setQueryText] = useState(urlQueryText ?? '')
-  /** 默认 "全部"; URL 上有 configCode 时优先走 URL 指定 */
-  const [configCode, setConfigCode] = useState(urlConfigCode ?? ALL_CONFIG_CODE)
+  /** 多选: 空数组 = 全部维度 */
+  const [selectedCodes, setSelectedCodes] = useState<string[]>(urlConfigCodes)
   const [topN, setTopN] = useState<number>(10)
   const [result, setResult] = useState<RecallResultVO | null>(null)
   const [loading, setLoading] = useState(false)
@@ -659,81 +665,70 @@ function TextRecallTab() {
       message.warning('请输入查询文本')
       return
     }
+    // 空选 = 全部维度; 否则用户显式勾选的子集
+    const codes =
+      selectedCodes.length === 0 ? listAllConfigCodes(configCodes) : selectedCodes
+    if (codes.length === 0) {
+      message.warning('维度字典尚未加载完成, 请稍后再试')
+      return
+    }
+
     const myGen = ++submitGenRef.current
     const isStale = () => myGen !== submitGenRef.current
     setLoading(true)
     const preview = trimmed.length > 24 ? trimmed.slice(0, 24) + '…' : trimmed
 
-    if (configCode === ALL_CONFIG_CODE) {
-      // 并发调所有维度, allSettled 等齐再展示
-      const allCodes = listAllConfigCodes(configCodes)
-      if (allCodes.length === 0) {
-        message.warning('维度字典尚未加载完成, 请稍后再试')
-        setLoading(false)
-        return
-      }
-      setResultMeta({
-        dimensionLabel: `全部(${allCodes.length} 个维度)`,
-        dimensionCode: ALL_CONFIG_CODE,
-        description: `基于文本 "${preview}"`,
-      })
-      try {
-        const settled = await Promise.allSettled(
-          allCodes.map((code) => matchByText({ queryText: trimmed, configCode: code, topN })),
-        )
-        if (isStale()) return  // 已被新一轮 submit 抢占
-        const failedDims: string[] = []
-        const merged: RecallResultVO = {
-          items: [],
-          videoCount: 0,
-          materialCount: 0,
-          articleCount: 0,
-          total: 0,
-        }
-        settled.forEach((s, i) => {
-          if (s.status === 'fulfilled') {
-            merged.items.push(...s.value.items)
-          } else {
-            failedDims.push(getConfigDisplayLabel(allCodes[i], configCodes))
-          }
-        })
-        // 按 score 倒序; null/undefined 视为 -Infinity
-        merged.items.sort((a, b) => (b.score ?? -Infinity) - (a.score ?? -Infinity))
-        merged.videoCount = merged.items.filter((x) => x.modality === 'VIDEO').length
-        merged.materialCount = merged.items.filter((x) => x.modality === 'MATERIAL').length
-        merged.articleCount = merged.items.filter((x) => x.modality === 'ARTICLE').length
-        merged.total = merged.items.length
-        setResult(merged)
-        if (failedDims.length > 0) {
-          message.warning(`部分维度召回失败: ${failedDims.join(', ')} — 已展示其余维度结果`)
-        } else {
-          message.success(`全部维度召回完成, 共 ${merged.total} 条`)
-        }
-      } catch {
-        if (!isStale()) message.error('召回失败')
-      } finally {
-        if (!isStale()) setLoading(false)
-      }
-      return
+    let dimensionLabel: string
+    if (selectedCodes.length === 0) {
+      dimensionLabel = `全部 (${codes.length} 个维度)`
+    } else if (codes.length === 1) {
+      dimensionLabel = getConfigDisplayLabel(codes[0], configCodes)
+    } else {
+      dimensionLabel = `${codes.map((c) => getConfigDisplayLabel(c, configCodes)).join(' / ')} (${codes.length} 个维度)`
     }
-
-    // 单维度, 原有逻辑
     setResultMeta({
-      dimensionLabel: getConfigDisplayLabel(configCode, configCodes),
-      dimensionCode: configCode,
+      dimensionLabel,
+      dimensionCode: codes.length === 1 ? codes[0] : ALL_CONFIG_CODE,
       description: `基于文本 "${preview}"`,
     })
+
     try {
-      const data = await matchByText({ queryText: trimmed, configCode, topN })
+      const settled = await Promise.allSettled(
+        codes.map((code) => matchByText({ queryText: trimmed, configCode: code, topN })),
+      )
       if (isStale()) return
-      setResult(data)
-      message.success(`召回 ${data.total} 条`)
+      const failedDims: string[] = []
+      const merged: RecallResultVO = {
+        items: [],
+        videoCount: 0,
+        materialCount: 0,
+        articleCount: 0,
+        total: 0,
+      }
+      settled.forEach((s, i) => {
+        if (s.status === 'fulfilled') {
+          merged.items.push(...s.value.items)
+        } else {
+          failedDims.push(getConfigDisplayLabel(codes[i], configCodes))
+        }
+      })
+      merged.items.sort((a, b) => (b.score ?? -Infinity) - (a.score ?? -Infinity))
+      merged.videoCount = merged.items.filter((x) => x.modality === 'VIDEO').length
+      merged.materialCount = merged.items.filter((x) => x.modality === 'MATERIAL').length
+      merged.articleCount = merged.items.filter((x) => x.modality === 'ARTICLE').length
+      merged.total = merged.items.length
+      setResult(merged)
+      if (failedDims.length > 0) {
+        message.warning(`部分维度召回失败: ${failedDims.join(', ')} — 已展示其余维度结果`)
+      } else {
+        message.success(`召回完成, 共 ${merged.total} 条`)
+      }
     } catch {
       if (!isStale()) message.error('召回失败')
     } finally {
       if (!isStale()) setLoading(false)
     }
-  }, [queryText, configCode, topN, configCodes])
+  }, [queryText, selectedCodes, topN, configCodes])
 
   /**
    * URL 上有 queryText 时, 挂载后自动触发一次召回 (Grafana 跳转 0 点击)
@@ -744,10 +739,11 @@ function TextRecallTab() {
   useEffect(() => {
     if (autoSearchedRef.current) return
     if (!urlQueryText) return
-    if (!urlConfigCode && !configCodesReady) return
+    // URL 没显式带 configCode 时, 必须等真实字典加载完再触发, 否则只会跑 fallback 2 维度
+    if (urlConfigCodes.length === 0 && !configCodesReady) return
     autoSearchedRef.current = true
     onSubmit()
-  }, [urlQueryText, urlConfigCode, configCodesReady, onSubmit])
+  }, [urlQueryText, urlConfigCodes, configCodesReady, onSubmit])
 
   return (
     <Space direction="vertical" size={16} style={{ width: '100%' }}>
@@ -762,7 +758,16 @@ function TextRecallTab() {
           />
           <Space wrap>
             <Text strong>召回维度</Text>
-            <Select value={configCode} onChange={setConfigCode} options={groupedOptions} style={{ width: 240 }} />
+            <Select
+              mode="multiple"
+              value={selectedCodes}
+              onChange={setSelectedCodes}
+              options={groupedOptions}
+              placeholder="不选 = 全部维度"
+              maxTagCount="responsive"
+              style={{ minWidth: 320 }}
+              allowClear
+            />
             <Text strong>TopN</Text>
             <InputNumber min={1} max={20} value={topN} onChange={(v) => setTopN(v ?? 10)} style={{ width: 80 }} />
             <Button type="primary" icon={<SearchOutlined />} loading={loading} onClick={onSubmit}>

+ 117 - 0
src/utils/scoring.ts

@@ -0,0 +1,117 @@
+import { useEffect, useState } from 'react'
+import type { VideoMatchEnrichedVO } from '../api/types'
+import { parseNum } from './format'
+
+/**
+ * 召回结果综合排序参数
+ *
+ * 公式:
+ *   sim_norm = clip((sim - lower) / (1 - lower), 0, 1)   lower 默认 simThreshold, 可被 simThresholdsByCode 覆盖
+ *   rov_norm = clip((rov - rovP5) / (rovP95 - rovP5), 0, 1)
+ *   composite = c × (alpha × sim_norm + (1-alpha) × rov_norm)   c=deconstructBoost when configCode startsWith 'VIDEO_'
+ *
+ * simThreshold 同时是"硬筛阈值": sim < lower 的 item 直接剔除.
+ */
+export interface RankingParams {
+  simThreshold: number
+  simThresholdsByCode: Record<string, number>
+  rovP5: number
+  rovP95: number
+  alpha: number
+  deconstructBoost: number
+}
+
+export const DEFAULT_RANKING_PARAMS: RankingParams = {
+  simThreshold: 0.65,
+  simThresholdsByCode: {},
+  rovP5: 0,
+  rovP95: 0.07,
+  alpha: 0.6,
+  deconstructBoost: 1.2,
+}
+
+export interface ScoreBreakdown {
+  composite: number
+  simNorm: number
+  rovNorm: number
+  boost: number
+  lowerBound: number
+  passesThreshold: boolean
+}
+
+const clip01 = (x: number) => Math.max(0, Math.min(1, x))
+
+export function effectiveSimThreshold(
+  configCode: string | null | undefined,
+  params: RankingParams,
+): number {
+  if (configCode && configCode in params.simThresholdsByCode) {
+    return params.simThresholdsByCode[configCode]
+  }
+  return params.simThreshold
+}
+
+/**
+ * 计算单条召回结果的综合得分.
+ * sim 缺失返回 null (无法参与排序).
+ * sim 不达阈值时仍返回 breakdown, passesThreshold=false; 调用方决定是否剔除.
+ */
+export function computeCompositeScore(
+  item: VideoMatchEnrichedVO,
+  params: RankingParams,
+): ScoreBreakdown | null {
+  if (item.score == null || !Number.isFinite(item.score)) return null
+  const sim = item.score
+  const lowerBound = effectiveSimThreshold(item.configCode, params)
+  const denom = 1 - lowerBound
+  const simNorm = denom > 0 ? clip01((sim - lowerBound) / denom) : 0
+  // rov 缺失视为 0 (= 最低水位), 让"无数据"项在质量维度被惩罚但不至于直接被剔除
+  const rovRaw = parseNum(item.videoDetail?.rov) ?? 0
+  const rovDenom = params.rovP95 - params.rovP5
+  const rovNorm = rovDenom > 0 ? clip01((rovRaw - params.rovP5) / rovDenom) : 0
+  const boost =
+    item.configCode && item.configCode.startsWith('VIDEO_') ? params.deconstructBoost : 1
+  const composite = boost * (params.alpha * simNorm + (1 - params.alpha) * rovNorm)
+  return {
+    composite,
+    simNorm,
+    rovNorm,
+    boost,
+    lowerBound,
+    passesThreshold: sim >= lowerBound,
+  }
+}
+
+const STORAGE_KEY = 'vector_recall_ranking_params'
+
+function loadFromStorage(): RankingParams {
+  try {
+    const raw = localStorage.getItem(STORAGE_KEY)
+    if (!raw) return DEFAULT_RANKING_PARAMS
+    const parsed = JSON.parse(raw) as Partial<RankingParams>
+    return {
+      ...DEFAULT_RANKING_PARAMS,
+      ...parsed,
+      simThresholdsByCode: parsed.simThresholdsByCode ?? {},
+    }
+  } catch {
+    return DEFAULT_RANKING_PARAMS
+  }
+}
+
+function saveToStorage(p: RankingParams) {
+  try {
+    localStorage.setItem(STORAGE_KEY, JSON.stringify(p))
+  } catch {
+    // localStorage 失败时静默, 当次会话仍可用
+  }
+}
+
+/** localStorage 持久化的排序参数 hook */
+export function useRankingParams(): [RankingParams, (next: RankingParams) => void] {
+  const [params, setParams] = useState<RankingParams>(() => loadFromStorage())
+  useEffect(() => {
+    saveToStorage(params)
+  }, [params])
+  return [params, setParams]
+}