前言
前四篇文章把整个链路跑通了:从数据清洗到向量化,再到 Redis 建索引,最后是查询服务。本篇我们不做宏观的“优缺点对比”或“结论总结”,而是聚焦于记录两件具体的事情:
- 如何基于 Redis 8 实际跑通“混合召回(Hybrid Retrieval)”的完整流程;
- 项目中关键的
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 的重点是两个方面:
- 使用 TEXT 字段进行全文检索(基于 BM25 算法);
- 设置
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 结构保存,包含 author、title、text 和 vector 字段。
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)服务时一个非常关键的细节。