实战:一个带分页和过滤的检索服务

🎉摘要:基于Chroma封装Searcher类,复用AliQwenEmbeddingFunction,实现指定集合的分页查询、相似度查询及返回所有符合条件数据,含完整代码与示例。

通过前面的学习,我们已经学会了如何从 Chroma 查询数据、分页查询、以及进行相识度查询。下面将结合前面学习到的知识,封装一个 Searcher 类,实现对指定集合进行分页查询和相识度查询,以及返回集合所有符合条件数据的工具方法。

注意,嵌入方法还是复用前面自定义的 AliQwenEmbeddingFunction。

代码如下:

import chromadb

from dataclasses import dataclass
from typing import Optional, Sequence
from common.custom_embedding import AliQwenEmbeddingFunction


# 使用 @dataclass 定义数据类
# 用来封装向量库检索单条结果(SearchHit,检索命中项)
@dataclass
class SearchHit:
    id: str
    document: Optional[str]
    metadata: Optional[dict]
    distance: Optional[float]
    similarity: Optional[float] = None


# 自己封装的检索类
class Searcher:
    def __init__(self, cli, collection_name: str, embedding_function=None):
        """构造方法
        :param cli Chroma数据库客户端实例
        :param collection_name 集合名称
        :param embedding_function 嵌入函数
        """
        self.col = cli.get_collection(
            collection_name, embedding_function=embedding_function
        )
        # 获取集合的 hnsw 配置,用来判断如何进行距离转换为相似度
        self._space = (self.col.configuration.get("hnsw") or {}).get("space", "l2")

    def _to_similarity(self, distance: float) -> Optional[float]:
        """将距离转换为相似度"""
        if distance is None:
            return None
        if self._space == "cosine":
            # 余弦距离
            return 1.0 - distance
        elif self._space == "l2":
            # 平方欧式距离
            return 1.0 / (1.0 + distance)
        return None

    def search(
        self,
        query: str | Sequence[float],
        top_k: int = 10,
        where: Optional[dict] = None,
        where_document: Optional[dict] = None,
        ids: Optional[list[str]] = None,
    ) -> list[SearchHit]:
        """检索方法,先过滤,在进行相似度检索
        :param query 语义检索,可以是文本,也可以是向量数组
        :param top_k 返回前N个记录
        :param where 过滤条件,根据 metadatas 过滤
        :param where_document 过滤条件,根据文档内容过滤
        :param ids 过滤条件,根据ID进行过滤
        """
        if isinstance(query, str):
            # 如果是字符串,则由 embedding_function 帮我们计算向量
            kwargs = {"query_texts": [query]}
        else:
            # 向量检索,不需要计算向量
            kwargs = {"query_embeddings": [list(query)]}

        res = self.col.query(
            **kwargs,
            n_results=top_k,
            where=where,
            where_document=where_document,
            ids=ids,
        )

        # 使用 zip 将列返回进行打包成行数据,每行包含对应的 ids、documents、metadatas、distances
        hits = []
        for id_, doc, meta, dist in zip(
            res["ids"][0],
            res["documents"][0],
            res["metadatas"][0],
            res["distances"][0],
        ):
            hits.append(SearchHit(
                id=id_,
                document=doc,
                metadata=meta,
                distance=dist,
                similarity=self._to_similarity(dist),
            ))
        return hits

    def page(
        self,
        where: Optional[dict] = None,
        limit: int = 50,
        offset: int = 0,
    ) -> list[SearchHit]:
        """分页查询"""
        res = self.col.get(
            where=where,
            limit=limit,
            offset=offset,
            include=["documents", "metadatas"],
        )
        return [
            SearchHit(id=i, document=d, metadata=m, distance=None)
            for i, d, m in zip(res["ids"], res["documents"], res["metadatas"])
        ]

    def iter_all(self, batch_size: int = 500, where=None):
        """分页扫全量,写导出/迁移脚本时很好用"""
        offset = 0
        while True:
            batch = self.page(where=where, limit=batch_size, offset=offset)
            if not batch:
                break
            yield from batch
            if len(batch) < batch_size:
                break
            offset += batch_size


