feat(appendix): 添加多个交互式演示组件,完善 AI/Infra 等章节内容
- 新增 Vibe Coding 全栈相关演示组件 (DeveloperSkillShift, FrontendTriad, BackendCore 等) - 新增 RAG 相关组件 (RAGPipeline, ChunkingStrategy, Retrieval 等) - 新增 Embedding & Vector 相关组件 (EmbeddingConcept, VectorSimilarity 等) - 新增 AI Native App 设计组件 (AINativeArch, PromptDesign 等) - 新增 Infrastructure as Code 组件 (IaCConcept, TerraformWorkflow 等) - 新增 DNS & HTTPS 演示组件 (DnsResolution, HttpsHandshake 等) - 新增 Model Finetuning 组件 (FinetuningPipeline 等) - 更新多个章节的 markdown 内容,集成交互式演示
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
<!--
|
||||
EmbeddingConceptDemo.vue
|
||||
嵌入概念可视化组件
|
||||
|
||||
用途:
|
||||
将词语/句子嵌入可视化为二维空间中的点,展示语义相似的概念如何聚集在一起。
|
||||
|
||||
交互功能:
|
||||
- 切换不同词组类别查看聚类效果
|
||||
- 悬停查看词语详情和坐标
|
||||
- 动态展示语义空间的分布
|
||||
-->
|
||||
<template>
|
||||
<div class="embedding-demo">
|
||||
<div class="demo-header">
|
||||
<h4>词嵌入空间可视化</h4>
|
||||
<p class="desc">语义相近的词语在向量空间中距离更近,形成自然聚类</p>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button
|
||||
v-for="cat in categories"
|
||||
:key="cat.key"
|
||||
class="cat-btn"
|
||||
:class="{ active: activeCategory === cat.key }"
|
||||
@click="activeCategory = cat.key"
|
||||
>
|
||||
{{ cat.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="canvas-wrap">
|
||||
<svg
|
||||
ref="svgRef"
|
||||
viewBox="0 0 500 400"
|
||||
class="embed-svg"
|
||||
>
|
||||
<!-- 坐标轴 -->
|
||||
<line x1="50" y1="370" x2="480" y2="370" stroke="var(--vp-c-divider)" stroke-width="1" />
|
||||
<line x1="50" y1="370" x2="50" y2="20" stroke="var(--vp-c-divider)" stroke-width="1" />
|
||||
<text x="265" y="395" text-anchor="middle" fill="var(--vp-c-text-3)" font-size="12">维度 1</text>
|
||||
<text x="15" y="195" text-anchor="middle" fill="var(--vp-c-text-3)" font-size="12" transform="rotate(-90, 15, 195)">维度 2</text>
|
||||
|
||||
<!-- 聚类椭圆 -->
|
||||
<ellipse
|
||||
v-for="cluster in currentClusters"
|
||||
:key="cluster.label"
|
||||
:cx="cluster.cx"
|
||||
:cy="cluster.cy"
|
||||
:rx="cluster.rx"
|
||||
:ry="cluster.ry"
|
||||
:fill="cluster.color"
|
||||
fill-opacity="0.08"
|
||||
:stroke="cluster.color"
|
||||
stroke-opacity="0.3"
|
||||
stroke-width="1.5"
|
||||
stroke-dasharray="4 3"
|
||||
/>
|
||||
|
||||
<!-- 数据点 -->
|
||||
<g
|
||||
v-for="(point, idx) in currentPoints"
|
||||
:key="point.word"
|
||||
class="point-group"
|
||||
@mouseenter="hoveredPoint = idx"
|
||||
@mouseleave="hoveredPoint = -1"
|
||||
>
|
||||
<circle
|
||||
:cx="point.x"
|
||||
:cy="point.y"
|
||||
:r="hoveredPoint === idx ? 8 : 6"
|
||||
:fill="point.color"
|
||||
stroke="#fff"
|
||||
stroke-width="1.5"
|
||||
class="data-point"
|
||||
/>
|
||||
<text
|
||||
:x="point.x"
|
||||
:y="point.y - 12"
|
||||
text-anchor="middle"
|
||||
:fill="point.color"
|
||||
font-size="12"
|
||||
font-weight="600"
|
||||
>
|
||||
{{ point.word }}
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<!-- 聚类标签 -->
|
||||
<text
|
||||
v-for="cluster in currentClusters"
|
||||
:key="'label-' + cluster.label"
|
||||
:x="cluster.cx"
|
||||
:y="cluster.cy + cluster.ry + 16"
|
||||
text-anchor="middle"
|
||||
:fill="cluster.color"
|
||||
font-size="11"
|
||||
font-weight="500"
|
||||
opacity="0.7"
|
||||
>
|
||||
{{ cluster.label }}
|
||||
</text>
|
||||
</svg>
|
||||
|
||||
<!-- 悬停信息 -->
|
||||
<div v-if="hoveredPoint >= 0" class="hover-info">
|
||||
<span class="hw">{{ currentPoints[hoveredPoint].word }}</span>
|
||||
<span class="hc">向量: [{{ currentPoints[hoveredPoint].vec.join(', ') }}]</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<p>
|
||||
<span class="icon">💡</span>
|
||||
嵌入模型将文本映射到高维向量空间(通常 768~1536 维)。这里我们将其简化为二维来展示核心思想:<strong>语义相近的词语,向量距离也更近</strong>。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const activeCategory = ref('animals-royalty')
|
||||
const hoveredPoint = ref(-1)
|
||||
|
||||
const categories = [
|
||||
{ key: 'animals-royalty', label: '动物 vs 皇室' },
|
||||
{ key: 'food-tech', label: '食物 vs 科技' },
|
||||
{ key: 'emotions', label: '情感词汇' }
|
||||
]
|
||||
|
||||
const dataMap = {
|
||||
'animals-royalty': {
|
||||
clusters: [
|
||||
{ label: '动物', cx: 150, cy: 160, rx: 80, ry: 65, color: '#10b981' },
|
||||
{ label: '皇室', cx: 370, cy: 200, rx: 75, ry: 60, color: '#8b5cf6' }
|
||||
],
|
||||
points: [
|
||||
{ word: '猫', x: 120, y: 140, color: '#10b981', vec: [0.21, 0.68] },
|
||||
{ word: '狗', x: 160, y: 180, color: '#10b981', vec: [0.28, 0.55] },
|
||||
{ word: '老虎', x: 185, y: 130, color: '#10b981', vec: [0.35, 0.72] },
|
||||
{ word: '兔子', x: 130, y: 195, color: '#10b981', vec: [0.22, 0.48] },
|
||||
{ word: '国王', x: 350, y: 175, color: '#8b5cf6', vec: [0.82, 0.58] },
|
||||
{ word: '王后', x: 390, y: 195, color: '#8b5cf6', vec: [0.88, 0.52] },
|
||||
{ word: '王子', x: 360, y: 225, color: '#8b5cf6', vec: [0.84, 0.42] },
|
||||
{ word: '公主', x: 395, y: 215, color: '#8b5cf6', vec: [0.89, 0.45] }
|
||||
]
|
||||
},
|
||||
'food-tech': {
|
||||
clusters: [
|
||||
{ label: '食物', cx: 140, cy: 240, rx: 85, ry: 70, color: '#f59e0b' },
|
||||
{ label: '科技', cx: 360, cy: 120, rx: 80, ry: 65, color: '#3b82f6' }
|
||||
],
|
||||
points: [
|
||||
{ word: '苹果(水果)', x: 110, y: 220, color: '#f59e0b', vec: [0.15, 0.38] },
|
||||
{ word: '面包', x: 155, y: 260, color: '#f59e0b', vec: [0.25, 0.28] },
|
||||
{ word: '牛奶', x: 130, y: 280, color: '#f59e0b', vec: [0.20, 0.22] },
|
||||
{ word: '蛋糕', x: 175, y: 230, color: '#f59e0b', vec: [0.30, 0.35] },
|
||||
{ word: '电脑', x: 340, y: 100, color: '#3b82f6', vec: [0.78, 0.82] },
|
||||
{ word: '手机', x: 375, y: 130, color: '#3b82f6', vec: [0.85, 0.75] },
|
||||
{ word: '芯片', x: 355, y: 150, color: '#3b82f6', vec: [0.82, 0.70] },
|
||||
{ word: '算法', x: 390, y: 110, color: '#3b82f6', vec: [0.88, 0.80] }
|
||||
]
|
||||
},
|
||||
emotions: {
|
||||
clusters: [
|
||||
{ label: '积极情感', cx: 150, cy: 130, rx: 90, ry: 70, color: '#10b981' },
|
||||
{ label: '消极情感', cx: 360, cy: 270, rx: 85, ry: 65, color: '#ef4444' },
|
||||
{ label: '中性情感', cx: 260, cy: 200, rx: 60, ry: 45, color: '#6b7280' }
|
||||
],
|
||||
points: [
|
||||
{ word: '快乐', x: 120, y: 110, color: '#10b981', vec: [0.15, 0.78] },
|
||||
{ word: '幸福', x: 155, y: 130, color: '#10b981', vec: [0.22, 0.72] },
|
||||
{ word: '兴奋', x: 180, y: 100, color: '#10b981', vec: [0.28, 0.82] },
|
||||
{ word: '悲伤', x: 340, y: 250, color: '#ef4444', vec: [0.78, 0.30] },
|
||||
{ word: '愤怒', x: 380, y: 270, color: '#ef4444', vec: [0.85, 0.25] },
|
||||
{ word: '恐惧', x: 360, y: 295, color: '#ef4444', vec: [0.82, 0.18] },
|
||||
{ word: '平静', x: 245, y: 190, color: '#6b7280', vec: [0.50, 0.52] },
|
||||
{ word: '淡然', x: 275, y: 210, color: '#6b7280', vec: [0.55, 0.48] }
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const currentClusters = computed(() => dataMap[activeCategory.value].clusters)
|
||||
const currentPoints = computed(() => dataMap[activeCategory.value].points)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.embedding-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.demo-header h4 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.desc {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cat-btn {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-2);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.cat-btn:hover {
|
||||
background: var(--vp-c-bg-alt);
|
||||
}
|
||||
|
||||
.cat-btn.active {
|
||||
border-color: var(--vp-c-brand);
|
||||
background: var(--vp-c-brand-soft);
|
||||
color: var(--vp-c-brand-dark);
|
||||
}
|
||||
|
||||
.canvas-wrap {
|
||||
position: relative;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.embed-svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.data-point {
|
||||
cursor: pointer;
|
||||
transition: r 0.2s;
|
||||
}
|
||||
|
||||
.point-group {
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.hover-info {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.hw {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.hc {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-3);
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.info-box {
|
||||
padding: 0.75rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.info-box .icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.info-box p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.embedding-demo {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,453 @@
|
||||
<!--
|
||||
EmbeddingPipelineDemo.vue
|
||||
嵌入生成流水线演示组件
|
||||
|
||||
用途:
|
||||
展示从原始文本到向量存储的完整嵌入流水线:
|
||||
Text → Tokenize → Model → Vector → Store → Query
|
||||
|
||||
交互功能:
|
||||
- 输入自定义文本
|
||||
- 逐步执行流水线
|
||||
- 每一步展示中间结果
|
||||
-->
|
||||
<template>
|
||||
<div class="pipeline-demo">
|
||||
<div class="demo-header">
|
||||
<h4>嵌入生成流水线</h4>
|
||||
<p class="desc">逐步体验从文本到向量的完整转换过程</p>
|
||||
</div>
|
||||
|
||||
<div class="input-area">
|
||||
<label>输入文本</label>
|
||||
<input
|
||||
v-model="inputText"
|
||||
type="text"
|
||||
placeholder="输入一段文本,观察嵌入生成过程..."
|
||||
class="text-input"
|
||||
/>
|
||||
<button class="run-btn" @click="runPipeline">
|
||||
{{ running ? '处理中...' : '开始处理' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 流水线步骤 -->
|
||||
<div class="pipeline-steps">
|
||||
<div
|
||||
v-for="(step, idx) in steps"
|
||||
:key="step.key"
|
||||
class="step"
|
||||
:class="{
|
||||
active: currentStep >= idx,
|
||||
current: currentStep === idx && running
|
||||
}"
|
||||
>
|
||||
<div class="step-header">
|
||||
<div class="step-num" :style="{ background: currentStep >= idx ? step.color : '' }">
|
||||
{{ idx + 1 }}
|
||||
</div>
|
||||
<div class="step-title">{{ step.title }}</div>
|
||||
<div v-if="currentStep > idx" class="step-check">✓</div>
|
||||
</div>
|
||||
|
||||
<div v-if="currentStep >= idx" class="step-content">
|
||||
<div class="step-desc">{{ step.desc }}</div>
|
||||
<div class="step-output" v-if="stepOutputs[step.key]">
|
||||
<code>{{ stepOutputs[step.key] }}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 箭头连接 -->
|
||||
<div v-if="idx < steps.length - 1" class="step-arrow">
|
||||
<span :class="{ visible: currentStep > idx }">↓</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 最终结果 -->
|
||||
<div v-if="currentStep >= steps.length - 1 && !running" class="final-result">
|
||||
<div class="result-title">嵌入向量已生成</div>
|
||||
<div class="vector-viz">
|
||||
<div
|
||||
v-for="(val, i) in finalVector"
|
||||
:key="i"
|
||||
class="vec-bar"
|
||||
:style="{
|
||||
height: Math.abs(val) * 60 + 'px',
|
||||
background: val >= 0 ? '#3b82f6' : '#ef4444',
|
||||
opacity: 0.4 + Math.abs(val) * 0.6
|
||||
}"
|
||||
>
|
||||
<span class="vec-val">{{ val.toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="vec-note">
|
||||
实际嵌入向量通常有 768~1536 个维度,这里仅展示前 16 维的模拟值
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
|
||||
const inputText = ref('今天天气真不错,适合出去散步')
|
||||
const currentStep = ref(-1)
|
||||
const running = ref(false)
|
||||
const stepOutputs = reactive({})
|
||||
const finalVector = ref([])
|
||||
|
||||
const steps = [
|
||||
{
|
||||
key: 'tokenize',
|
||||
title: '分词 (Tokenize)',
|
||||
desc: '将文本拆分为模型可处理的 Token 序列',
|
||||
color: '#3b82f6'
|
||||
},
|
||||
{
|
||||
key: 'encode',
|
||||
title: '编码 (Encode)',
|
||||
desc: '将 Token 映射为数字 ID',
|
||||
color: '#8b5cf6'
|
||||
},
|
||||
{
|
||||
key: 'model',
|
||||
title: '模型推理 (Model)',
|
||||
desc: '通过 Transformer 模型生成上下文感知的向量表示',
|
||||
color: '#10b981'
|
||||
},
|
||||
{
|
||||
key: 'pool',
|
||||
title: '池化 (Pooling)',
|
||||
desc: '将多个 Token 向量聚合为单一句子向量',
|
||||
color: '#f59e0b'
|
||||
},
|
||||
{
|
||||
key: 'normalize',
|
||||
title: '归一化 (Normalize)',
|
||||
desc: '将向量缩放到单位长度,便于余弦相似度计算',
|
||||
color: '#ef4444'
|
||||
}
|
||||
]
|
||||
|
||||
function simulateTokenize(text) {
|
||||
const tokens = []
|
||||
const zhRegex = /[\u4e00-\u9fa5]/g
|
||||
const enRegex = /[a-zA-Z]+/g
|
||||
let i = 0
|
||||
while (i < text.length) {
|
||||
if (/[\u4e00-\u9fa5]/.test(text[i])) {
|
||||
tokens.push(text[i])
|
||||
i++
|
||||
} else if (/[a-zA-Z]/.test(text[i])) {
|
||||
let word = ''
|
||||
while (i < text.length && /[a-zA-Z]/.test(text[i])) {
|
||||
word += text[i]
|
||||
i++
|
||||
}
|
||||
tokens.push(word)
|
||||
} else if (/\s/.test(text[i])) {
|
||||
i++
|
||||
} else {
|
||||
tokens.push(text[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
function hashCode(str) {
|
||||
let hash = 0
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash)
|
||||
}
|
||||
return Math.abs(hash)
|
||||
}
|
||||
|
||||
function generateVector(text, dim = 16) {
|
||||
const vec = []
|
||||
for (let i = 0; i < dim; i++) {
|
||||
const seed = hashCode(text + i)
|
||||
vec.push(((seed % 2000) - 1000) / 1000)
|
||||
}
|
||||
// 归一化
|
||||
const mag = Math.sqrt(vec.reduce((s, v) => s + v * v, 0))
|
||||
return vec.map((v) => v / (mag || 1))
|
||||
}
|
||||
|
||||
async function runPipeline() {
|
||||
if (running.value) return
|
||||
running.value = true
|
||||
currentStep.value = -1
|
||||
Object.keys(stepOutputs).forEach((k) => delete stepOutputs[k])
|
||||
finalVector.value = []
|
||||
|
||||
const text = inputText.value || '你好世界'
|
||||
|
||||
// Step 1: Tokenize
|
||||
await delay(400)
|
||||
currentStep.value = 0
|
||||
const tokens = simulateTokenize(text)
|
||||
stepOutputs.tokenize = `[${tokens.map((t) => '"' + t + '"').join(', ')}]`
|
||||
|
||||
// Step 2: Encode
|
||||
await delay(500)
|
||||
currentStep.value = 1
|
||||
const ids = tokens.map((t) => hashCode(t) % 50000)
|
||||
stepOutputs.encode = `[${ids.join(', ')}]`
|
||||
|
||||
// Step 3: Model
|
||||
await delay(600)
|
||||
currentStep.value = 2
|
||||
stepOutputs.model = `${tokens.length} 个 Token -> ${tokens.length} x 768 维隐藏状态矩阵`
|
||||
|
||||
// Step 4: Pool
|
||||
await delay(500)
|
||||
currentStep.value = 3
|
||||
stepOutputs.pool = `Mean Pooling: ${tokens.length} 个向量 -> 1 个 768 维句子向量`
|
||||
|
||||
// Step 5: Normalize
|
||||
await delay(400)
|
||||
currentStep.value = 4
|
||||
finalVector.value = generateVector(text)
|
||||
stepOutputs.normalize = `L2 归一化: ||v|| = 1.0000`
|
||||
|
||||
running.value = false
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.pipeline-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.demo-header h4 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.desc {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.input-area {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.input-area label {
|
||||
width: 100%;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-1);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.text-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.run-btn {
|
||||
padding: 8px 18px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-brand);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
white-space: nowrap;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.run-btn:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.pipeline-steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.step {
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
opacity: 0.4;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.step.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.step.current {
|
||||
border-color: var(--vp-c-brand);
|
||||
box-shadow: 0 0 0 2px var(--vp-c-brand-soft);
|
||||
}
|
||||
|
||||
.step-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.step-num {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
background: var(--vp-c-text-3);
|
||||
flex-shrink: 0;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.step-check {
|
||||
margin-left: auto;
|
||||
color: #10b981;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
margin-top: 0.5rem;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.step-desc {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-3);
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.step-output {
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 4px;
|
||||
padding: 6px 10px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.step-output code {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
font-family: var(--vp-font-family-mono);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.step-arrow {
|
||||
text-align: center;
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.step-arrow span {
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.step-arrow span.visible {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.final-result {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid #10b981;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-weight: 600;
|
||||
color: #10b981;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.vector-viz {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
height: 80px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.vec-bar {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border-radius: 2px 2px 0 0;
|
||||
position: relative;
|
||||
transition: height 0.3s;
|
||||
}
|
||||
|
||||
.vec-val {
|
||||
position: absolute;
|
||||
top: -16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.6rem;
|
||||
color: var(--vp-c-text-3);
|
||||
font-family: var(--vp-font-family-mono);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vec-note {
|
||||
margin: 0.75rem 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.pipeline-demo {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.input-area {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.run-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,401 @@
|
||||
<!--
|
||||
VectorDatabaseDemo.vue
|
||||
向量数据库对比组件
|
||||
|
||||
用途:
|
||||
交互式对比主流向量数据库的特性、适用场景和架构差异。
|
||||
|
||||
交互功能:
|
||||
- 点击卡片查看详情
|
||||
- 对比不同数据库的核心指标
|
||||
- 场景推荐
|
||||
-->
|
||||
<template>
|
||||
<div class="vdb-demo">
|
||||
<div class="demo-header">
|
||||
<h4>主流向量数据库对比</h4>
|
||||
<p class="desc">点击卡片查看详细信息,了解不同向量数据库的特点与适用场景</p>
|
||||
</div>
|
||||
|
||||
<div class="db-grid">
|
||||
<div
|
||||
v-for="db in databases"
|
||||
:key="db.name"
|
||||
class="db-card"
|
||||
:class="{ active: selected === db.name }"
|
||||
@click="selected = selected === db.name ? null : db.name"
|
||||
>
|
||||
<div class="card-header">
|
||||
<span class="db-icon" :style="{ background: db.color }">{{ db.icon }}</span>
|
||||
<div>
|
||||
<div class="db-name">{{ db.name }}</div>
|
||||
<div class="db-type">{{ db.type }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-tags">
|
||||
<span
|
||||
v-for="tag in db.tags"
|
||||
:key="tag"
|
||||
class="tag"
|
||||
>{{ tag }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="selected === db.name" class="card-detail">
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">开源协议</span>
|
||||
<span class="detail-val">{{ db.license }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">索引算法</span>
|
||||
<span class="detail-val">{{ db.index }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">最大维度</span>
|
||||
<span class="detail-val">{{ db.maxDim }}</span>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<span class="detail-label">适用场景</span>
|
||||
<span class="detail-val">{{ db.useCase }}</span>
|
||||
</div>
|
||||
<p class="detail-desc">{{ db.description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="card-metrics">
|
||||
<div class="metric">
|
||||
<div class="metric-bar-wrap">
|
||||
<div class="metric-bar" :style="{ width: db.perf + '%', background: db.color }"></div>
|
||||
</div>
|
||||
<span class="metric-label">性能</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-bar-wrap">
|
||||
<div class="metric-bar" :style="{ width: db.ease + '%', background: db.color }"></div>
|
||||
</div>
|
||||
<span class="metric-label">易用性</span>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="metric-bar-wrap">
|
||||
<div class="metric-bar" :style="{ width: db.scale + '%', background: db.color }"></div>
|
||||
</div>
|
||||
<span class="metric-label">扩展性</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="scenario-section">
|
||||
<h5>场景推荐</h5>
|
||||
<div class="scenario-grid">
|
||||
<div
|
||||
v-for="s in scenarios"
|
||||
:key="s.title"
|
||||
class="scenario-card"
|
||||
>
|
||||
<div class="scenario-icon">{{ s.icon }}</div>
|
||||
<div class="scenario-title">{{ s.title }}</div>
|
||||
<div class="scenario-rec">{{ s.recommend }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
|
||||
const selected = ref(null)
|
||||
|
||||
const databases = [
|
||||
{
|
||||
name: 'Pinecone',
|
||||
type: '全托管云服务',
|
||||
icon: 'P',
|
||||
color: '#3b82f6',
|
||||
tags: ['云原生', 'Serverless'],
|
||||
license: '商业',
|
||||
index: 'Proprietary ANN',
|
||||
maxDim: '20,000',
|
||||
useCase: '快速上线的 AI 应用',
|
||||
description: '全托管向量数据库,无需运维,按用量付费。适合初创团队和快速原型开发。',
|
||||
perf: 85,
|
||||
ease: 95,
|
||||
scale: 80
|
||||
},
|
||||
{
|
||||
name: 'Milvus',
|
||||
type: '开源分布式',
|
||||
icon: 'M',
|
||||
color: '#10b981',
|
||||
tags: ['开源', '分布式', '高性能'],
|
||||
license: 'Apache 2.0',
|
||||
index: 'IVF / HNSW / DiskANN',
|
||||
maxDim: '32,768',
|
||||
useCase: '大规模企业级检索',
|
||||
description: '支持十亿级向量的分布式数据库,提供丰富的索引类型和混合查询能力。',
|
||||
perf: 95,
|
||||
ease: 65,
|
||||
scale: 95
|
||||
},
|
||||
{
|
||||
name: 'Weaviate',
|
||||
type: '开源 AI 原生',
|
||||
icon: 'W',
|
||||
color: '#8b5cf6',
|
||||
tags: ['开源', 'GraphQL', '模块化'],
|
||||
license: 'BSD-3',
|
||||
index: 'HNSW',
|
||||
maxDim: '65,536',
|
||||
useCase: '语义搜索与多模态',
|
||||
description: '内置向量化模块,支持文本、图像等多模态数据的自动嵌入和检索。',
|
||||
perf: 80,
|
||||
ease: 85,
|
||||
scale: 80
|
||||
},
|
||||
{
|
||||
name: 'Chroma',
|
||||
type: '轻量级嵌入式',
|
||||
icon: 'C',
|
||||
color: '#f59e0b',
|
||||
tags: ['开源', '轻量', 'Python'],
|
||||
license: 'Apache 2.0',
|
||||
index: 'HNSW',
|
||||
maxDim: '无限制',
|
||||
useCase: '本地开发与 RAG 原型',
|
||||
description: '极简 API 设计,几行代码即可集成。非常适合 LangChain / LlamaIndex 生态。',
|
||||
perf: 60,
|
||||
ease: 98,
|
||||
scale: 40
|
||||
},
|
||||
{
|
||||
name: 'pgvector',
|
||||
type: 'PostgreSQL 扩展',
|
||||
icon: 'pg',
|
||||
color: '#ef4444',
|
||||
tags: ['SQL', 'PostgreSQL', '扩展'],
|
||||
license: 'PostgreSQL',
|
||||
index: 'IVFFlat / HNSW',
|
||||
maxDim: '16,000',
|
||||
useCase: '已有 PG 基础设施的团队',
|
||||
description: '在现有 PostgreSQL 中添加向量能力,无需引入新的数据库。支持 SQL 混合查询。',
|
||||
perf: 65,
|
||||
ease: 80,
|
||||
scale: 60
|
||||
}
|
||||
]
|
||||
|
||||
const scenarios = [
|
||||
{ icon: '🚀', title: '快速原型', recommend: 'Chroma / Pinecone' },
|
||||
{ icon: '🏢', title: '企业级部署', recommend: 'Milvus / Weaviate' },
|
||||
{ icon: '💾', title: '已有 PG 数据库', recommend: 'pgvector' },
|
||||
{ icon: '🤖', title: 'RAG 应用', recommend: 'Chroma / Weaviate' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.vdb-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.demo-header h4 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.desc {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.db-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.db-card {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.db-card:hover {
|
||||
border-color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.db-card.active {
|
||||
border-color: var(--vp-c-brand);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.db-icon {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 0.8rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.db-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.db-type {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.card-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
font-size: 0.7rem;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.card-detail {
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
padding-top: 0.6rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.detail-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8rem;
|
||||
padding: 3px 0;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.detail-val {
|
||||
color: var(--vp-c-text-1);
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.detail-desc {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin: 0.5rem 0 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.card-metrics {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.metric {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.metric-bar-wrap {
|
||||
flex: 1;
|
||||
height: 4px;
|
||||
background: var(--vp-c-divider);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.metric-bar {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-text-3);
|
||||
width: 40px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.scenario-section h5 {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.scenario-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.scenario-card {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.scenario-icon {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.scenario-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-1);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.scenario-rec {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-brand);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.db-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.scenario-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.vdb-demo {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,443 @@
|
||||
<!--
|
||||
VectorIndexDemo.vue
|
||||
向量索引策略可视化组件
|
||||
|
||||
用途:
|
||||
展示暴力搜索与近似最近邻(ANN)搜索的对比,可视化不同索引策略的工作原理。
|
||||
|
||||
交互功能:
|
||||
- 切换暴力搜索和ANN搜索模式
|
||||
- 点击查询点触发搜索动画
|
||||
- 展示搜索过程中访问的节点数量对比
|
||||
-->
|
||||
<template>
|
||||
<div class="index-demo">
|
||||
<div class="demo-header">
|
||||
<h4>向量索引策略对比</h4>
|
||||
<p class="desc">对比暴力搜索与近似最近邻搜索的效率差异</p>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button
|
||||
v-for="mode in modes"
|
||||
:key="mode.key"
|
||||
class="mode-btn"
|
||||
:class="{ active: activeMode === mode.key }"
|
||||
@click="switchMode(mode.key)"
|
||||
>
|
||||
{{ mode.label }}
|
||||
</button>
|
||||
<button class="search-btn" @click="runSearch">
|
||||
{{ searching ? '搜索中...' : '开始搜索' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="canvas-area">
|
||||
<svg viewBox="0 0 500 380" class="index-svg">
|
||||
<!-- 数据点 -->
|
||||
<circle
|
||||
v-for="(p, i) in points"
|
||||
:key="'p' + i"
|
||||
:cx="p.x" :cy="p.y" r="5"
|
||||
:fill="pointColor(i)"
|
||||
:opacity="pointOpacity(i)"
|
||||
stroke="#fff"
|
||||
stroke-width="0.8"
|
||||
class="data-pt"
|
||||
/>
|
||||
|
||||
<!-- ANN 分区线 (仅 ANN 模式) -->
|
||||
<template v-if="activeMode === 'ann'">
|
||||
<line
|
||||
v-for="(line, i) in partitionLines"
|
||||
:key="'line' + i"
|
||||
:x1="line.x1" :y1="line.y1"
|
||||
:x2="line.x2" :y2="line.y2"
|
||||
stroke="var(--vp-c-brand)"
|
||||
stroke-width="1"
|
||||
stroke-dasharray="4 3"
|
||||
opacity="0.3"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 查询点 -->
|
||||
<g>
|
||||
<circle
|
||||
:cx="query.x" :cy="query.y" r="9"
|
||||
fill="none" stroke="#ef4444" stroke-width="2.5"
|
||||
/>
|
||||
<circle :cx="query.x" :cy="query.y" r="3" fill="#ef4444" />
|
||||
<text
|
||||
:x="query.x + 14" :y="query.y + 4"
|
||||
fill="#ef4444" font-size="12" font-weight="600"
|
||||
>查询点</text>
|
||||
</g>
|
||||
|
||||
<!-- 搜索连线 -->
|
||||
<line
|
||||
v-for="(idx, i) in visitedOrder"
|
||||
:key="'visit' + i"
|
||||
:x1="query.x" :y1="query.y"
|
||||
:x2="points[idx].x" :y2="points[idx].y"
|
||||
:stroke="resultIndices.includes(idx) ? '#10b981' : '#94a3b8'"
|
||||
:stroke-width="resultIndices.includes(idx) ? 2 : 0.8"
|
||||
:opacity="resultIndices.includes(idx) ? 0.8 : 0.25"
|
||||
/>
|
||||
|
||||
<!-- 结果高亮 -->
|
||||
<circle
|
||||
v-for="idx in resultIndices"
|
||||
:key="'res' + idx"
|
||||
:cx="points[idx].x" :cy="points[idx].y" r="8"
|
||||
fill="none" stroke="#10b981" stroke-width="2.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 统计面板 -->
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">数据点总数</div>
|
||||
<div class="stat-val">{{ points.length }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">访问节点数</div>
|
||||
<div class="stat-val" :class="{ good: visitedOrder.length < points.length }">
|
||||
{{ visitedOrder.length }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">搜索效率</div>
|
||||
<div class="stat-val efficiency">
|
||||
{{ points.length > 0 ? ((visitedOrder.length / points.length) * 100).toFixed(0) : 0 }}%
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">找到最近 K 个</div>
|
||||
<div class="stat-val">{{ resultIndices.length }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="comparison-table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>策略</th>
|
||||
<th>时间复杂度</th>
|
||||
<th>精确度</th>
|
||||
<th>适用场景</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr :class="{ 'row-active': activeMode === 'brute' }">
|
||||
<td>暴力搜索</td>
|
||||
<td><code>O(n)</code></td>
|
||||
<td>100%</td>
|
||||
<td>小数据集 (<10K)</td>
|
||||
</tr>
|
||||
<tr :class="{ 'row-active': activeMode === 'ann' }">
|
||||
<td>ANN (IVF)</td>
|
||||
<td><code>O(n/k)</code></td>
|
||||
<td>~95%</td>
|
||||
<td>大数据集 (>100K)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>HNSW</td>
|
||||
<td><code>O(log n)</code></td>
|
||||
<td>~98%</td>
|
||||
<td>高性能检索</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
|
||||
const activeMode = ref('brute')
|
||||
const searching = ref(false)
|
||||
const visitedOrder = ref([])
|
||||
const resultIndices = ref([])
|
||||
|
||||
const modes = [
|
||||
{ key: 'brute', label: '暴力搜索' },
|
||||
{ key: 'ann', label: 'ANN 近似搜索' }
|
||||
]
|
||||
|
||||
const query = reactive({ x: 250, y: 190 })
|
||||
|
||||
// 生成随机数据点
|
||||
function generatePoints() {
|
||||
const pts = []
|
||||
const rng = (seed) => {
|
||||
let s = seed
|
||||
return () => { s = (s * 16807) % 2147483647; return s / 2147483647 }
|
||||
}
|
||||
const rand = rng(42)
|
||||
for (let i = 0; i < 60; i++) {
|
||||
pts.push({
|
||||
x: 40 + rand() * 420,
|
||||
y: 30 + rand() * 320
|
||||
})
|
||||
}
|
||||
return pts
|
||||
}
|
||||
|
||||
const points = ref(generatePoints())
|
||||
|
||||
const partitionLines = [
|
||||
{ x1: 250, y1: 10, x2: 250, y2: 370 },
|
||||
{ x1: 30, y1: 190, x2: 470, y2: 190 },
|
||||
{ x1: 140, y1: 10, x2: 140, y2: 370 },
|
||||
{ x1: 360, y1: 10, x2: 360, y2: 370 }
|
||||
]
|
||||
|
||||
function dist(a, b) {
|
||||
return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2)
|
||||
}
|
||||
|
||||
function switchMode(mode) {
|
||||
activeMode.value = mode
|
||||
visitedOrder.value = []
|
||||
resultIndices.value = []
|
||||
}
|
||||
|
||||
function runSearch() {
|
||||
if (searching.value) return
|
||||
searching.value = true
|
||||
visitedOrder.value = []
|
||||
resultIndices.value = []
|
||||
|
||||
const K = 3
|
||||
const allDists = points.value.map((p, i) => ({ i, d: dist(query, p) }))
|
||||
allDists.sort((a, b) => a.d - b.d)
|
||||
const trueTopK = allDists.slice(0, K).map((x) => x.i)
|
||||
|
||||
if (activeMode.value === 'brute') {
|
||||
// 暴力搜索:逐个访问
|
||||
const order = allDists.map((x) => x.i)
|
||||
let step = 0
|
||||
const timer = setInterval(() => {
|
||||
if (step < order.length) {
|
||||
visitedOrder.value = order.slice(0, step + 1)
|
||||
step++
|
||||
} else {
|
||||
clearInterval(timer)
|
||||
resultIndices.value = trueTopK
|
||||
searching.value = false
|
||||
}
|
||||
}, 30)
|
||||
} else {
|
||||
// ANN:只搜索查询点所在分区附近
|
||||
const qPartX = query.x < 140 ? 0 : query.x < 250 ? 1 : query.x < 360 ? 2 : 3
|
||||
const qPartY = query.y < 190 ? 0 : 1
|
||||
const nearby = points.value
|
||||
.map((p, i) => {
|
||||
const pPartX = p.x < 140 ? 0 : p.x < 250 ? 1 : p.x < 360 ? 2 : 3
|
||||
const pPartY = p.y < 190 ? 0 : 1
|
||||
const samePart = Math.abs(pPartX - qPartX) <= 1 && pPartY === qPartY
|
||||
return { i, d: dist(query, p), samePart }
|
||||
})
|
||||
.filter((x) => x.samePart)
|
||||
.sort((a, b) => a.d - b.d)
|
||||
|
||||
const order = nearby.map((x) => x.i)
|
||||
let step = 0
|
||||
const timer = setInterval(() => {
|
||||
if (step < order.length) {
|
||||
visitedOrder.value = order.slice(0, step + 1)
|
||||
step++
|
||||
} else {
|
||||
clearInterval(timer)
|
||||
resultIndices.value = nearby.slice(0, K).map((x) => x.i)
|
||||
searching.value = false
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
}
|
||||
|
||||
function pointColor(i) {
|
||||
if (resultIndices.value.includes(i)) return '#10b981'
|
||||
if (visitedOrder.value.includes(i)) return '#3b82f6'
|
||||
return '#94a3b8'
|
||||
}
|
||||
|
||||
function pointOpacity(i) {
|
||||
if (resultIndices.value.includes(i)) return 1
|
||||
if (visitedOrder.value.includes(i)) return 0.8
|
||||
return 0.4
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.index-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.demo-header h4 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.desc {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.controls {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.mode-btn {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-2);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.mode-btn.active {
|
||||
border-color: var(--vp-c-brand);
|
||||
background: var(--vp-c-brand-soft);
|
||||
color: var(--vp-c-brand-dark);
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
padding: 6px 16px;
|
||||
border: 1px solid #10b981;
|
||||
border-radius: 6px;
|
||||
background: #10b981;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
margin-left: auto;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.search-btn:hover {
|
||||
background: #059669;
|
||||
}
|
||||
|
||||
.canvas-area {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.index-svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.data-pt {
|
||||
transition: fill 0.3s, opacity 0.3s;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 0.6rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-3);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.stat-val {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-text-1);
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.stat-val.good {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.stat-val.efficiency {
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.comparison-table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.comparison-table table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.comparison-table th,
|
||||
.comparison-table td {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.comparison-table th {
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-2);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.comparison-table td {
|
||||
background: var(--vp-c-bg-soft);
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.row-active {
|
||||
background: var(--vp-c-brand-soft) !important;
|
||||
}
|
||||
|
||||
.row-active td {
|
||||
background: var(--vp-c-brand-soft) !important;
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: var(--vp-font-family-mono);
|
||||
font-size: 0.8rem;
|
||||
background: var(--vp-c-bg);
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.stats-row {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.index-demo {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,398 @@
|
||||
<!--
|
||||
VectorSimilarityDemo.vue
|
||||
向量相似度交互演示组件
|
||||
|
||||
用途:
|
||||
交互式展示余弦相似度和欧氏距离的计算过程,用户可拖动点观察相似度变化。
|
||||
|
||||
交互功能:
|
||||
- 拖动两个向量端点
|
||||
- 实时计算余弦相似度和欧氏距离
|
||||
- 切换度量方式查看差异
|
||||
-->
|
||||
<template>
|
||||
<div class="similarity-demo">
|
||||
<div class="demo-header">
|
||||
<h4>向量相似度计算器</h4>
|
||||
<p class="desc">拖动向量端点,观察不同相似度指标的实时变化</p>
|
||||
</div>
|
||||
|
||||
<div class="metric-tabs">
|
||||
<button
|
||||
v-for="m in metrics"
|
||||
:key="m.key"
|
||||
class="metric-btn"
|
||||
:class="{ active: activeMetric === m.key }"
|
||||
@click="activeMetric = m.key"
|
||||
>
|
||||
{{ m.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="canvas-area">
|
||||
<svg
|
||||
ref="svgEl"
|
||||
viewBox="0 0 460 360"
|
||||
class="sim-svg"
|
||||
@mousemove="onDrag"
|
||||
@mouseup="stopDrag"
|
||||
@mouseleave="stopDrag"
|
||||
>
|
||||
<!-- 网格 -->
|
||||
<defs>
|
||||
<pattern id="grid" width="40" height="40" patternUnits="userSpaceOnUse">
|
||||
<path d="M 40 0 L 0 0 0 40" fill="none" stroke="var(--vp-c-divider)" stroke-width="0.5" opacity="0.5" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect x="30" y="10" width="400" height="320" fill="url(#grid)" />
|
||||
|
||||
<!-- 坐标轴 -->
|
||||
<line x1="230" y1="10" x2="230" y2="330" stroke="var(--vp-c-divider)" stroke-width="1" />
|
||||
<line x1="30" y1="170" x2="430" y2="170" stroke="var(--vp-c-divider)" stroke-width="1" />
|
||||
|
||||
<!-- 夹角弧线 (余弦相似度模式) -->
|
||||
<path
|
||||
v-if="activeMetric === 'cosine'"
|
||||
:d="anglePath"
|
||||
fill="none"
|
||||
stroke="var(--vp-c-brand)"
|
||||
stroke-width="1.5"
|
||||
stroke-dasharray="3 2"
|
||||
opacity="0.6"
|
||||
/>
|
||||
|
||||
<!-- 距离线 (欧氏距离模式) -->
|
||||
<line
|
||||
v-if="activeMetric === 'euclidean'"
|
||||
:x1="vecA.x" :y1="vecA.y"
|
||||
:x2="vecB.x" :y2="vecB.y"
|
||||
stroke="#ef4444"
|
||||
stroke-width="2"
|
||||
stroke-dasharray="6 3"
|
||||
opacity="0.7"
|
||||
/>
|
||||
|
||||
<!-- 向量 A -->
|
||||
<line x1="230" y1="170" :x2="vecA.x" :y2="vecA.y" stroke="#3b82f6" stroke-width="2.5" />
|
||||
<polygon :points="arrowHead(230, 170, vecA.x, vecA.y)" fill="#3b82f6" />
|
||||
<circle
|
||||
:cx="vecA.x" :cy="vecA.y" r="10"
|
||||
fill="#3b82f6" stroke="#fff" stroke-width="2"
|
||||
class="drag-handle"
|
||||
@mousedown.prevent="startDrag('A')"
|
||||
/>
|
||||
<text :x="vecA.x + 14" :y="vecA.y - 8" fill="#3b82f6" font-size="13" font-weight="600">A</text>
|
||||
|
||||
<!-- 向量 B -->
|
||||
<line x1="230" y1="170" :x2="vecB.x" :y2="vecB.y" stroke="#10b981" stroke-width="2.5" />
|
||||
<polygon :points="arrowHead(230, 170, vecB.x, vecB.y)" fill="#10b981" />
|
||||
<circle
|
||||
:cx="vecB.x" :cy="vecB.y" r="10"
|
||||
fill="#10b981" stroke="#fff" stroke-width="2"
|
||||
class="drag-handle"
|
||||
@mousedown.prevent="startDrag('B')"
|
||||
/>
|
||||
<text :x="vecB.x + 14" :y="vecB.y - 8" fill="#10b981" font-size="13" font-weight="600">B</text>
|
||||
|
||||
<!-- 原点标记 -->
|
||||
<circle cx="230" cy="170" r="3" fill="var(--vp-c-text-3)" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 结果面板 -->
|
||||
<div class="results">
|
||||
<div class="result-card" :class="{ highlight: activeMetric === 'cosine' }">
|
||||
<div class="result-label">余弦相似度</div>
|
||||
<div class="result-value">{{ cosineSim.toFixed(4) }}</div>
|
||||
<div class="result-bar">
|
||||
<div class="bar-fill cosine-bar" :style="{ width: ((cosineSim + 1) / 2 * 100) + '%' }"></div>
|
||||
</div>
|
||||
<div class="result-range">-1 (相反) ~ 1 (相同)</div>
|
||||
</div>
|
||||
<div class="result-card" :class="{ highlight: activeMetric === 'euclidean' }">
|
||||
<div class="result-label">欧氏距离</div>
|
||||
<div class="result-value">{{ euclideanDist.toFixed(2) }}</div>
|
||||
<div class="result-bar">
|
||||
<div class="bar-fill euclidean-bar" :style="{ width: Math.min(euclideanDist / 5 * 100, 100) + '%' }"></div>
|
||||
</div>
|
||||
<div class="result-range">0 (完全重合) ~ ∞ (无穷远)</div>
|
||||
</div>
|
||||
<div class="result-card">
|
||||
<div class="result-label">点积</div>
|
||||
<div class="result-value">{{ dotProduct.toFixed(2) }}</div>
|
||||
<div class="result-hint">dot(A, B) = |A||B|cosθ</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<p>
|
||||
<span class="icon">💡</span>
|
||||
<strong>余弦相似度</strong>只关注方向,不关注长度,适合文本语义比较;<strong>欧氏距离</strong>同时考虑方向和大小,适合需要绝对距离的场景。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const activeMetric = ref('cosine')
|
||||
const dragging = ref(null)
|
||||
|
||||
const metrics = [
|
||||
{ key: 'cosine', label: '余弦相似度' },
|
||||
{ key: 'euclidean', label: '欧氏距离' }
|
||||
]
|
||||
|
||||
const vecA = ref({ x: 350, y: 80 })
|
||||
const vecB = ref({ x: 370, y: 250 })
|
||||
|
||||
const svgEl = ref(null)
|
||||
|
||||
function toVec(p) {
|
||||
return { x: (p.x - 230) / 100, y: (170 - p.y) / 100 }
|
||||
}
|
||||
|
||||
const cosineSim = computed(() => {
|
||||
const a = toVec(vecA.value)
|
||||
const b = toVec(vecB.value)
|
||||
const dot = a.x * b.x + a.y * b.y
|
||||
const magA = Math.sqrt(a.x * a.x + a.y * a.y)
|
||||
const magB = Math.sqrt(b.x * b.x + b.y * b.y)
|
||||
if (magA === 0 || magB === 0) return 0
|
||||
return dot / (magA * magB)
|
||||
})
|
||||
|
||||
const euclideanDist = computed(() => {
|
||||
const a = toVec(vecA.value)
|
||||
const b = toVec(vecB.value)
|
||||
return Math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2)
|
||||
})
|
||||
|
||||
const dotProduct = computed(() => {
|
||||
const a = toVec(vecA.value)
|
||||
const b = toVec(vecB.value)
|
||||
return a.x * b.x + a.y * b.y
|
||||
})
|
||||
|
||||
const anglePath = computed(() => {
|
||||
const r = 40
|
||||
const ax = vecA.value.x - 230
|
||||
const ay = vecA.value.y - 170
|
||||
const bx = vecB.value.x - 230
|
||||
const by = vecB.value.y - 170
|
||||
const angA = Math.atan2(ay, ax)
|
||||
const angB = Math.atan2(by, bx)
|
||||
const x1 = 230 + r * Math.cos(angA)
|
||||
const y1 = 170 + r * Math.sin(angA)
|
||||
const x2 = 230 + r * Math.cos(angB)
|
||||
const y2 = 170 + r * Math.sin(angB)
|
||||
const diff = angB - angA
|
||||
const large = Math.abs(diff) > Math.PI ? 1 : 0
|
||||
const sweep = diff > 0 ? 1 : 0
|
||||
return `M ${x1} ${y1} A ${r} ${r} 0 ${large} ${sweep} ${x2} ${y2}`
|
||||
})
|
||||
|
||||
function arrowHead(x1, y1, x2, y2) {
|
||||
const dx = x2 - x1
|
||||
const dy = y2 - y1
|
||||
const len = Math.sqrt(dx * dx + dy * dy)
|
||||
if (len < 1) return ''
|
||||
const ux = dx / len
|
||||
const uy = dy / len
|
||||
const px = -uy
|
||||
const py = ux
|
||||
const tipX = x2
|
||||
const tipY = y2
|
||||
const s = 8
|
||||
const p1x = tipX - ux * s + px * s * 0.4
|
||||
const p1y = tipY - uy * s + py * s * 0.4
|
||||
const p2x = tipX - ux * s - px * s * 0.4
|
||||
const p2y = tipY - uy * s - py * s * 0.4
|
||||
return `${tipX},${tipY} ${p1x},${p1y} ${p2x},${p2y}`
|
||||
}
|
||||
|
||||
function startDrag(which) {
|
||||
dragging.value = which
|
||||
}
|
||||
|
||||
function stopDrag() {
|
||||
dragging.value = null
|
||||
}
|
||||
|
||||
function onDrag(e) {
|
||||
if (!dragging.value || !svgEl.value) return
|
||||
const svg = svgEl.value
|
||||
const rect = svg.getBoundingClientRect()
|
||||
const scaleX = 460 / rect.width
|
||||
const scaleY = 360 / rect.height
|
||||
const x = Math.max(30, Math.min(430, (e.clientX - rect.left) * scaleX))
|
||||
const y = Math.max(10, Math.min(330, (e.clientY - rect.top) * scaleY))
|
||||
if (dragging.value === 'A') {
|
||||
vecA.value = { x, y }
|
||||
} else {
|
||||
vecB.value = { x, y }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.similarity-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
padding: 1.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.demo-header h4 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 1rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.desc {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.metric-tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.metric-btn {
|
||||
padding: 6px 14px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-2);
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.metric-btn.active {
|
||||
border-color: var(--vp-c-brand);
|
||||
background: var(--vp-c-brand-soft);
|
||||
color: var(--vp-c-brand-dark);
|
||||
}
|
||||
|
||||
.canvas-area {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sim-svg {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.results {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.result-card {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.result-card.highlight {
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.result-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-3);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.result-value {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: var(--vp-c-text-1);
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.result-bar {
|
||||
height: 4px;
|
||||
background: var(--vp-c-divider);
|
||||
border-radius: 2px;
|
||||
margin: 6px 0 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
|
||||
.cosine-bar {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.euclidean-bar {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.result-range {
|
||||
font-size: 0.7rem;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.result-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-3);
|
||||
margin-top: 6px;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.info-box {
|
||||
padding: 0.75rem;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 6px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.info-box p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.info-box .icon {
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.results {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.similarity-demo {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user