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:
sanbuphy
2026-02-24 18:22:58 +08:00
parent b5a55811cc
commit 3af119a598
86 changed files with 20311 additions and 340 deletions
@@ -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">&#x1F4A1;</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">&#x2713;</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 }">&#x2193;</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: '&#x1F680;', title: '快速原型', recommend: 'Chroma / Pinecone' },
{ icon: '&#x1F3E2;', title: '企业级部署', recommend: 'Milvus / Weaviate' },
{ icon: '&#x1F4BE;', title: '已有 PG 数据库', recommend: 'pgvector' },
{ icon: '&#x1F916;', 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>小数据集 (&lt;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 (完全重合) ~ &#x221E; (无穷远)</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&#x3B8;</div>
</div>
</div>
<div class="info-box">
<p>
<span class="icon">&#x1F4A1;</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>