使用LLMs自动化过滤

我们的向量搜索中的过滤完整指南描述了为什么过滤是重要的,以及如何使用Qdrant实现它。然而,当您使用传统界面构建应用程序时,应用过滤更容易。您的用户界面可能包含一个带有复选框、滑块和其他元素的表单,用户可以使用这些元素来设置他们的标准。但是,如果您想构建一个仅具有对话界面甚至语音命令的RAG驱动的应用程序呢?在这种情况下,您需要自动化过滤过程!

LLMs 似乎特别擅长这项任务。它们能够理解自然语言并基于此生成结构化输出。在本教程中,我们将向您展示如何使用 LLMs 在您的向量搜索应用程序中自动化过滤。

关于Qdrant过滤器的几点说明

Qdrant Python SDK 使用 Pydantic 定义模型。这个库是 Python 中数据验证和序列化的实际标准。它允许你使用 Python 类型提示来定义数据的结构。例如,我们的 Filter 模型定义如下:

class Filter(BaseModel, extra="forbid"):
    should: Optional[Union[List["Condition"], "Condition"]] = Field(
        default=None, description="At least one of those conditions should match"
    )
    min_should: Optional["MinShould"] = Field(
        default=None, description="At least minimum amount of given conditions should match"
    )
    must: Optional[Union[List["Condition"], "Condition"]] = Field(default=None, description="All conditions must match")
    must_not: Optional[Union[List["Condition"], "Condition"]] = Field(
        default=None, description="All conditions must NOT match"
    )

Qdrant过滤器可以嵌套,您可以使用mustshouldmust_not表示法来表达最复杂的条件。

来自LLM的结构化输出

使用LLMs生成结构化输出并不是一种不常见的做法。如果它们的输出旨在由不同的应用程序进一步处理,这尤其有用。例如,您可以使用LLMs生成SQL查询、JSON对象,最重要的是,Qdrant过滤器。Pydantic被LLM生态系统很好地采用,因此有许多库使用Pydantic模型来定义语言模型的输出结构。

在这个领域中一个有趣的项目是讲师,它允许你与不同的LLM提供商进行互动,并将它们的输出限制为特定的结构。让我们安装这个库,并选择我们将在本教程中使用的提供商:

pip install "instructor[anthropic]"

Anthropic 并不是唯一的选择,因为 Instructor 支持许多其他提供商,包括 OpenAI、Ollama、Llama、Gemini、Vertex AI、Groq、Litellm 等。您可以选择最适合您需求的,或者您已经在 RAG 中使用的那个。

使用Instructor生成Qdrant过滤器

Instructor 有一些辅助方法来装饰 LLM API,因此您可以像使用它们的普通 SDK 一样与它们交互。对于 Anthropic,您只需将 Anthropic 类的实例传递给 from_anthropic 函数:

import instructor
from anthropic import Anthropic

anthropic_client = instructor.from_anthropic(
    client=Anthropic(
        api_key="YOUR_API_KEY",
    )
)

一个装饰过的客户端稍微修改了原始API,因此你可以将response_model参数传递给.messages.create方法。这个参数应该是一个定义输出结构的Pydantic模型。在Qdrant过滤器的情况下,它应该是一个Filter模型:

from qdrant_client import models

qdrant_filter = anthropic_client.messages.create(
    model="claude-3-5-sonnet-latest",
    response_model=models.Filter,
    max_tokens=1024,
    messages=[
        {
            "role": "user",
            "content": "red T-shirt"
        }
    ],
)

这段代码的输出将是一个表示Qdrant过滤器的Pydantic模型。令人惊讶的是,不需要传递额外的指令就能推断出用户希望通过产品的颜色和类型进行过滤。以下是输出的样子:

Filter(
    should=None, 
    min_should=None, 
    must=[
        FieldCondition(
            key="color", 
            match=MatchValue(value="red"), 
            range=None, 
            geo_bounding_box=None, 
            geo_radius=None, 
            geo_polygon=None, 
            values_count=None
        ), 
        FieldCondition(
            key="type", 
            match=MatchValue(value="t-shirt"), 
            range=None, 
            geo_bounding_box=None, 
            geo_radius=None, 
            geo_polygon=None, 
            values_count=None
        )
    ], 
    must_not=None
)

