找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

1683

积分

0

好友

216

主题
发表于 2026-2-11 12:19:40 | 查看: 35| 回复: 0

前言

前四篇文章把整个链路跑通了:从数据清洗到向量化,再到 Redis 建索引,最后是查询服务。本篇我们不做宏观的“优缺点对比”或“结论总结”,而是聚焦于记录两件具体的事情:

  1. 如何基于 Redis 8 实际跑通“混合召回(Hybrid Retrieval)”的完整流程;
  2. 项目中关键的 hybrid_search(混合检索函数)内部到底做了哪些步骤(这里提供的是去掉调试日志后的可读版本)。

需要说明的是,文中会保留少量英文标识符(例如 hybrid_search、BM25、KNN、TopK),因为它们直接来自项目代码和检索领域的通用术语。我会在它们首次出现时给出中文解释,以确保所有读者都能顺畅理解。

实验环境与查询输入说明

2.1 实验环境

  • 硬件环境:两台 8核16GB内存的 Linux 服务器,没有 GPU。
  • 模型:Qwen/Qwen3-Embedding-0.6B
  • 向量维度:1024
  • Redis 版本:8.4.0
  • 语料规模:约 43,000 首经过清洗和繁简转换的唐诗。
  • 检索策略:混合检索,其中向量权重设为 0.7,文本权重设为 0.3。

2.2 查询输入说明(重要)

下面这些查询文本并非精心设计的“评测集”,而是我在项目冒烟测试(Smoke Test)中,出于个人兴趣随机输入的内容。它们主要用来快速验证几个核心问题:

  • 中文分词是否能正常生效;
  • 混合检索在“关键词命中”和“关键词不命中”两种情况下,能否稳定返回 TopK(前K条)结果;
  • 返回的结果结构是否方便后续进行重排序(Rerank)或分析。

为了便于对照,我把当时用于冒烟测试的一组查询词列出来:

序号 查询词
1 天涯
2 故乡
3 人到中年
4 在深山中漫步
5 怀念逝去的故人
6 壮志未酬
7 找朋友吃饭
8 人生艰难
9 人到中年没有意义
10 少年要努力
11 今天天气好啊
12 多吃点饭
13 策马奔腾

怎么做:项目主流程概览(建索引 → 写入 → 查询)

下面我用一组贴近实际项目实现的代码片段,把整个链路的关键步骤串联起来:包括建索引、写入数据和查询。我会具体说明“项目里做了什么、怎么做的、关键参数是什么”,比如中文分词配置、KNN向量近邻检索,以及两路信号(文本和向量)的归一化与加权融合。

3.1 建索引(关键:中文语言配置)

schema 的重点是两个方面:

  1. 使用 TEXT 字段进行全文检索(基于 BM25 算法);
  2. 设置 index.language = “Chinese” 以启用中文分词。
import redis
from redisvl.index import SearchIndex

def create_index(
    *,
    client: redis.Redis,
    index_name: str,
    prefix: str,
    dims: int,
) -> SearchIndex:
    schema = {
        “index”: {
            “name”: index_name,
            “prefix”: prefix,
            “storage_type”: “hash”,
            “language”: “Chinese”,  # 关键:启用中文分词
        },
        “fields”: [
            {“name”: “author”, “type”: “text”, “attrs”: {“weight”: 2.0}},
            {“name”: “title”, “type”: “text”, “attrs”: {“weight”: 1.5}},
            {“name”: “text”, “type”: “text”, “attrs”: {“weight”: 1.0}},
            {
                “name”: “vector”,
                “type”: “vector”,
                “attrs”: {
                    “dims”: dims,
                    “algorithm”: “hnsw”,
                    “distance_metric”: “cosine”,
                },
            },
        ],
    }

    vl_index = SearchIndex.from_dict(
        schema, redis_client=client, validate_on_load=True
    )
    # 如需重建索引,可把 overwrite=True
    vl_index.create(overwrite=False)
    return vl_index

3.2 写入数据(Hash + 向量 bytes)

示例中,每条文档用一个 Hash 结构保存,包含 authortitletextvector 字段。

import redis
import numpy as np
from typing import Dict, List

