Files

137 lines
6.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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()