显然,让模型完全自由地生成过滤器可能会导致意外的结果,或者根本没有结果。您的集合可能具有特定结构的有效载荷,因此使用其他任何东西都没有意义。此外,通过已索引的字段进行过滤被认为是一种良好的做法。这就是为什么自动确定索引字段并将输出限制在这些字段上是有意义的。

限制可用字段

Qdrant 集合信息包含在特定集合上创建的索引列表。您可以使用此信息自动确定可用于过滤的字段。以下是您可以如何操作的方法:

from qdrant_client import QdrantClient

client = QdrantClient("http://localhost:6333")
collection_info = client.get_collection(collection_name="test_filter")
indexes = collection_info.payload_schema
print(indexes)

输出:

{
    "city.location": PayloadIndexInfo(
        data_type=PayloadSchemaType.GEO,
        ...
    ),
    "city.name": PayloadIndexInfo(
        data_type=PayloadSchemaType.KEYWORD,
        ...
    ),
    "color": PayloadIndexInfo(
        data_type=PayloadSchemaType.KEYWORD,
        ...
    ),
    "fabric": PayloadIndexInfo(
        data_type=PayloadSchemaType.KEYWORD,
        ...
    ),
    "price": PayloadIndexInfo(
        data_type=PayloadSchemaType.FLOAT,
        ...
    ),
}

我们的LLM应该知道它可以使用的字段名称,以及它们的类型,因为例如,范围过滤仅对数值字段有意义,而在非地理字段上进行地理过滤不会产生任何有意义的结果。您可以将此信息作为提示的一部分传递给LLM,因此让我们将其编码为字符串:

formatted_indexes = "\n".join([
    f"- {index_name} - {index.data_type.name}"
    for index_name, index in indexes.items()
])
print(formatted_indexes)

输出:

- fabric - KEYWORD
- city.name - KEYWORD
- color - KEYWORD
- price - FLOAT
- city.location - GEO

缓存可用字段及其类型的列表是一个好主意,因为它们不应该经常更改。我们现在与LLM的交互应该略有不同:

qdrant_filter = anthropic_client.messages.create(
    model="claude-3-5-sonnet-latest",
    response_model=models.Filter,
    max_tokens=1024,
    messages=[
        {
            "role": "user",
            "content": (
                "<query>color is red</query>"
                f"<indexes>\n{formatted_indexes}\n</indexes>"
            )
        }
    ],
)

输出:

Filter(
    should=None, 
    min_should=None, 
    must=FieldCondition(
        key="color", 
        match=MatchValue(value="red"), 
        range=None, 
        geo_bounding_box=None, 
        geo_radius=None, 
        geo_polygon=None, 
        values_count=None
    ), 
    must_not=None
)

相同的查询,限制在可用字段内,现在生成了更好的条件,因为它不会尝试通过集合中不存在的字段进行过滤。

测试LLM输出

尽管LLMs非常强大,但它们并不完美。如果你计划自动化过滤,进行一些测试以了解它们的表现是有意义的。特别是边缘情况,比如无法表达为过滤器的查询。让我们看看LLM将如何处理以下查询:

qdrant_filter = anthropic_client.messages.create(
    model="claude-3-5-sonnet-latest",
    response_model=models.Filter,
    max_tokens=1024,
    messages=[
        {
            "role": "user",
            "content": (
                "<query>fruit salad with no more than 100 calories</query>"
                f"<indexes>\n{formatted_indexes}\n</indexes>"
            )
        }
    ],
)

输出:

Filter(
    should=None, 
    min_should=None, 
    must=FieldCondition(
        key="price", 
        match=None, 
        range=Range(lt=None, gt=None, gte=None, lte=100.0), 
        geo_bounding_box=None, 
        geo_radius=None, 
        geo_polygon=None, 
        values_count=None
    ), 
    must_not=None
)

令人惊讶的是,LLM从查询中提取了卡路里信息,并根据价格字段生成了一个过滤器。 它从查询中提取了任何数字信息,并尝试将其与可用字段匹配。