def add_docs(*, client: redis.Redis, prefix: str, docs: List[Dict]):
    “”“docs: {id, author, title, text, vector(np.ndarray float32)}”“”
    pipe = client.pipeline(transaction=False)
    for d in docs:
        key = f”{prefix}{d[‘id’]}”
        v = np.asarray(d[“vector”], dtype=np.float32)
        pipe.hset(
            key,
            mapping={
                “author”: d.get(“author”, “”),
                “title”: d.get(“title”, “”),
                “text”: d.get(“text”, “”),
                “vector”: v.tobytes(),
            },
        )
    pipe.execute()

3.3 查询主流程(向量化/Embedding → 混合检索/hybrid_search → 返回 TopK)

这里的 hybrid_search 函数是整个流程的核心:它把“关键词检索得分(BM25)”和“向量相似度(通过 KNN 检索得到的向量距离)”统一到一个候选集中,并在应用侧完成归一化与加权融合,最终为每条结果生成一个可用于排序的 hybrid_score

注意除了在建索引时指定中文语言外,查询端也必须显式指定中文语言。否则中文分词可能不生效,具体表现为 @text:xxx 这类查询的命中数量异常。

说明:查询向量(query_vector)来自向量化/Embedding 模块(例如,调用向量模型服务得到一个 float32 向量)。这部分实现与模型部署方式紧密相关,本文不展开讨论;下面的示例假设你已经拿到了 query_vector

import redis
import re
import numpy as np
from redis.commands.search.query import Query
from typing import Any, Dict, List, Optional

