137 lines
6.3 KiB
Python
137 lines
6.3 KiB
Python
# requires: elasticsearch
|
||
"""Elasticsearch:中文分词、BM25 关键词检索、kNN 向量检索、RRF 混合检索、别名零停机重建索引。
|
||
|
||
运行:ES_URL=http://127.0.0.1:9200 python es_hybrid.py
|
||
本地测试用的单节点 ES 关闭了安全认证;生产环境必须开启认证和 TLS。没有设置环境变量时跳过。
|
||
向量用一个“字符二元组哈希”的玩具嵌入代替真实模型,只为演示流程;索引名带随机后缀,结束后删除。
|
||
"""
|
||
import hashlib
|
||
import math
|
||
import os
|
||
import sys
|
||
import uuid
|
||
|
||
from elasticsearch import Elasticsearch, helpers
|
||
|
||
url = os.environ.get("ES_URL")
|
||
if not url:
|
||
print("未设置 ES_URL,跳过")
|
||
sys.exit(0)
|
||
|
||
es = Elasticsearch(url, request_timeout=30)
|
||
suffix = uuid.uuid4().hex[:6]
|
||
alias = f"kb-{suffix}"
|
||
DIM = 64
|
||
|
||
DOCS = [
|
||
{"id": "d1", "tenant": "t1", "title": "退货政策", "body": "签收后七天内可以申请无理由退货,商品需保持完好。"},
|
||
{"id": "d2", "tenant": "t1", "title": "退款到账时间", "body": "退款审核通过后,原路退回,一般一到三个工作日到账。"},
|
||
{"id": "d3", "tenant": "t1", "title": "发票开具", "body": "订单完成后可以在订单详情页申请电子发票。"},
|
||
{"id": "d4", "tenant": "t1", "title": "会员积分", "body": "每消费一元累计一个积分,积分可以抵扣运费。"},
|
||
{"id": "d5", "tenant": "t2", "title": "退货政策(其他租户)", "body": "本店商品不支持无理由退货。"},
|
||
{"id": "d6", "tenant": "t1", "title": "错误码 E1024", "body": "E1024 表示支付渠道超时,请稍后重试或更换支付方式。"},
|
||
]
|
||
|
||
|
||
def embed(text: str) -> list[float]:
|
||
"""玩具嵌入:字符二元组哈希到 64 维再归一化。真实项目换成嵌入模型。"""
|
||
v = [0.0] * DIM
|
||
for a, b in zip(text, text[1:]):
|
||
h = int(hashlib.md5((a + b).encode()).hexdigest(), 16)
|
||
v[h % DIM] += 1.0
|
||
n = math.sqrt(sum(x * x for x in v)) or 1.0
|
||
return [x / n for x in v]
|
||
|
||
|
||
def create_index(name: str) -> None:
|
||
es.indices.create(index=name, mappings={
|
||
"properties": {
|
||
"tenant": {"type": "keyword"}, # 过滤用,不分词
|
||
"title": {"type": "text", "analyzer": "cjk"}, # 内置 cjk 分析器(中文二元切分);生产常用 IK 插件,以插件文档为准
|
||
"body": {"type": "text", "analyzer": "cjk"},
|
||
"vec": {"type": "dense_vector", "dims": DIM, "index": True, "similarity": "cosine"},
|
||
}
|
||
})
|
||
|
||
|
||
def load(index: str) -> None:
|
||
# 用业务 id 作为 _id:重复同步是覆盖而不是新增(幂等)
|
||
actions = ({"_index": index, "_id": d["id"], "_source": {**{k: v for k, v in d.items() if k != "id"}, "vec": embed(d["title"] + d["body"])}} for d in DOCS)
|
||
ok, errors = helpers.bulk(es, actions, refresh="wait_for")
|
||
assert ok == len(DOCS) and not errors
|
||
|
||
|
||
def bm25(q: str, tenant: str, k: int = 3) -> list[str]:
|
||
r = es.search(index=alias, size=k, query={"bool": {
|
||
"must": {"multi_match": {"query": q, "fields": ["title^2", "body"]}}, # 标题权重更高
|
||
"filter": {"term": {"tenant": tenant}}, # 租户过滤放 filter:不算分、可缓存
|
||
}})
|
||
return [h["_id"] for h in r["hits"]["hits"]]
|
||
|
||
|
||
def knn(q: str, tenant: str, k: int = 3) -> list[str]:
|
||
r = es.search(index=alias, size=k, knn={
|
||
"field": "vec", "query_vector": embed(q), "k": k, "num_candidates": 50,
|
||
"filter": {"term": {"tenant": tenant}}, # 过滤在向量检索过程中生效,而不是检索后再过滤
|
||
})
|
||
return [h["_id"] for h in r["hits"]["hits"]]
|
||
|
||
|
||
def rrf(*rankings: list[str], k: int = 60) -> list[str]:
|
||
"""倒数排名融合:只看名次,不看分数(BM25 分数和向量相似度不可直接比较)。"""
|
||
score: dict[str, float] = {}
|
||
for ranking in rankings:
|
||
for rank, doc_id in enumerate(ranking, start=1):
|
||
score[doc_id] = score.get(doc_id, 0.0) + 1.0 / (k + rank)
|
||
return sorted(score, key=lambda d: (-score[d], d))
|
||
|
||
|
||
try:
|
||
v1 = f"{alias}-v1"
|
||
create_index(v1)
|
||
load(v1)
|
||
es.indices.put_alias(index=v1, name=alias) # 业务只访问别名
|
||
|
||
exact = bm25("E1024", "t1")
|
||
assert exact[0] == "d6", exact
|
||
print(f"1. 关键词检索擅长精确词(错误码):E1024 → {exact}")
|
||
|
||
kw = bm25("退货", "t1")
|
||
assert "d5" not in kw and kw[0] == "d1", kw
|
||
print(f"2. 租户过滤生效:t1 搜“退货” → {kw}(没有 t2 的 d5)")
|
||
|
||
sem = knn("商品不想要了怎么退", "t1")
|
||
print(f"3. 向量检索(玩具嵌入):{sem}")
|
||
assert "d5" not in sem
|
||
|
||
fused = rrf(bm25("退货 退款多久到账", "t1", 5), knn("退货 退款多久到账", "t1", 5))
|
||
assert set(fused[:2]) == {"d1", "d2"}, fused
|
||
print(f"4. RRF 混合检索:{fused[:3]}")
|
||
|
||
# 服务端 RRF(retriever 语法)。许可证和可用版本以官方文档为准,不可用时退回客户端融合
|
||
try:
|
||
r = es.search(index=alias, size=3, retriever={"rrf": {"retrievers": [
|
||
{"standard": {"query": {"bool": {"must": {"match": {"body": "退款"}}, "filter": {"term": {"tenant": "t1"}}}}}},
|
||
{"knn": {"field": "vec", "query_vector": embed("退款到账"), "k": 3, "num_candidates": 50, "filter": {"term": {"tenant": "t1"}}}},
|
||
]}})
|
||
print(f"5. 服务端 RRF:{[h['_id'] for h in r['hits']['hits']]}")
|
||
except Exception as e: # noqa: BLE001 —— 例如 basic 许可下返回 403 授权错误
|
||
print(f"5. 服务端 RRF 不可用({type(e).__name__}: {str(e)[:80]}),使用客户端 RRF")
|
||
|
||
# 别名切换:新建 v2(例如修改了映射),灌数据,原子切换别名,业务无感知
|
||
v2 = f"{alias}-v2"
|
||
create_index(v2)
|
||
load(v2)
|
||
es.indices.update_aliases(actions=[
|
||
{"remove": {"index": v1, "alias": alias}},
|
||
{"add": {"index": v2, "alias": alias}},
|
||
])
|
||
assert list(es.indices.get_alias(name=alias).keys()) == [v2]
|
||
assert bm25("E1024", "t1")[0] == "d6"
|
||
print("6. 别名原子切换到 v2,查询不中断")
|
||
print("全部检查通过")
|
||
finally:
|
||
# 生产集群通常禁止通配符删除(action.destructive_requires_name),这里逐个写明索引名
|
||
es.indices.delete(index=[f"{alias}-v1", f"{alias}-v2"], ignore_unavailable=True)
|
||
es.close()
|