跳到主要内容

13 篇博文 含有标签「Bug修复」

查看所有标签

修改 WordPress Block Theme 不生效?FSE 开发 5 大难题排查指南

· 阅读需 8 分钟

在为客户开发 WordPress Block Theme 时反复遇到这五个问题,每次排查都花了不少时间。整理成指南,帮助同样在做 FSE 开发的同学快速定位。

TL;DR

五个问题按频率排序:文件修改不生效(数据库缓存覆盖文件)、块嵌套错乱(注释未关闭)、子主题内容不渲染(缺少 post-content 块)、SVG 图标消失(WP_Filesystem 被插件污染)、WP-CLI 邮件失败(SMTP 插件在命令行不生效)。每个场景都给出可直接复用的排查命令。

场景一:改了主题文件,页面没变化

问题现象

修改了主题目录下的 theme.jsontemplates/*.htmlparts/*.html,刷新页面无变化。甚至 git pull 更新了代码,前端仍然显示旧样式。

根因

FSE 主题的模板和全局样式会被 Site Editor 保存到数据库——wp_templatewp_template_partwp_global_styles 三种自定义文章类型。WordPress 读取时数据库版本优先于文件版本。即使你改了文件,只要数据库里有对应记录,就会用数据库的。

解决方案

不同文件类型对应不同的清理方式:

修改内容清理方式
templates/*.htmlwp_template
parts/*.htmlwp_template_part
theme.jsonwp_global_styles + 缓存
patterns/*.php直接生效,无需清理

一键清理所有数据库模板缓存:

# 本地 Docker 环境
docker exec wp_cli bash -c 'wp post delete $(wp post list --post_type=wp_template --format=ids --allow-root) --force --allow-root'
docker exec wp_cli bash -c 'wp post delete $(wp post list --post_type=wp_template_part --format=ids --allow-root) --force --allow-root'
docker exec wp_cli bash -c 'wp post delete $(wp post list --post_type=wp_global_styles --format=ids --allow-root) --force --allow-root'
docker exec wp_cli wp cache flush --allow-root

如果 theme.json 修改后仍不生效,先验证 JSON 格式是否有语法错误(如尾随逗号,JSON 规范不允许):

docker exec wp_cli wp eval 'echo json_encode(json_decode(file_get_contents(get_template_directory() . "/theme.json")));' --allow-root
# 返回空字符串 → JSON 有语法错误

注意事项

  • 开发期禁止在 Site Editor 中保存,避免产生数据库覆盖
  • 生产环境清理前确认 Site Editor 中没有自定义修改需要保留
  • patterns/*.php 不受此问题影响——Pattern 注册走 PHP 代码,不经过数据库
  • Site Editor 保存的 wp_global_styles 如果 JSON 损坏,会导致全站 WP_Theme_JSON_Resolver 解析错误,表现为所有页面样式崩溃

场景二:Site Editor 显示"尝试恢复",页面布局全乱了

问题现象

Site Editor 中 Pattern 显示"尝试恢复"(Attempt Recovery),保存后页面布局完全错乱。某些块被错误地嵌套在其他块内部,层级关系与源码不一致。

根因

WordPress 块编辑器使用 HTML 注释标记块的边界:

<!-- wp:group {"layout":{"type":"constrained"}} -->
<div class="wp-block-group">
<!-- 内容 -->
<!-- /wp:group -->

当容器块(如 wp:groupwp:columns)缺少关闭注释 <!-- /wp:group --> 时,Gutenberg 的 parse_blocks() 会将后续所有块视为该容器的子块。后果:

  1. 父块的 save 输出为空
  2. 触发 Block validation failed
  3. 后续所有块的嵌套关系全部错位

解决方案

排查:在浏览器控制台检查块树层级:

wp.data.select('core/block-editor').getBlocks()

检查返回的块树结构,确认每个容器块的 innerBlocks 是否符合预期。如果一个 group 块内部包含了不应该在里面的块,大概率是前面某个容器缺少关闭注释。

修复:打开 Pattern 源文件,逐个检查每个 <!-- wp:xxx --> 都有对应的 <!-- /wp:xxx -->。建议在编辑器中搜索 <!-- wp:<!-- /wp:,计数确认数量一致。

预防技巧

使用支持括号匹配的编辑器(如 VS Code),配合 Block Comment 高亮插件,可以在编写时立即发现未关闭的注释。对于复杂的 Pattern,建议先写骨架结构(所有开闭注释配对),再填充内容。

场景三:子主题覆盖模板后,编辑器内容消失了

问题现象

创建子主题覆盖父主题模板后,在 WordPress 页面编辑器中输入的内容(文本、图片等)在前端完全空白。但模板文件中硬编码的 Pattern(如 hero 区域、CTA 区块)正常显示。

根因

FSE 模板通过 <!-- wp:post-content /--> 块来渲染页面编辑器中的 post_content。如果子主题覆盖的模板文件中没有这个块,WordPress 不知道在哪里输出页面内容。

结果就是:模板的固定结构(header、hero、sidebar)正常显示,但编辑器里写的内容全部丢失。

解决方案

确保子主题模板包含 post-content 块:

<!-- wp:group {"layout":{"type":"constrained"}} -->
<div class="wp-block-group">

<!-- 模板固定结构(hero、sidebar 等) -->

<!-- wp:post-content {"layout":{"type":"constrained"}} /-->

<!-- 更多固定结构(CTA、footer 引用等) -->

</div>
<!-- /wp:group -->

排查"改了没效果"时,按以下顺序确认:

  1. 模板文件是否包含 <!-- wp:post-content /-->
  2. 你修改的是模板文件还是页面内容——两者控制不同的内容区域
  3. 模板中内联的 cover block 由模板文件控制,与数据库 post_content 无关

场景四:SVG 图标突然消失,但文件还在

问题现象

主题中使用 WP_Filesystem 读取 SVG 图标文件,突然所有 SVG 图标消失。直接访问 SVG 文件 URL 返回正常内容,但页面上图标位置是空的。

根因

WordPress 的 $wp_filesystem 全局变量默认使用 WP_Filesystem_Direct(直接读写本地文件)。某些插件(备份、安全类)在初始化时会将 $wp_filesystem 替换为 WP_Filesystem_ftpsocketsWP_Filesystem_SSH2

FTP/SSH 适配器通过远程连接读取文件,对本地路径(如 /var/www/html/wp-content/themes/...)无法正确访问,返回空字符串。由于替换发生在全局作用域,所有使用 WP_Filesystem 的主题和插件代码都受影响。

解决方案

第一步——诊断:检查当前 $wp_filesystem 的实际类型:

# 本地 Docker 环境
docker exec wp_cli wp eval 'global $wp_filesystem; echo get_class($wp_filesystem);' --allow-root

# 返回 WP_Filesystem_Direct → 正常
# 返回 WP_Filesystem_ftpsockets 或其他 → 已被污染

第二步——定位污染源:逐个禁用插件,检查哪个插件替换了适配器:

docker exec wp_cli wp plugin deactivate <plugin-name> --allow-root
docker exec wp_cli wp eval 'global $wp_filesystem; echo get_class($wp_filesystem);' --allow-root
# 重复直到返回 WP_Filesystem_Direct

第三步——代码兜底:在主题中加 file_get_contents() 作为 fallback:

function mytheme_get_svg( $path ) {
global $wp_filesystem;

// 优先使用 WP_Filesystem
if ( $wp_filesystem && method_exists( $wp_filesystem, 'get_contents' ) ) {
$content = $wp_filesystem->get_contents( $path );
if ( $content ) {
return $content;
}
}

// WP_Filesystem 失败时回退到直接读取
if ( file_exists( $path ) ) {
return file_get_contents( $path );
}

return '';
}

注意事项

  • file_get_contents() 在某些受限主机可能被 disable_functions 禁用,但 VPS 和 Docker 环境通常可用
  • 根治方案是定位并处理污染源插件,代码兜底只是临时方案
  • 此问题具有隐蔽性——SVG 文件本身完好,直接访问正常,只有在 PHP 中通过 WP_Filesystem 读取时才返回空

场景五:命令行发邮件失败,网页端正常

问题现象

通过 wp eval 在命令行调用 wp_mail() 发送邮件,始终失败。但通过 Web 请求触发的邮件(用户注册、联系表单)发送正常。WP Mail SMTP 等插件已正确配置 SMTP。

根因

SMTP 插件通过 Hook 拦截 wp_mail(),将发信通道从 PHP sendmail 切换到 SMTP 服务。但这些插件的 Hook 注册依赖 WordPress 完整启动流程——特别是 wp_loaded 之后的阶段。

WP-CLI 的 wp eval 虽然加载了 WordPress 核心,但部分插件 Hook 在 CLI 环境下不会被注册。wp_mail() 回退到 PHP sendmail,而大多数服务器没有配置 sendmail,导致发送失败。

解决方案

方法一——Web 请求测试:在主题中临时添加测试路由,通过浏览器触发:

// 临时添加到 functions.php,测试完立即删除
add_action( 'wp_loaded', function() {
if ( ! isset( $_GET['test_mail'] ) ) return;
if ( '1' !== $_GET['test_mail'] ) return;

$result = wp_mail( '[email protected]', 'SMTP Test', 'Test email body' );
var_dump( $result ); // true = 发送成功
exit;
} );

访问 https://yoursite.com/?test_mail=1 触发测试。

方法二——eval-file 确保完整加载

cat > /tmp/test-smtp.php << 'EOF'
<?php
require_once ABSPATH . 'wp-load.php';
do_action('wp_loaded');

$result = wp_mail('[email protected]', 'CLI SMTP Test', 'Test body');
echo $result ? "Sent\n" : "Failed\n";
EOF

docker exec wp_cli wp eval-file /tmp/test-smtp.php --allow-root

最佳实践

生产环境中,邮件发送验证应通过 Web 请求测试。WP-CLI 适合定时任务和批量操作,但不适合验证依赖完整 WordPress Hook 链的功能(如邮件、缓存预热等)。


SITE123 活动页面嵌入时间轴?先绕过这 5 个平台限制

· 阅读需 5 分钟

TL;DR

在 SITE123 平台的活动页面嵌入时间轴,踩了 5 类坑:Custom Code 无法精准定位、脚本在 DOM 未加载时执行、选择器命中 隐藏元素float 布局冲突导致相邻区块错位、平台缓存造成 脚本重复注入。解决方案:JS 动态 DOM 操作 + 横向鱼骨布局 + DOMContentLoaded 包裹。


问题现象

客户基于 SITE123 搭建活动网站,需要在 Hero 横幅下方嵌入竖排时间轴,展示关键日期(Key Dates)。Timeline 服务已自建完成(部署在 timeline.aidevhub.ai),但嵌入到 SITE123 页面后:

  • 时间轴不显示
  • 时间轴出现在页面底部
  • 时间轴插入后,Tickets 区域被挤到下方

根因

限制一:Custom Code 无法精准定位

SITE123 的 Custom Code 只能选择 4 个注入位置(head 开头、head 结尾、body 开头、body 结尾),无法指定目标页面,也无法插入到页面某个 section 之间

这是最根本的限制,绕不过去。只能通过 JS 动态操作 DOM,在页面加载后自行找到目标节点再插入。

限制二:脚本在 DOM 未加载时执行

如果将代码放在 "Before closing head tag",document.body 在执行时为 null。更诡异的是,在 SITE123 后台修改注入位置后,页面源码中的实际位置可能与设置不符。

解决方案:无论脚本放在哪,都用 DOMContentLoaded 包裹,确保 DOM 就绪后再执行。

限制三:选择器命中隐藏元素

tl.js 的插入逻辑优先查找 .container,但 SITE123 活动页面的 .container 是面包屑导航区域,设有 visibility: hidden,时间轴被插入到隐藏元素前,页面不可见。

querySelector 只取第一个匹配项,而 .container 是通用类名,很容易踩坑。

教训:选择器必须通过浏览器控制台在实际页面验证,不能依赖推断。

限制四:float 布局冲突

SITE123 活动页面采用 Bootstrap float 布局(col-sm-5 + col-sm-7,12 列刚占满)。在 .product-container 之前插入独占一行的元素,会导致 Tickets 列被挤到下一行。

这是结构性冲突——在 float 容器内部无法插入独占行元素而不破坏布局。

解决方案:改用横向鱼骨布局(贯穿线 + 圆点 + 上下交替卡片),width:100% 自然撑满,不参与 float 计算。

限制五:平台缓存导致脚本重复注入

修改 Custom Code 位置设置后,页面源码中出现两处 timeline.aidevhub.ai——同一条代码被注入了两次。这是 SITE123 的平台 bug,暂未找到根因,但不影响最终效果。


解决方案

1. 脚本执行时机兜底

DOMContentLoaded 包裹全部插入逻辑,不再依赖 SITE123 的位置设置:

document.addEventListener('DOMContentLoaded', function() {
// 插入逻辑
});

2. 选择器必须实测

在浏览器控制台验证三步走:

// 第一步:确认元素存在且可见
const el = document.querySelector('.product-container');
console.log(el.getBoundingClientRect());

// 第二步:确认唯一性(跨页面验证返回值为 1)
const all = document.querySelectorAll('.product-container');
console.log(all.length);

// 第三步:插入测试色块确认位置
const test = document.createElement('div');
test.style.cssText = 'background:red;height:100px;';
el.parentNode.insertBefore(test, el);

3. 布局选型

采用横向鱼骨布局:一条贯穿横线 + 圆点定位在线上 + 卡片上下交替。竖排布局在 float 场景下容易引发结构冲突,横向布局天然不参与 float 计算。

4. 插入位置

最终验证有效的插入目标:.product-container(活动详情和 Tickets 的父容器),在其之前插入时间轴,位置回到 Hero 下方、活动详情之前。

5. 部署生效

修改 routes/script.js 后执行 pm2 restart timeline 即生效,无需更新 SITE123 的嵌入代码


注意事项

选择器必须实测

SITE123 页面结构没有文档,选择器不能靠推断。.container 在其他场景是通用类名,在活动页面匹配到的是面包屑导航。始终用浏览器控制台在实际页面验证。

Custom Code 位置设置不可靠

在 SITE123 后台修改注入位置后,页面源码中的实际位置可能与设置不符。脚本自身做好执行时机的兜底,不要依赖平台设置。

float 布局内不能插入独占行元素

在 Bootstrap float 布局(col-*)的容器内部或之前插入 width:100% 的块级元素,会导致后续列被挤到下一行。绕行方案:插入位置改到容器外部,或改用不依赖 float 的布局。


自建 Timeline 服务,服务器怎么选?

本项目使用 Express + sql.js 部署在 Vultr,最低 $5/月配置足够,支持 PM2 进程管理。附安装脚本,5 分钟搭建完毕。

Vultr $5/月起 →

关键结论

  1. SITE123 Custom Code 无法精准定位,必须靠 JS 动态 DOM 操作弥补
  2. 脚本自身做好 DOMContentLoaded 兜底,不依赖平台位置设置
  3. 选择器用控制台在实际页面验证,querySelectorAll + getBoundingClientRect
  4. float 布局内插入独占行元素会引发结构冲突,改用横向布局绕过
  5. 修改服务端代码后 pm2 restart timeline 即生效,无需动嵌入代码

修复 React 列表 key 重复导致的 DOM 报错

· 阅读需 3 分钟

在开发 AI Agent 对话界面时遇到此问题,记录根因与解法。

TL;DR

Date.now() 毫秒级时间戳可能在同一毫秒内重复,作为 React 列表 key 会导致 DOM 报错。解决方案是添加随机后缀,或使用 crypto.randomUUID()

问题现象

聊天界面快速发送消息时,控制台报错:

Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node.

消息列表渲染异常,部分消息消失或错位。

根因

原代码使用 Date.now() 生成消息 ID:

// ❌ 问题代码
const id = `msg-${Date.now()}-user`

Date.now() 返回毫秒级时间戳(如 1742345678001)。问题在于:

  1. 同一毫秒内多次调用返回相同值 — JavaScript 事件循环中,同步代码执行速度远快于 1ms
  2. 快速操作触发多次调用 — 用户快速发送消息、SSE 流式响应同时创建多条消息
  3. key 重复破坏 reconciliation — React 认为 key 相同的是同一元素,导致 DOM 操作错乱

示例:用户在 1ms 内发送两条消息,两条消息的 key 都是 msg-1742345678001-user

解决方案

方案一:添加随机后缀(推荐)

// ✅ 修复后
const id = `msg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}-user`
  • Math.random().toString(36) 生成 36 进制随机字符串
  • .slice(2, 9) 截取 7 位,提供足够的唯一性
  • 时间戳 + 随机串的组合,碰撞概率极低

方案二:使用 crypto.randomUUID()

// ✅ 更严格方案(需要现代浏览器或 Node 15.6+)
const id = crypto.randomUUID() // 如 "550e8400-e29b-41d4-a716-446655440000"
  • 密码学安全的唯一标识符
  • 无碰撞保证
  • 兼容性:Chrome 92+、Firefox 95+、Safari 15.4+

方案三:计数器 + 时间戳

let counter = 0
const id = `msg-${Date.now()}-${counter++}-user`
  • 简单可靠
  • 需要维护计数器状态

完整示例

// Zustand store 中的消息创建
interface ChatMessage {
id: string
role: 'user' | 'assistant'
content: string
timestamp: number
}

export const useChatStore = create<ChatState>((set) => ({
messages: [],

addUserMessage: (content: string) => {
// ✅ 时间戳 + 随机后缀
const id = `msg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}-user`
const message: ChatMessage = {
id,
role: 'user',
content,
timestamp: Date.now(),
}
set((state) => ({
messages: [...state.messages, message],
}))
return id
},
}))

关键原则

  1. key 必须唯一且稳定 — 同一元素在兄弟节点间 key 不能重复
  2. 避免使用 index 作为 key — 列表顺序变化时会出问题
  3. 避免仅用时间戳 — 毫秒级不够精确,微秒级(performance.now())也不可靠

对类似需求感兴趣?联系合作

修复 Milvus 混合检索 RRF 分数与相似度阈值不兼容

· 阅读需 3 分钟

在 RAG 知识库项目中调试混合检索评分问题,以下是完整排查过程。

TL;DR

Milvus 混合检索的加权融合分数 = 0.7 * dense_score + 0.3 * sparse_score,理论最大值约 0.7。如果用 min_similarity=0.7 过滤,结果几乎全被剔除。解决方案:将阈值降到 0.3,或根据融合策略动态调整。

问题现象

混合检索返回空结果,即使数据库中明确存在相关文档:

# 调用混合检索
results = await milvus_service.hybrid_search(
collection_name="knowledge_base",
query_dense=dense_vector,
query_sparse=sparse_vector,
top_k=5,
min_similarity=0.7 # 问题根源
)

# 返回空数组 []
print(results) # {"documents": [[]], "metadatas": [[]], "distances": [[]]}

日志显示检索到了结果,但过滤后为空:

fused_results before filter: 10, scores: [0.52, 0.48, 0.45, ...]
min_similarity threshold: 0.7
fused_results after filter: 0, scores: []

根因

混合检索使用加权融合(Weighted Fusion)而非 Reciprocal Rank Fusion(RRF):

def _fuse_and_rank(self, dense_results, sparse_results, top_k):
semantic_weight = 0.7 # 语义权重
keyword_weight = 0.3 # 关键词权重

for result in dense_results:
similarity = 1 - distance
score = similarity * semantic_weight # 0.7 * score

for result in sparse_results:
similarity = 1 - distance
score = similarity * keyword_weight # 0.3 * score

# 同一文档的分数相加
final_score = dense_score + sparse_score

数学分析

  • 假设 dense 和 sparse 的相似度最大值都是 1.0
  • 融合分数最大值 = 0.7 * 1.0 + 0.3 * 1.0 = 1.0
  • 但实际中 sparse 分数通常较低(0.3-0.5),因为关键词很难完全匹配
  • 实际最大分数约 0.5-0.7

min_similarity=0.7 过滤,相当于要求"完美匹配",结果自然为空。

解决方案

方案一:降低阈值(推荐)

# config.py
class Settings(BaseSettings):
rag_min_similarity: float = 0.3 # 混合搜索分数阈值(加权分数通常较低)

方案二:动态阈值

根据检索类型使用不同阈值:

# 混合检索用较低阈值
if search_type == "hybrid":
min_similarity = 0.3
else:
min_similarity = 0.7 # 纯语义检索可用较高阈值

方案三:归一化融合分数

将融合分数归一化到 [0, 1]:

def _fuse_and_rank(self, dense_results, sparse_results, top_k):
# ... 融合逻辑 ...

# 归一化:除以权重和
max_possible_score = self.semantic_weight + self.keyword_weight # 1.0
for doc in doc_scores.values():
doc["score"] = doc["score"] / max_possible_score

return sorted_docs[:top_k]

FAQ

Q: 为什么混合检索分数比纯语义检索低?

A: 混合检索的分数是加权和,而非单纯的相似度。语义检索返回的是 0-1 的余弦相似度,而混合检索的分数是 0.7*dense + 0.3*sparse,即使两部分都是 1.0,最终也只有 1.0。但实际中 sparse 分数通常较低,导致总分偏低。

Q: RRF(Reciprocal Rank Fusion)和加权融合有什么区别?

A: RRF 基于排名位置计算:score = 1/(k+rank),与原始相似度无关。加权融合直接用相似度分数加权,更直观但需要调整阈值。Milvus 原生支持加权融合,RRF 需要自己实现。

Q: 阈值设为 0.3 会不会引入低质量结果?

A: 需要结合业务场景测试。0.3 是一个经验值,如果发现结果质量下降,可以:

  1. 提高到 0.4-0.5
  2. 在应用层做二次过滤
  3. 使用 LLM 对结果做相关性打分

修复 RAG 查询返回的 sources 缺少 similarity 字段

· 阅读需 3 分钟

在 RAG 知识库项目中调试查询结果返回格式问题,以下是完整排查过程。

TL;DR

RAG /query 接口返回的 sources 字段只包含 metadata,没有每条来源的 similarity 分数。解决方案:在组装响应时,将 metadatasdistances 合并,计算 similarity = 1 - distance

问题现象

调用 RAG 查询接口,返回的 sources 缺少相似度信息:

{
"answer": "根据文档...",
"sources": [
{"doc_id": "doc_001", "title": "API 文档", "source": "github"},
{"doc_id": "doc_002", "title": "开发指南", "source": "github"}
],
"similarity": 0.85
}

问题:

  • sources 数组中的每个对象没有 similarity 字段
  • 只有顶层的 similarity(最高相似度),无法知道每条来源的相关性
  • 前端无法按相似度排序或高亮显示

根因

原始代码直接返回 metadata,忽略了 distances 信息:

# 问题代码
result = {
"answer": answer,
"sources": search_results.get("metadatas", [[]])[0], # 只有 metadata
"collection": collection,
"similarity": max_similarity # 只有最高分
}

向量数据库(如 Milvus、Chroma)的检索结果通常包含三个数组:

  • documents: 文本内容
  • metadatas: 元数据
  • distances: 距离分数(越小越相似)

疏漏:只传递了 metadata,没有把 distance 转换为 similarity 并合并到 sources 中。

解决方案

合并 metadatasdistances,计算每条来源的相似度:

# 修复代码
metadatas = search_results.get("metadatas", [[]])[0]
distances = search_results.get("distances", [[]])[0]

sources = [
{**meta, "similarity": round(1 - dist, 3)}
for meta, dist in zip(metadatas, distances)
]

result = {
"answer": answer,
"sources": sources, # 现在包含 similarity
"collection": collection,
"similarity": max_similarity
}

修复后返回:

{
"answer": "根据文档...",
"sources": [
{"doc_id": "doc_001", "title": "API 文档", "similarity": 0.85},
{"doc_id": "doc_002", "title": "开发指南", "similarity": 0.72}
],
"similarity": 0.85
}

完整代码示例

async def query_handler(request):
# 1. 执行向量检索
search_results = await milvus_service.query(
collection_name=collection,
query_embeddings=[query_embedding],
n_results=5
)

# 2. 生成答案
answer = await llm.generate(context, question)

# 3. 组装 sources(合并 metadata 和 similarity)
metadatas = search_results.get("metadatas", [[]])[0]
distances = search_results.get("distances", [[]])[0]

sources = [
{**meta, "similarity": round(1 - dist, 3)}
for meta, dist in zip(metadatas, distances)
]

# 4. 计算最高相似度
max_similarity = max(s["similarity"] for s in sources) if sources else 0

return {
"answer": answer,
"sources": sources,
"similarity": max_similarity
}

FAQ

Q: 为什么 similarity = 1 - distance?

A: 向量数据库通常返回距离(distance)而非相似度(similarity)。对于余弦距离,cosine_distance = 1 - cosine_similarity,所以 similarity = 1 - distance。对于欧氏距离,需要用 similarity = 1 / (1 + distance) 等公式转换。

Q: 顶层 similarity 和 sources 中的 similarity 有什么区别?

A: 顶层 similarity 是最高相似度(最相关的那条来源),用于判断整体回答质量。sources 中每条记录的 similarity 表示该来源的相关性,用于排序、高亮或过滤。

Q: 如果 distance 不是余弦距离怎么办?

A: 需要根据距离类型调整公式:

  • 余弦距离:similarity = 1 - distance
  • 欧氏距离:similarity = 1 / (1 + distance)
  • 内积:similarity = distance(已经是相似度)

检查你的向量数据库配置,确认使用的是哪种距离度量。

排查前端部署后线上未更新的问题

· 阅读需 4 分钟

TL;DR

前端代码已推送到 Git,但线上看不到新功能?根因通常是服务器构建产物未更新。通过对比本地和服务器的 dist/ 目录时间戳即可确认,解决方案是在服务器上重新运行 npm run build

问题现象

新开发的按钮/功能在本地 (npm run dev) 正常显示,但部署后线上看不到:

  • 浏览器刷新无效
  • 清除浏览器缓存无效
  • 检查代码逻辑没问题
  • Git 确认代码已推送

根因

前端静态文件通常由 Nginx 直接服务,流程如下:

Git Push → 服务器 git pull → 服务器 npm run build → Nginx 服务 dist/

问题出在第二步到第三步之间:代码已 git pull,但 npm run build 未执行或执行失败。

典型场景:

  1. 自动部署脚本只同步代码,未触发构建
  2. 手动部署时忘记运行构建命令
  3. 构建命令执行了但输出到错误目录

解决方案

步骤 1:对比构建产物时间戳

# 本地
ls -la dist/assets/ | head -5

# 服务器
ssh user@server "ls -la /path/to/project/dist/assets/ | head -5"

输出对比:

# 本地(最新构建)
Mar 7 22:55 index-28dFGXhX.js ← 包含新功能

# 服务器(旧构建)
Mar 7 20:18 index-DsFdnylh.js ← 不包含新功能

文件名不同(hash 变化)说明内容有变化,时间戳不同说明构建未同步。

步骤 2:在服务器上重新构建

ssh user@server "cd /path/to/project && npm run build"

步骤 3:验证构建产物已更新

ssh user@server "ls -la /path/to/project/dist/assets/"

确认时间戳和文件名与本地一致。

步骤 4:刷新页面

由于 Vite/Webpack 构建会生成带 hash 的新文件名(如 index-28dFGXhX.js),index.html 会引用新文件,用户只需正常刷新即可,无需强制清除缓存。

完整排查命令

#!/bin/bash
# 保存为 check-deploy.sh

SERVER="user@server"
PROJECT_PATH="/path/to/project"

echo "=== 本地最新提交 ==="
git log --oneline -1

echo -e "\n=== 服务器最新提交 ==="
ssh $SERVER "cd $PROJECT_PATH && git log --oneline -1"

echo -e "\n=== 本地构建时间 ==="
ls -la dist/assets/ | head -3

echo -e "\n=== 服务器构建时间 ==="
ssh $SERVER "ls -la $PROJECT_PATH/dist/assets/ | head -3"

echo -e "\n=== 服务器与远程差异 ==="
ssh $SERVER "cd $PROJECT_PATH && git fetch origin && git log HEAD..origin/main --oneline"

FAQ

Q: 为什么 Git 已推送但线上还是旧代码?

A: Git 推送只更新了源代码,前端需要 npm run build 生成静态文件。如果部署流程没有自动触发构建,服务器上的 dist/ 目录仍是旧版本。Nginx 直接服务静态文件,不会自动执行构建。

Q: 清除浏览器缓存为什么无效?

A: 问题不在浏览器缓存,而在服务器上的静态文件本身是旧的。即使强制刷新,Nginx 返回的仍是旧的 JS/CSS 文件。正确做法是更新服务器上的构建产物。

Q: 如何避免忘记重新构建?

A: 两种方案:1) 配置 CI/CD 自动构建(如 GitHub Actions);2) 在服务器上使用 git hooks,git pull 后自动执行 npm run build

Q: Vite 构建为什么文件名带 hash?

A: Vite 默认给打包文件添加 content hash(如 index-28dFGXhX.js),文件内容变化时 hash 变化。这是缓存破坏策略,确保用户总能获取最新版本,同时保留长期缓存能力。

修复 Milvus 混合搜索的四个常见坑

· 阅读需 3 分钟

在 RAG 知识库项目中调试混合检索评分问题,以下是完整排查过程。

TL;DR

Milvus 混合搜索(Dense + Sparse)有四个常见坑:空稀疏向量报错、Collection 未加载、sparse 格式错误、阈值过高。本文给出每个问题的最小修复代码。

问题现象

坑 1:空稀疏向量插入失败

MilvusException: (code=65535, message=empty sparse float vector row)

坑 2:Collection 未加载

MilvusException: (code=101, message=failed to search: collection not loaded[collection=xxx])

坑 3:Sparse 向量格式错误

ParamError: (code=1, message=`search_data` value [{0: {81705: 1.3486}}] is illegal)

坑 4:搜索无结果(分数被过滤)

{"answer": "抱歉,知识库中没有相关内容", "similarity": 0.0}

根因

坑 1:Milvus 的 SPARSE_FLOAT_VECTOR 类型不接受空字典 {},必须有至少一个键值对。

坑 2:Milvus 2.4+ 要求搜索前显式调用 load_collection(),否则报 collection not loaded。

坑 3:DashScope API 返回的 sparse 格式是 {text_index: sparse_vec},搜索时需要提取 sparse_vec 本身,而非整个嵌套结构。

坑 4:混合搜索的分数是加权组合(如 0.7 * dense_score + 0.3 * sparse_score),通常在 0.3-0.5 之间。如果阈值设为 0.7,所有结果都会被过滤。

解决方案

坑 1:为空稀疏向量添加占位符

# 获取稀疏向量,如果为空则使用最小占位符
sparse_vec = sparse_vectors.get(chunk_idx, {})
if not sparse_vec:
sparse_vec = {0: 0.0} # Milvus 不接受空 sparse vector

data = {
"dense_vector": dense_embeddings[chunk_idx],
"sparse_vector": sparse_vec, # 保证非空
"text": chunk,
"doc_id": doc_id,
"metadata": metadata
}

坑 2:搜索前加载 Collection

async def hybrid_search(self, collection_name: str, ...):
self.get_or_create_collection(collection_name)

# Milvus 2.4+ 要求:搜索前必须加载
self.client.load_collection(collection_name=collection_name)

dense_results = self.client.search(...)
sparse_results = self.client.search(...)

坑 3:正确提取 Sparse 向量

async def embed_query(self, text: str) -> dict:
result = await self._embed_batch([text], text_type="query", use_instruct=True)
# _embed_batch 返回 {"sparse": {0: sparse_vec}}
# 需要提取 index 0 的向量本身
return {
"dense": result["dense"][0],
"sparse": result["sparse"].get(0, {}) # 提取 sparse_vec
}

坑 4:调整混合搜索阈值

# config.py 或环境变量
rag_min_similarity: float = 0.3 # 过滤阈值(原 0.7 过高)
rag_refuse_similarity: float = 0.3 # 拒答阈值(原 0.5 过高)

混合搜索分数计算公式:

# 典型分数范围:0.3 - 0.5
score = dense_similarity * 0.7 + sparse_similarity * 0.3

FAQ

Q: Milvus 为什么不接受空的稀疏向量?

A: Milvus 的 SPARSE_FLOAT_VECTOR 类型要求每行至少有一个非零元素。空字典 {} 无法确定向量维度,会触发 empty sparse float vector row 错误。使用 {0: 0.0} 作为占位符即可绕过。

Q: Milvus 2.4 搜索前必须调用 load_collection 吗?

A: 是的。Milvus 2.4+ 默认不自动加载 Collection 到内存,必须显式调用 client.load_collection(collection_name) 后才能搜索。这是性能优化设计,避免不用的 Collection 占用内存。

Q: 混合搜索的分数为什么通常只有 0.3-0.5?

A: 混合搜索分数是加权组合(如 0.7 * dense + 0.3 * sparse)。即使两个检索都完美匹配(1.0),加权后最高也只有 1.0。实际场景中 dense 和 sparse 分数很少同时为 1.0,所以典型分数在 0.3-0.5。阈值应设为 0.3 左右,而非 0.7。

Q: DashScope sparse embedding 返回什么格式?

A: DashScope 返回 {"embeddings": [{"sparse_embedding": [{"index": 123, "value": 0.5}, ...]}]}。批量调用时,转换后格式为 {text_index: {dim_index: value}}。搜索时需要用 .get(0, {}) 提取第一条的 sparse 向量。