使用 LLMs 自动过滤

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

LLM 似乎特别擅长这项任务。它们可以理解自然语言并根据其生成结构化输出。在本教程中,我们将向您展示如何使用 LLM 在您的向量搜索应用程序中实现自动化过滤。

关于 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 的结构化输出

使用 LLM 生成结构化输出并不少见。如果它们的输出旨在供其他应用程序进一步处理,则主要有用。例如,您可以使用 LLM 生成 SQL 查询、JSON 对象,以及最重要的是 Qdrant 过滤器。Pydantic 在 LLM 生态系统中得到了很好的采用,因此有很多库使用 Pydantic 模型来定义语言模型的输出结构。

该领域中一个有趣的项目是 Instructor,它允许您使用不同的 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"
        }
    ],
)

这段代码的输出将是一个 Pydantic 模型,表示一个 Qdrant 过滤器。令人惊讶的是,无需传递额外的指令,模型就已经能够确定用户希望按颜色和产品类型进行过滤。输出如下所示:

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 输出

尽管 LLM 功能强大,但它们并不完美。如果您计划自动化过滤,进行一些测试以查看它们的表现是明智的。特别是边缘情况,例如无法表达为过滤器的查询。让我们看看 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 问题。