def _sanitize_query_text(query_text: str) -> str:
    q = (query_text or “”).strip()
    if not q:
        return “”

    # 去掉可能触发 RediSearch 查询语法冲突的字符
    q = re.sub(r”[\-\(\)\{\}\[\]\^\"~*?:\\]“, “”, q).strip()

    # 如果 query 中包含空格:按词项拆分,并用 OR(|) 连接
    if “ “ in q:
        terms = [t.strip() for t in q.split() if t.strip()]
        q = “|“.join(terms) if terms else q.replace(“ “, “”)
    return q

def hybrid_search(
    *,
    client,
    index_name: str,
    query_vector: np.ndarray,
    query_text: str,
    top_k: int = 10,
    vector_weight: float = 0.7,
    text_weight: float = 0.3,
    return_fields: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
    if return_fields is None:
        return_fields = [“text”, “author”, “title”]

    vec = np.asarray(query_vector, dtype=np.float32)
    sanitized = _sanitize_query_text(query_text)
    results: Dict[str, Dict[str, Any]] = {}

    # 1) 第一轮:混合查询(全文过滤 + KNN)
    query_str = (
        f”@text:{sanitized} =>[KNN {top_k} @vector $vec AS vector_score]”
        if sanitized
        else f”*=>[KNN {top_k} @vector $vec AS vector_score]”
    )

    q = (
        Query(query_str)
        .return_fields(*return_fields)
        .with_scores()            # doc.score = BM25(仅当有文本过滤时才有意义)
        .sort_by(“vector_score”)  # vector_score = 向量距离(COSINE 距离越小越相似)
        .dialect(2)
        .language(“Chinese”)      # 关键:查询也要指定中文语言
        .paging(0, top_k)
    )

    r = client.ft(index_name).search(q, query_params={“vec”: vec.tobytes()})
    for doc in r.docs:
        text_score_raw = float(getattr(doc, “score”, 0.0))
        vector_distance = float(getattr(doc, “vector_score”, 0.0))
        results[doc.id] = {
            “id”: doc.id,
            “text”: getattr(doc, “text”, “”),
            “metadata”: {
                “author”: getattr(doc, “author”, “”),
                “title”: getattr(doc, “title”, “”),
            },
            “text_score_raw”: text_score_raw,
            “vector_distance”: vector_distance,
            “has_text_match”: bool(sanitized) and text_score_raw > 0,
        }

    # 2) 候选集不足:纯向量补齐(排重)
    if len(results) < top_k:
        extra_k = top_k + len(results)
        extra_q = (
            Query(f”*=>[KNN {extra_k} @vector $vec AS vector_score]”)
            .return_fields(*return_fields)
            .sort_by(“vector_score”)
            .dialect(2)
            .language(“Chinese”)
            .paging(0, extra_k)
        )
        extra_r = client.ft(index_name).search(
            extra_q, query_params={“vec”: vec.tobytes()}
        )
        for doc in extra_r.docs:
            if doc.id in results:
                continue
            results[doc.id] = {
                “id”: doc.id,
                “text”: getattr(doc, “text”, “”),
                “metadata”: {
                    “author”: getattr(doc, “author”, “”),
                    “title”: getattr(doc, “title”, “”),
                },
                “text_score_raw”: 0.0,
                “vector_distance”: float(getattr(doc, “vector_score”, 0.0)),
                “has_text_match”: False,
            }
            if len(results) >= top_k:
                break

    # 3) 归一化(BM25 越大越好;向量距离越小越好)
    text_scores = [
        x[“text_score_raw”] for x in results.values() if x[“text_score_raw”] > 0
    ]
    tmin, tmax = (min(text_scores), max(text_scores)) if text_scores else (0.0, 0.0)

    distances = [x[“vector_distance”] for x in results.values()]
    dmin, dmax = min(distances), max(distances)

    merged: List[Dict[str, Any]] = []
    for x in results.values():
        if x[“text_score_raw”] > 0 and tmax > tmin:
            text_norm = (x[“text_score_raw”] - tmin) / (tmax - tmin)
        elif x[“text_score_raw”] > 0:
            text_norm = 1.0
        else:
            text_norm = 0.0

        if dmax > dmin:
            vector_norm = 1.0 - (x[“vector_distance”] - dmin) / (dmax - dmin)
        else:
            vector_norm = 1.0

        hybrid_score = text_weight * text_norm + vector_weight * vector_norm
        merged.append(
            {
                “id”: x[“id”],
                “text”: x[“text”],
                “metadata”: x[“metadata”],
                “source”: “both” if x[“has_text_match”] else “vector_match”,
                “text_score_raw”: x[“text_score_raw”],
                “vector_distance”: x[“vector_distance”],
                “hybrid_score”: hybrid_score,
            }
        )

    merged.sort(key=lambda item: item[“hybrid_score”], reverse=True)
    return merged[:top_k]

# 调用示例(省略向量化实现,假设 query_vector 已获得)
# client = redis.Redis(host=“127.0.0.1”, port=6379, db=0, decode_responses=False)
# index_name = “text_vectors_idx”
# query_text = “故乡”
# query_vector = …  # float32 ndarray,来自 embedding 模块
# results = hybrid_search(
#     client=client,
#     index_name=index_name,
#     query_vector=query_vector,
#     query_text=query_text,
#     top_k=10,
# )

检索结果展示(对外可读版)

这里仅展示对外可阅读的“结果概要 + Top5 节选”。

关于结果里的“来源”字段(项目代码里命名为 source):

  • 关键词+向量:该条结果在第一轮“关键词过滤 + KNN”的混合查询中,同时具备关键词命中(BM25 分数)与向量相似度信号(对应代码标记:both);
  • 仅向量:该条结果仅由向量相似度召回(常见于“关键词不命中”或“候选集补齐”场景,对应代码标记:vector_match)。

4.1 概要表(来源分布 + Top1)

查询词 返回条数(TopK) 来源(关键词+向量 / 仅向量) 第一条(Top1,作者·题目)
天涯 10 2 / 8 李商隐·《天涯》
故乡 10 1 / 9 龙昌期·《故乡》
人到中年 10 0 / 10 戴表元·《老态》
在深山中漫步 10 0 / 10 姜特立·《山中》
怀念逝去的故人 10 0 / 10 王炎·《追悼昭叔弟》
壮志未酬 10 0 / 10 喻良能·《览镜》
找朋友吃饭 10 0 / 10 释崇岳·《接待》
人生艰难 10 0 / 10 释法演·《偈七首  其二》
人到中年没有意义 10 0 / 10 戴表元·《老态》
少年要努力 10 0 / 10 冯楫·《临终颂》
今天天气好啊 10 0 / 10 释子益·《偈颂七十六首  其一四》
多吃点饭 10 0 / 10 释慧空·《示僧  其四》
策马奔腾 10 0 / 10 许及之·《逸骥》

4.2 更多示例输出(按查询词展示 Top5(前 5 条),节选)

下面仅展示每个查询词的 Top5(前 5 条,作者·题目 + 正文节选),用于直观对照“关键词命中/向量补齐”两类来源。

4.2.1 天涯

名次 来源 作者·题目 文本节选
1 关键词+向量 李商隐·《天涯》 春日在天涯,天涯日又斜。莺啼如有泪,为湿最高花。
2 关键词+向量 陆游·《天涯》 天涯到处自生愁,逰子征尘暗弊裘。灯火青荧五门夜,风烟索莫二江秋。帝城漫诵新诗句,客路难逢旧辈流。送老把茅须早决,此生何止四宜休。
3 仅向量 释法薰·《偈颂一百三十三首  其六六》 向上一路,千圣不然。目前无异草,脚下有青天。
4 仅向量 释法忠·《观水磨牌文有省》 转大法轮,目前包裹。更问如何,水推石磨。
5 仅向量 释绍隆·《偈二十七首  其一一》 目前无法,万象森然。意在目前,突出难辨。

4.2.2 故乡

名次 来源 作者·题目 文本节选
1 关键词+向量 龙昌期·《故乡》 桤木惭桑低别壤,芋苗护稻远分行。上书莫道便他郡,学稼终宜老故乡。
2 仅向量 郑刚中·《临行小颂别见春清浔二老》 不在四旁,亦非中央。个中生出老村汉,看尽桃花归故乡。
3 仅向量 曾几·《寓轩》 故国例卜宅,他乡多惜居。短长三万日,何处是吾庐。
4 仅向量 赵蕃·《寓舍书怀》 还乡一过年,借屋两成迁。旧阁爱晚碧,今居因地偏。居然阙生理,复尔办行缠。四十今如此,修名后若传。
5 仅向量 释宗杲·《颂古一百二十一首  其六五》 祖父田园都卖了,四边界至不曾留。奈何犹有中心树,恼乱春风卒未休。

4.2.3 人到中年

名次 来源 作者·题目 文本节选
1 仅向量 戴表元·《老态》 老态谁能脱,中年子自如。丹胎三转候,卦气一周时。倦动身如客,平眠力胜医。看书亦渐懒,意到或成诗。
2 仅向量 戴复古·《赠饶叔虎谈易论命多奇中》 中年多病早衰翁,诗不能工枉受穷。郊岛五行君识否,要知我命与渠同。
3 仅向量 白居易·《白云期》 三十气太状,胷中多是非。六十身太老,四体不支持。四十至五十,正是退闲时。年长识命分,心慵少营为。见酒兴犹在,登山力未衰。吾年幸当此,且与白云期。
4 仅向量 辛弃疾·《书清凉境界壁  其一》 从今数到七十岁,一十四度见梅花。何况人生七十少,云胡不归留此耶。
5 仅向量 苏泂·《病中排闷》 中年一病一回觉,老大康宁知更难。只今四十已华发,未到七旬应碧山。故疆风土望莫及,前辈典刑追不还。但愿生生遇尧舜,耕田凿井泰嵩间。

4.2.4 在深山中漫步

名次 来源 作者·题目 文本节选
1 仅向量 姜特立·《山中》 不到山中久,山寒景益奇。冻泉零雪谷,坠叶弄风丝。
2 仅向量 邢仙老·《诗赠晚学李君  其三》 诘曲川原几里深,偶寻岩壑在前林。长怀万古典坟乐,果称几年泉石心。将看道经延白日,偷收岩药化黄金。山中所访逍遥客,为报白云深处寻。
3 仅向量 楼钥·《送朱叔止守南剑  其四》 下水上山腰带州,人家无数起危楼。遨头不用喧箫鼓,祇把清诗纪胜游。
4 仅向量 郭君举·《天聪洞》 混沌何年凿,天聪亦强名。我疑龙窟宅,人说鬼经营。梯向岩边接,云从洞里生。此行顽健甚,脚力十分轻。
5 仅向量 刘宰·《挽恭靖司法兄九首  其五》 踏遍山崖与水边,要教皇泽下氓编。祇今桐汭民犹活,问讯南昌尉已仙。

4.2.5 怀念逝去的故人

名次 来源 作者·题目 文本节选
1 仅向量 王炎·《追悼昭叔弟》 老去无他念,追怀手足亲。学成虽漫仕,客处只长贫。在昔为三友,如今少一人。青山未归窆,回首独伤神。
2 仅向量 方一夔·《别子声诸友》 祇有去日数,悠悠岁月除。再三来使语,千万故人书。老况难为别,亲情暂以疎。往来愁道路,吾道竟何如。
3 仅向量 寒山·《诗三百三首 二九四》 昔日经行处,今复七十年。故人无来往,埋在古冢间。余今头已白,犹守片云山。为报后来子,何不读古言。
4 仅向量 程俱·《避寇村舍》 再脱兵戈里,全家走路尘。百年同是客,万事不如人。幻境终归尽,生涯正要贫。故人知在否,魂断楚江滨。
5 仅向量 释清远·《寒食礼先师真五首  其四》 去人去矣叮咛嘱,住者相承无断续。若遇知音一和时,乃知去住常充足。

4.2.6 壮志未酬

名次 来源 作者·题目 文本节选
1 仅向量 喻良能·《览镜》 平生壮夫志,老去未消磨。其奈青铜里,萧萧双鬓何。
2 仅向量 释正觉·《颂古一百则  其二八》 壮志棱棱鬓未秋,男儿不愤不封侯。翻思清白传家客,洗耳溪头不饮牛。
3 仅向量 韩淲·《感兴十首  其八》 壮志情知已不多,便鹰扬去盍如何。凄凉两眼西风里,犹尔留连作好歌。
4 仅向量 欧阳修·《苏才翁挽诗二首  其二》 雄心壮志两峥嵘,谁谓中年志不成。零落篇章为世宝,平生风义见交情。青松月下泉台路,白草原头薤露声。自古英豪皆若此,哭君徒有泪沾缨。
5 仅向量 薛能·《下第后春日长安寓居三首 二》 暂屈固何恨,所忧无此时。隔年空仰望,临日又参差。劳力且成病,壮心能不衰。犹将琢磨意,更欲候宗师。

4.2.7 找朋友吃饭

名次 来源 作者·题目 文本节选
1 仅向量 释崇岳·《接待》 茶饭家常没可陪,临机元不在安排。门前有客来相访,试问朝从何处来。
2 仅向量 居遁·《偈颂(幷序○序为南岳齐己撰) 七十八》 饭吃随时食,心穿独自缝。若人来问我,招手报伊聋。
3 仅向量 释昙华·《偈颂六十首  其二○》 若作一句商量,吃粥吃饭阿谁不会。不作一句商量,屎坑里虫子笑杀阇梨。
4 仅向量 释印肃·《颂  其二》 一句当天正信希,欲谈词丧懒投机。高峰既远休寻访,不惜眉毛为发挥。
5 仅向量 释道璨·《偈颂二十五首  其一○》 百不知,百不会,吃饱饭,熟打睡。要得克期,取灯取灯,如是三昧。

4.2.8 人生艰难

名次 来源 作者·题目 文本节选
1 仅向量 释法演·《偈七首  其二》 难难几何般,易易没巴鼻。好好催人老,默默从此得。
2 仅向量 释绍隆·《偈二十七首  其一一》 目前无法,万象森然。意在目前,突出难辨。
3 仅向量 释云岫·《病起》 病起棱层骨数茎,尽情提挈强为生。思量袯襫当年事,道在大雄山上行。
4 仅向量 陆游·《食粥》 世人个个学长年,不悟长年在目前。我得宛丘平易法,只将食粥致神仙。
5 仅向量 释道行·《偈十首  其九》 问问不差,答答不错。问答去来,龟毛兔角。

4.2.9 人到中年没有意义

名次 来源 作者·题目 文本节选
1 仅向量 戴表元·《老态》 老态谁能脱,中年子自如。丹胎三转候,卦气一周时。倦动身如客,平眠力胜医。看书亦渐懒,意到或成诗。
2 仅向量 白居易·《白云期》 三十气太状,胷中多是非。六十身太老,四体不支持。四十至五十,正是退闲时。年长识命分,心慵少营为。见酒兴犹在,登山力未衰。吾年幸当此,且与白云期。
3 仅向量 释师范·《偈颂一百四十一首  其一四一》 九旬今已满,那事竟如何。无为无事人,往往成蹉跎。育王敢道不蹉跎,少年曾决龙蛇阵,老倒还同穉子歌。
4 仅向量 方岳·《元夕  其二》 去年人老已苍华,野蔌山醪办咄嗟。到得今年人又老,也无筋力看梅花。
5 仅向量 释怀深·《拟寒山寺  其一○一》 自料七十岁,可期不可期。况今五六十,形骸日渐衰。正如春暮后,青多红少时。去住呼吸间,佛言真不欺。

4.2.10 少年要努力

名次 来源 作者·题目 文本节选
1 仅向量 冯楫·《临终颂》 初三十一,中九下七。老人言尽,龟哥眼赤。
2 仅向量 释宝印·《偈颂十五首  其一二》 初三十一,中九下七。众眼难瞒,当机莫惜。
3 仅向量 释慧性·《偈颂一百零一首  其六二》 初三十一,中九下七。更不囊藏,当阳拈出。一气转洪钧,八方开寿域。
4 仅向量 韩仪·《记知闻近过关试》 短行轴了付三铨,休把新衔恼必先。今日便称前进士,好留春色与明年。
5 仅向量 姜特立·《送光孙崇元读书》 吾儿毛骨已森然,会见排风上紫烟。但把文章博科第,便成陆地作神仙。胸中滔裕书多读,笔下纵横业要专。卜者相期非止此,后生进学在丁年。

4.2.11 今天天气好啊

名次 来源 作者·题目 文本节选
1 仅向量 释子益·《偈颂七十六首  其一四》 昨日雨,今日晴。桃花开口笑,无处觅灵云。
2 仅向量 释慧开·《颂古四十八首  其三四》 天晴日头出,雨下地上湿。尽情都说了,只恐信不及。
3 仅向量 释守卓·《偈十九首  其三》 久雨不晴今日晴,和泥合水要分明。虚空湿了何妨事,依旧太阳东畔生。
4 仅向量 陈藻·《丙子秋作》 今年时雨即时旸,用向田园每恰当。刈者不愁耕者喜,似曾真宰与商量。
5 仅向量 杨万里·《和李子寿通判曾庆祖判院投赠喜雨口号八首  其三》 雨早些时打麦残,雨迟许日即秧干。阿谁会得天公意,只道今年乞雨难。

4.2.12 多吃点饭

名次 来源 作者·题目 文本节选
1 仅向量 释慧空·《示僧  其四》 一个端坐吃不足,二个同槃则有余。缺齿老胡曾未会,洞传今古在吾徒。
2 仅向量 韩淲·《感风发汗卧病数日推枕翛然叙事为十诗  其八》 病起加餐饭,随宜且菜蔬。何须尊有酒,岂叹食无鱼。绝不关人事,多慙得自如。从他志温饱,我愧腹空虚。
3 仅向量 贾似道·《养法十二条  其四》 喂食还须只一件,莫信傍人教你换。鸡荳菱肉尽非宜,不及朝朝黄米饭。
4 仅向量 居遁·《偈颂(幷序○序为南岳齐己撰) 七十八》 饭吃随时食,心穿独自缝。若人来问我,招手报伊聋。
5 仅向量 释昙华·《偈颂六十首  其二○》 若作一句商量,吃粥吃饭阿谁不会。不作一句商量,屎坑里虫子笑杀阇梨。

4.2.13 策马奔腾

名次 来源 作者·题目 文本节选
1 仅向量 许及之·《逸骥》 一秣志千里,我欲乘腾骧。圉人不在侧,勇气聊颉颃。
2 仅向量 陈深·《题唐圉人调马图》 飞龙天厩隘云稠,一匹骄驰掣电流。枥下髯奴真厮养,眼中元不识骅骝。
3 仅向量 梅尧臣·《伤骥》 驽骥同一辀,迟速能几里。当其被问时,举策数耳耳。驰骋心独存,压抑头不起。空传八骏名,未遇穆天子。
4 仅向量 梅尧臣·《疲马》 疲马不畏鞭,暮途知几千。当须量马力,始得君马全。
5 仅向量 仇远·《马毙三首  其二》 苦无驰骤但加餐,马性由来不耐闲。官满马亡归去好,且骑白犊看青山。

hybrid_search 做了什么(工程侧流程拆解)

本文的重点在于深入理解 hybrid_search 函数:它并非一个简单的“一条 FT.SEARCH 命令就结束”的单点调用,而是一个应用侧(客户端)的处理流程,其核心目的是将两种信号(BM25 文本检索分值与向量距离/相似度)统一到可混合打分的结构里,并在候选结果不足时进行补齐与去重。

5.1 核心流程(对应上面的代码片段)

  • 1 清洗 query_text:去掉在 RediSearch 查询语法中可能造成冲突的字符(如括号、引号等);如果查询词包含空格,则把各个词项用 |(OR 操作符)连接起来。

  • 2 第一轮:混合查询(全文过滤 + KNN)

    
    query_str = (
    f”@text:{sanitized} =>[KNN {top_k} @vector $vec AS vector_score]”
    if sanitized
    else f”*=>[KNN {top_k} @vector $vec AS vector_score]”
    )

q = (
Query(query_str)
.return_fields(“text”, “author”, “title”)
.with_scores()           # 有文本过滤时,doc.score 是 BM25
.sort_by(“vector_score”) # 先按向量距离粗排
.dialect(2)
.language(“Chinese”)     # 关键:查询也要指定中文语言
.paging(0, top_k)
)

hybrid_results = client.ft(index_name).search(
q,
query_params={“vec”: query_vector.tobytes()},
)



*   **3 候选集补齐**:如果第一轮查询返回的候选结果数量小于 `top_k`(例如,关键词完全不匹配),那么就再执行一轮“纯向量 KNN”查询,将缺少的结果补上,并确保不与第一轮结果重复。

*   **4 归一化 + 混合打分**:
    *   BM25 分数(如果存在)进行 min-max 归一化,将其映射到 [0, 1] 区间;
    *   向量距离(越小表示越相似)被转换为一个“越大越好”的相似度归一化分数;
    *   计算最终混合得分:`hybrid_score = text_weight * text_score_norm + vector_weight * vector_score_norm`。

*   **5 排序返回**:将所有候选结果按照 `hybrid_score` 降序排序,返回 TopK。同时,用一个“来源”字段(`source`)来标注每条结果的召回方式:
    *   `关键词+向量`:表示该结果在第一轮混合查询中同时具备 BM25 分值和向量距离(对应代码标记:`both`);
    *   `仅向量`:表示该结果来自第二轮纯向量补齐阶段,或第一轮中没有文本命中(对应代码标记:`vector_match`)。

### 5.2 实践时最容易踩的点:查询也要指定中文语言

在建索引时配置 `language = Chinese` 只解决了一半问题。在实际测试中,要让中文分词在查询时稳定生效,**查询端也必须显式指定语言为中文**。

对应到代码中,就是在 Query 对象上调用 `.language(“Chinese”)` 方法:
*   第一轮的混合查询必须设置;
*   第二轮用于候选集补齐的纯向量查询同样需要设置。

忽略这一步可能导致 `@text:xxx` 这样的中文查询无法正确分词,从而返回异常的命中数量。这是构建稳定高效的中文[混合检索](https://yunpan.plus/f/29-1)(Hybrid Retrieval)服务时一个非常关键的细节。



上一篇:Linux版微信1Click RCE漏洞被披露,双击文件即可远程执行代码
下一篇:腾讯2025年终奖发放观察:部门差异分析与薪酬激励趋势
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-2-23 11:43 , Processed in 0.747220 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表