RAG 别只会向量检索:玄镜为什么加了「特征加权」

很多人上手 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 到底表示相似度还是距离会完全相反。所以我们做两件事:

  1. 建集合时显式声明 metadata={"hnsw:space": "cosine"};
  2. 过滤时把 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。

写在最后

玄镜这套检索增强,本质是三件套而不是单一「特征加权」:

  1. module metadata 过滤 ------ 先把无关模块砍掉;
  2. 特征查询扩展 ------ 把核心特征注入 query,拉偏 embedding 方向;
  3. 相似度阈值 ------ 用 cosine distance 转回的 similarity 兜底,宁少勿杂。

阈值怎么调、检索有没有用,都不能靠感觉。下一篇讲玄镜怎么用 golden set + 质量门把「RAG 到底有没有用」量化出来。

下一篇:怎么判断你的 RAG 到底有没有用(而非自我感动)

部署上的坑另开一篇单独讲:把 chromadb 塞进 Vercel:500MB 体积雷区与 Plan B

相关推荐
Erishen15 分钟前
Lume A2A 实战:让 Lume 应用接入分布式 Agent 协作网络
架构·开源·agent
武子康3 小时前
Qwen-Drive 的 91.4 分,究竟选中了什么?
大数据·人工智能·agent
阿里云云原生3 小时前
GOTC,来我这:阿里云AgentCore,带你的Agent走出“新手村”
agent
火山引擎开发者社区4 小时前
接大模型别再自己配 Key、搭代理了,火山引擎 IGA Pages 一步搞定
llm
chengliu05086 小时前
从 Rag 到领域 Agent
agent
阿里云云原生6 小时前
TechTalk丨从能买奶茶到能进企业,Agent 绕不开同一组问题
云原生·agent
chengliu05086 小时前
Agent 开发认知和架构 & 推理规划和编排
agent
去伪存真9 小时前
并发量一高就装死?Agent 全链路“防猝死”手册
前端·agent
liferecords10 小时前
不训练一个参数,把图像编辑器变成视频编辑器:EditVid 的局部-全局拆解术
人工智能·llm·aigc·多模态