if __name__ == "__main__":
    # 自定义嵌入函数实现
    ef = AliQwenEmbeddingFunction()

    client = chromadb.EphemeralClient()
    col = client.create_collection(
        "faq", embedding_function=ef, configuration={"hnsw": {"space": "cosine"}}
    )

    # 灌注数据
    col.add(
        ids=["r1", "r2", "r3", "r4", "r5"],
        documents=[
            "退款流程:在订单页点申请退款,3 个工作日内到账",
            "发票开具:在订单详情页申请电子发票",
            "物流查询:发货后可在物流详情里看到进度",
            "退货政策:7 天无理由退货,运费买家承担",
            "账号安全:如何修改密码和绑定手机",
        ],
        metadatas=[
            {"cat": "售后", "year": 2024, "score": 0.9, "tags": ["退款"]},
            {"cat": "财务", "year": 2023, "score": 0.5, "tags": ["发票"]},
            {"cat": "物流", "year": 2024, "score": 0.7, "tags": ["物流"]},
            {"cat": "售后", "year": 2022, "score": 0.3, "tags": ["退货"]},
            {"cat": "账号", "year": 2024, "score": 0.6, "tags": ["安全"]},
        ],
    )

    # 相识度查询验证
    print("--- 相识度查询 ---")
    s = Searcher(client, collection_name="faq", embedding_function=ef)
    for hit in s.search("不要了", top_k=2, where={"cat": "售后"}):
        print(f"  id={hit.id}, 相似度={hit.similarity}, 距离={hit.distance}, document={hit.document}")

    # 演示 page 和 iter_all 函数的用法
    # page:按页取,用 limit / offset 控制,适合做前端列表翻页
    print("\n--- page:第 1 页(limit=2, offset=0)---")
    for hit in s.page(limit=2, offset=0):
        print(f"  id={hit.id}, cat={hit.metadata.get('cat')}, document={hit.document}")

    print("\n--- page:第 2 页(limit=2, offset=2)---")
    for hit in s.page(limit=2, offset=2):
        print(f"  id={hit.id}, cat={hit.metadata.get('cat')}, document={hit.document}")

    # page 也支持 where 过滤:只翻“售后”这一类
    print("\n--- page:只翻 cat=售后(limit=10)---")
    for hit in s.page(where={"cat": "售后"}, limit=10):
        print(f"  id={hit.id}, cat={hit.metadata.get('cat')}, document={hit.document}")

    # iter_all:不用管页码,一次把所有命中项吐出来,写导出 / 迁移脚本很方便
    print("\n--- iter_all:遍历全部(batch_size=2,方便看分批拉取效果)---")
    all_hits = list(s.iter_all(batch_size=2))
    print(f"  共 {len(all_hits)} 条:")
    for hit in all_hits:
        print(f"    id={hit.id}, cat={hit.metadata.get('cat')}, document={hit.document}")

运行示例,输出如下:

--- 相识度查询 ---
  id=r4, 相似度=0.3523508906364441, 距离=0.6476491093635559, document=退货政策:7 天无理由退货,运费买家承担
  id=r1, 相似度=0.28433358669281006, 距离=0.7156664133071899, document=退款流程:在订单页点申请退款,3 个工作日内到账

--- page:第 1 页(limit=2, offset=0)---
  id=r1, cat=售后, document=退款流程:在订单页点申请退款,3 个工作日内到账
  id=r2, cat=财务, document=发票开具:在订单详情页申请电子发票

--- page:第 2 页(limit=2, offset=2)---
  id=r3, cat=物流, document=物流查询:发货后可在物流详情里看到进度
  id=r4, cat=售后, document=退货政策:7 天无理由退货,运费买家承担

--- page:只翻 cat=售后(limit=10)---
  id=r1, cat=售后, document=退款流程:在订单页点申请退款,3 个工作日内到账
  id=r4, cat=售后, document=退货政策:7 天无理由退货,运费买家承担

--- iter_all:遍历全部(batch_size=2,方便看分批拉取效果)---
  共 5 条:
    id=r1, cat=售后, document=退款流程:在订单页点申请退款,3 个工作日内到账
    id=r2, cat=财务, document=发票开具:在订单详情页申请电子发票
    id=r3, cat=物流, document=物流查询:发货后可在物流详情里看到进度
    id=r4, cat=售后, document=退货政策:7 天无理由退货,运费买家承担
    id=r5, cat=账号, document=账号安全:如何修改密码和绑定手机

注意,在其他文件中,可以通过 from...import 导入上面的类,然后使用,避免每次都编写重复的模板代码。

说说我的看法
全部评论()
没有评论
关于
本网站专注于 Java、数据库(MySQL、Oracle)、Linux、软件架构及大数据等多领域技术知识分享。涵盖丰富的原创与精选技术文章,助力技术传播与交流。无论是技术新手渴望入门,还是资深开发者寻求进阶,这里都能为您提供深度见解与实用经验,让复杂编码变得轻松易懂,携手共赴技术提升新高度。如有侵权,请来信告知:hxstrive@outlook.com
其他应用
公众号