很多人上手 RAG 就是 collection.add + collection.query,然后发现「检索出来的东西跟我问的对不上」。玄镜在垂直命理领域踩过这个坑------最后没用裸向量检索,而是在检索前加了一层「特征加权」。
更准确地说,这是查询层面的特征注入 / 查询增强,不是检索分数的加权融合。下文会把边界讲清。
代码基于
oraclemind-ai/src/services/retrieval.py的真实实现整理。仓库当前函数名仍为_extract_features,且旧版存在dict.fromkeys整体去重问题;下方为修正后的示意实现 ,本文按行为重命名为_build_feature_query便于表意,未直接改动仓库源码。修正点可作为仓库 PR 的参考。
一、裸向量在垂直长尾领域为什么翻车
命理语料极小(单模块 14~20 条),而且术语高度正交:问「日主甲木喜水」和「日主庚金喜火」,在 embeddings 空间里可能都离某条「五行生克」通用条目很近,但真正该命中的是各自对应的专用条目。纯余弦相似度会把通用条目顶上来,专用条目反而沉底。
二、玄镜的方案:抽特征 → 查询扩展 → 模块过滤 → 相似度检索
核心在 retrieval.py 的 _build_feature_query:把排盘结果的关键字段抽出来,构造成一段「查询增强文本」------核心特征重复两遍做查询强调,再和原始问题拼到一起交给 Chroma 做语义检索。
这里先说清「加权」的边界:它不是检索分数融合 (比如 score = α·sim(query) + β·sim(feature) 那种多路召回融合),而是查询构造层面的关键词强调 :把核心特征重复写入 query,让 embedding 在编码时更偏向该特征。在 dense embedding 里,重复一个词不等于线性权重加倍------它受 tokenizer、文本长度归一化、embedding 模型影响,可能有效,也可能引入噪声。玄镜选这条路,是因为它无额外模型、无额外索引,只有 query 变长带来的少量编码开销,且在评估集上稳定提升了专属条目的召回(Recall@5 口径;第三节阈值评估使用同一指标与评估集)。
python
def _build_feature_query(module: str, result: dict) -> str:
"""把排盘结果抽成『查询增强文本』:base 去重一次,core 核心特征连续重复两遍。"""
base, core = [], []
if module == "bazi":
dm = result.get("dayMaster") or "" # 关键:or "" 防 key 存在但值为 None 拼出「日主None」
dmwx = result.get("dayMasterWuxing") or ""
# 拆开处理,避免 dm 为空、dmwx 有值时拼出「日主水」这种歧义串
if dm and dmwx:
base.append(f"日主{dm}{dmwx}")
elif dm:
base.append(f"日主{dm}")
elif dmwx:
base.append(f"五行属{dmwx}")
ys = result.get("yongshen")
if not isinstance(ys, dict): # 类型防御:上游可能给非 dict
ys = {}
xi_list = ys.get("xi") or []
if isinstance(xi_list, str): # 类型防御:上游可能给 "水" 而非 ["水"]
xi_list = [xi_list]
elif not isinstance(xi_list, (list, tuple)): # 其它非序列类型直接清空,避免迭代报错
xi_list = []
if xi_list:
# 用神为核心检索特征:逐条拆开,避免拼成『喜水木』这种模糊串
core.extend(f"喜{x}" for x in xi_list if x)
# ...... 紫微 / 六壬 / 塔罗 / 星座 / 数字命理 等模块各有专属特征抽取
base = list(dict.fromkeys(f for f in base if f)) # base 去重,只保留一次
core = list(dict.fromkeys(f for f in core if f)) # core 去重,避免同特征重复计数
# 核心特征(用神)连续重复两遍做查询强调;base(日主等)只保留一次
# 先去重再重复,重复才真正生效;用 [f for f in core for _ in range(2)] 保证每个特征连续两遍
core_repeated = [f for f in core for _ in range(2)]
return " ".join(base + core_repeated)
⚠️ 一个曾经踩过的坑(真实源码里也踩过):旧写法先
for _ in range(2)把核心特征 append 两遍,最后又用dict.fromkeys整体去重------去重会把重复的「喜水」全吃掉,最终每个只出现一次,「重复两遍」等于没做。正确顺序是先对 base / core 各自去重,再对 core 做连续重复(每个特征连续两遍) ,本文片段已按此修正。
然后才是真正的检索调用。文章里常被断链的一点是:retrieve 的 query 到底是原始问题、_build_feature_query 的结果,还是两者拼接?完整链路是这样:
ini
# 集合必须显式声明余弦空间,否则 distance 含义会反(见第三节)
collection = client.get_or_create_collection(
name="oracle_kb",
metadata={"hnsw:space": "cosine"},
)
feature_query = _build_feature_query(module, result)
final_query = f"{user_query} {feature_query}".strip() # 原始问题 + 特征查询扩展
docs = retrieve(final_query, module=module, min_similarity=MIN_SIMILARITY) # 阈值见第三节
- 为什么保留原始
user_query? 我们离线对比过「只用特征 query」和「原始问题 + 特征 query」两种(线上小流量 A/B 待补):在长尾、口语化、表述角度刁钻的问法上,后者(user + feature)更稳------特征 query 负责把 embedding 拉向正确领域,原始问题保留用户的真实意图细节,两者互补。 - 为什么还要
module过滤? 见第四节:正逆位、模块归属这类结构化条件,必须靠where/ metadata 过滤,不能指望相似度阈值兜底。
小语料(14~20 条)为什么还用 Chroma? 单模块这点数据,HNSW 索引优势确实有限,全量打分或规则重排更直接。玄镜仍保留 Chroma,是为了统一多模块的 metadata 过滤和后续扩展 ------实际排序里 module 过滤 + 特征查询 + 相似度阈值 三者共同起作用,不是单靠向量。换句话说,向量只是其中一块拼图。
同一 query 下:裸向量 vs 特征查询扩展(示意)
| 检索方式 | Top1 | Top2 | Top3 | 专属条是否进 Top3(至少一条) |
|---|---|---|---|---|
| 裸向量 | 通用「五行生克」 | 通用「十神关系」 | 通用「用神总论」 | ✗ |
| 特征扩展 | 专属「日主甲木喜水」 | 专属「用神喜水」 | 通用「五行生克」 | ✓(Top1 / Top2) |
上表是代表性 query 的示意 结果,用于说明排序结构差异;条目名为示意,非真实知识库原文 ,绝对相似度数值请以
rag-3里 golden set 的实测为准(跑run_eval可得)。核心可验证结论是:特征查询扩展改变了 Top 排序,让专属条目压过通用条目。
三、阈值不是拍脑袋:用基线评估反推
MIN_SIMILARITY 控制「低于多少相似度直接丢弃」。这里有个必须先讲清的前提:Chroma 的 query 默认返回的是 distance,不是 similarity ,而且默认空间不一定是 cosine。不显式声明 hnsw:space="cosine",你写的 0.15 到底表示相似度还是距离会完全相反。所以我们做两件事:
- 建集合时显式声明
metadata={"hnsw:space": "cosine"}; - 过滤时把 distance 转回 similarity 再比较。
ini
def retrieve(query: str, module: str, k: int = 5, min_similarity: float = None):
if min_similarity is None: # 默认回退到常量,避免两处双写漂移
min_similarity = MIN_SIMILARITY
res = collection.query(
query_texts=[query],
n_results=k,
where={"module": module},
)
docs_raw = res.get("documents") or [[]] # 空结果防御:documents 可能为 []
dists_raw = res.get("distances") or [[]]
docs = []
for doc, dist in zip(docs_raw[0], dists_raw[0]):
similarity = 1 - dist # Chroma 返回 distance,转成 similarity
if similarity >= min_similarity: # 阈值方向:相似度 ≥ 下限才保留
docs.append(doc)
return docs
示意简化:生产版
retrieve会一并返回(doc, similarity, metadata),便于上游做解释与兜底,不止返回文本。
ini
# 宁少给,不要给杂;但覆盖长尾中 0.20 的唯一相关条
MIN_SIMILARITY = 0.15
当前全局阈值是起步值 ;八字、塔罗、紫微的 embedding 分数分布未必可比,多模块分数校准后可按
module分别设阈值。
阈值怎么从 0.25 降到 0.15? 这里要区分两个完全不同的分数分布,否则数字看着自相矛盾:
- golden set 上的「命中条」分数分布 (当前 embedding 模型与语料下):最低相似度约 0.45,区间 0.45~0.62------这批是「标准问法 + 明确相关」的条目。
- 线上长尾 query 抽样 :存在「唯一相关条只有 0.20」的案例------问法刁钻、表述角度和知识库条目不一致,但确实是唯一该命中的那条。
于是我们在留出集(hold-out)上扫了 0.10~0.30,约束是 golden set 的 Recall@5 不下降、无答案 query 的误召率不超过设定上限 (上限值与具体口径见 rag-3),选了「召回不降、误召可接受」的下限。这个下限覆盖抽样中 0.20 的案例;0.10 以下仍可能漏召回------这是召回与噪声的权衡,0.15 是当前留出集上的安全下限 ,不是「永不漏」。一句话:0.25 会在长尾上漏召回 0.20 的唯一相关条,0.15 是权衡出来的结果。
「宁可少给,不要给杂」是实测换来的:曾把「全部正位」条目检索进含逆位的牌阵,直接带偏了模型输出。但这是结构化过滤问题,不是阈值问题 ------正逆位必须进
where/ metadata 过滤,不能靠相似度阈值兜底。阈值只能兜底,结构化条件仍要靠 metadata。
写在最后
玄镜这套检索增强,本质是三件套而不是单一「特征加权」:
- module metadata 过滤 ------ 先把无关模块砍掉;
- 特征查询扩展 ------ 把核心特征注入 query,拉偏 embedding 方向;
- 相似度阈值 ------ 用 cosine distance 转回的 similarity 兜底,宁少勿杂。
阈值怎么调、检索有没有用,都不能靠感觉。下一篇讲玄镜怎么用 golden set + 质量门把「RAG 到底有没有用」量化出来。
部署上的坑另开一篇单独讲:把 chromadb 塞进 Vercel:500MB 体积雷区与 Plan B