通常,给模型一些关于如何解释查询的更多指导可能会带来更好的结果。添加一个定义查询解释规则的系统提示可能有助于模型更好地完成任务。以下是你可以如何做到这一点:

SYSTEM_PROMPT = """
You are extracting filters from a text query. Please follow the following rules:
1. Query is provided in the form of a text enclosed in <query> tags.
2. Available indexes are put at the end of the text in the form of a list enclosed in <indexes> tags.
3. You cannot use any field that is not available in the indexes.
4. Generate a filter only if you are certain that user's intent matches the field name.
5. Prices are always in USD.
6. It's better not to generate a filter than to generate an incorrect one.
"""

qdrant_filter = anthropic_client.messages.create(
    model="claude-3-5-sonnet-latest",
    response_model=models.Filter,
    max_tokens=1024,
    messages=[
        {
            "role": "user",
            "content": SYSTEM_PROMPT.strip(),
        },
        {
            "role": "assistant",
            "content": "Okay, I will follow all the rules."
        },
        {
            "role": "user",
            "content": (
                "<query>fruit salad with no more than 100 calories</query>"
                f"<indexes>\n{formatted_indexes}\n</indexes>"
            )
        }
    ],
)

当前输出:

Filter(
    should=None, 
    min_should=None, 
    must=None, 
    must_not=None
)

处理复杂查询

我们在集合上创建了一堆索引,看看LLM如何处理更复杂的查询是相当有趣的。例如,让我们看看它将如何处理以下查询:

qdrant_filter = anthropic_client.messages.create(
    model="claude-3-5-sonnet-latest",
    response_model=models.Filter,
    max_tokens=1024,
    messages=[
        {
            "role": "user",
            "content": SYSTEM_PROMPT.strip(),
        },
        {
            "role": "assistant",
            "content": "Okay, I will follow all the rules."
        },
        {
            "role": "user",
            "content": (
                "<query>"
                "white T-shirt available no more than 30 miles from London, "
                "but not in the city itself, below $15.70, not made from polyester"
                "</query>\n"
                "<indexes>\n"
                f"{formatted_indexes}\n"
                "</indexes>"
            )
        },
    ],
)

可能令人惊讶的是,Anthropic Claude 甚至能够生成如此复杂的过滤器。以下是输出:

Filter(
    should=None, 
    min_should=None, 
    must=[
        FieldCondition(
            key="color", 
            match=MatchValue(value="white"), 
            range=None, 
            geo_bounding_box=None, 
            geo_radius=None, 
            geo_polygon=None, 
            values_count=None
        ), 
        FieldCondition(
            key="city.location", 
            match=None, 
            range=None, 
            geo_bounding_box=None, 
            geo_radius=GeoRadius(
                center=GeoPoint(lon=-0.1276, lat=51.5074), 
                radius=48280.0
            ), 
            geo_polygon=None, 
            values_count=None
        ), 
        FieldCondition(
            key="price", 
            match=None, 
            range=Range(lt=15.7, gt=None, gte=None, lte=None), 
            geo_bounding_box=None,
            geo_radius=None, 
            geo_polygon=None, 
            values_count=None
        )
    ], must_not=[
        FieldCondition(
            key="city.name", 
            match=MatchValue(value="London"), 
            range=None, 
            geo_bounding_box=None, 
            geo_radius=None, 
            geo_polygon=None, 
            values_count=None
        ), 
        FieldCondition(
            key="fabric", 
            match=MatchValue(value="polyester"),
            range=None, 
            geo_bounding_box=None, 
            geo_radius=None,
            geo_polygon=None, 
            values_count=None
        )
    ]
)

模型甚至知道伦敦的坐标,并使用它们生成地理过滤器。依赖模型生成如此复杂的过滤器并不是最好的主意,但它能做到这一点确实令人印象深刻。

进一步步骤

实际生产系统可能需要对LLM输出进行更多的测试和验证。构建一个包含查询和预期过滤器的真实数据集将是一个好主意。您可以使用此数据集来评估模型性能,并查看其在不同场景中的表现。

这个页面有用吗?

感谢您的反馈!🙏

我们很抱歉听到这个消息。😔 你可以在GitHub上编辑这个页面,或者创建一个GitHub问题。