AI 智能体与应用——查询生成、路由与检索后处理

23 阅读49分钟

本章涵盖以下内容:

  • 直接从用户问题中生成元数据查询
  • 将用户问题转换成特定数据库的查询语句(例如 SQL、SPARQL)
  • 基于意图将问题路由到合适的处理器
  • 使用 Reciprocal Rank Fusion 提升结果相关性

在第 8 章和第 9 章中,你已经通过高级索引技术和查询转换技术提升了 Retrieval-Augmented Generation(RAG) 的回答准确率。优化索引能够增强 embedding 对较大 chunk 的表示效果,从而引入更丰富的上下文;而查询转换则提升了向量存储检索的精确度。

现在,我们将继续深入三种更高级的 RAG 技术。首先,你会学习如何根据内容存储类型生成对应的查询。例如,你会看到如何从用户的自然语言问题中生成 SQL,从关系型数据库中提取数据。你的系统可能包含多种内容存储,例如向量存储、关系型数据库,甚至知识图谱数据库。你将借助大语言模型(LLM)把用户问题引导到正确的内容存储。最后,你还会学习如何对检索结果做进一步优化,只把最相关的内容送去做综合生成,过滤掉无关数据,以保持结果的清晰性和相关性。

10.1 内容数据库查询生成

为了让 LLM 在回答用户问题时获得尽可能优质的信息,你往往需要访问的不只是向量存储。很多数据库中存放的是结构化数据,而且只接受结构化查询。你可能会担心:用户提出的是非结构化自然语言问题,而这些数据库需要的是结构化查询语句,这两者之间是否存在一道鸿沟?这道鸿沟恰恰可以借助 LLM 来弥合。下面我们来看一看在 LLM 应用中常见的几类内容存储,以及从中检索数据的典型方式:

向量存储(或向量数据库) —— 你已经很熟悉向量存储了。它保存的是文档 chunk 及其 embedding,并使用向量索引来支持语义检索。这种方式通常称为 dense retrieval(稠密检索) ,因为它依赖 embedding——也就是那些由几百维到几千维构成、能够捕捉文本语义的紧凑向量。用户查询与已存 chunk 之间的相似度,正是通过这些稠密向量之间的距离来计算的。

与之相对的另一种方式是 sparse retrieval(稀疏检索) ,也常被称为 lexical retrieval(词法检索) 。许多向量数据库同样支持它。它基于词级别的相似性。在索引阶段,每个文档 chunk 会先被分词,然后构建一个倒排索引,把每一个唯一 token 映射到包含它的 chunk 列表上。要注意,这里的分词与 LLM 所使用的 tokenization 并不相同,因为这里的分词是为搜索与检索优化的,而不是为语言建模服务的。在查询阶段,用户的问题也会用同样方式分词,然后每个 token 都会去匹配倒排索引,从而找出相关 chunk。接着,再通过 BM25 或 Term Frequency-Inverse Document Frequency(TF-IDF)等相关性打分方法,根据每个 chunk 的词项统计信息与查询的匹配程度进行排序。稀疏检索特别擅长精确、关键词驱动型查询,支持布尔逻辑(例如 “must” 与 “must not”),而且具有很强的可解释性,因为它能直接把返回结果和查询中的匹配词关联起来。

关系型(SQL)数据库 —— 一个 LLM 应用也可以连接到关系型数据库中,这类数据库以表的形式存储结构化事实。数据通常通过 SQL 查询来提取。例如,一个数据库中可以保存旅游度假地的季节温度,或者某地可用酒店和租车服务的列表。LLM 可以通过把自然语言问题转换成 SQL 查询来提供帮助,这一技术通常被称为 text-to-SQL。它正在迅速流行起来,因为它为非技术用户打开了直接访问数据库的大门。

文档型、键值型或对象型数据库 —— 这些数据库通常以文档或对象的形式保存数据,一般采用 JSON 或 Binary JSON(BSON)格式。由于许多 LLM 都在大量 JSON 结构上接受过训练,因此它们通常能够相当准确地把用户问题转换成符合数据库 schema 的 JSON 查询。值得一提的是,许多文档数据库近年来由于加入了对向量字段的支持——也就是可用于保存 embedding 的字段,并加入了向量检索与相似度搜索能力——因此也开始重新定位为向量数据库。

知识图谱数据库 —— 知识图谱以图结构表示数据,其中节点表示实体,边表示实体之间的关系。最初,图数据库是在 Facebook、LinkedIn 这类公司中流行起来的,用于推断社交网络中的连接关系;如今,它们也越来越多地被用于 LLM 应用中。LLM 可以把非结构化文本转换成结构化知识图谱——这类过程常被称为 knowledge graph–enhanced RAG——与向量存储相比,它能形成一种更紧凑、更结构化的数据表示。一旦数据被存入图数据库,就可以通过 SPARQL 或 Cypher 这类图查询语言进行查询,从而支持比简单相似度检索更复杂的推理与推断。这就是 GraphRAG 的基础。后面我们还会讨论:LLM 如何帮助我们从原始文本构建这些图结构,如何生成 SPARQL 或 Cypher 查询,以及如何把查询结果重新转换成自然语言回答。

现在,让我们来看一看:用户的问题是如何被转换成不同类型数据库可执行的结构化查询的。我们将先从向量存储开始,看看如何利用元数据来检索文档 chunk。

10.2 Self-querying(元数据查询增强)

向量存储通常会为文档 chunk 建立 embedding 索引,以支持 dense search,但它同样也可以使用基于关键词的索引。常见的实现方式包括:

  • 显式元数据标签 —— 你可以给每个 chunk 附加元数据,例如时间戳、文件名或 URL、主题和关键词。这些关键词既可以来自用户输入,也可以是你手工指定的。
  • 通过算法抽取关键词 —— 你可以使用 TF-IDF 或其扩展 BM25 等算法,根据词频和重要性为每个 chunk 提取相关关键词。
  • 让 LLM 生成关键词建议 —— 你也可以在摄取阶段要求 LLM 为每个 chunk 生成关键词标签。

一旦你通过上述任意一种方式为 chunk 附加了关键词,你就可以在向量检索之前先做一层基于关键词的过滤,也就是先通过 metadata search 或 sparse search 缩小范围,再在这部分数据上执行语义检索。如果你的 chatbot UI 允许用户直接指定筛选条件——比如通过下拉框选择地区或类别——那么你的向量存储查询就可以显式地包含一个基于这些选择的元数据过滤器。不过更常见的情况是:你希望系统能自动从用户问题中推断出相关元数据,从而自动生成过滤条件。这种技术通常被称为 self-queryingself-metadata querying。它可以让系统根据用户问题自动构造一个带有元数据过滤条件的增强查询。

在 self-querying 流程中,用户的原始问题会被转换成一个增强查询,其中同时包含一个元数据过滤器和一个语义检索部分,从而实现 dense 与 sparse 的组合搜索,如图 10.1 所示。

image.png

图 10.1 Self-querying 工作流。原始问题会被转换成一个带嵌入式元数据过滤器的语义查询。当这个增强查询在向量存储上执行时,系统会先挑选出符合元数据过滤条件的 chunk,然后再在这个缩小后的集合上执行语义检索。

现在你已经理解了 self-metadata querying 的基本原理,接下来我们来具体实现它。我们会先从摄取阶段开始,在这一阶段,你要为每个 chunk 打上相关的元数据关键词。然后,在 Q&A 阶段,我会展示两种生成 self-metadata query 的方法:一种使用内置的 SelfQueryRetriever,另一种使用 LLM function calling。

10.2.1 摄取阶段:元数据增强

为了有效使用元数据,第一步是把英国旅游目的地的数据重新导入一个新的 collection,这次你要为每个 chunk 一并保存 metadata。下面几个小节将展示整个环境的设置方式。

初始化环境

打开一个新的操作系统终端,进入第 10 章代码目录,激活虚拟环境,安装所需包,并创建一个新的 Jupyter Notebook:

C:\Github\building-llm-applications\ch10>
C:\Github\building-llm-applications\ch10>python -m venv env_ch10
c:\GitHub\building-llm-applications\ch10>env_ch10\Scripts\activate
(env_ch10) C:\Github\building-llm-applications\ch10>
↪pip install -r requirements.txt
(env_ch10) C:\Github\building-llm-applications\ch10>jupyter notebook

然后在 Jupyter Notebook 中,选择 File > New > Notebook,并将文件保存为 10-query_generation.ipynb

定义元数据

你需要先确定要为每个 chunk 打上哪些关键词标签,例如:

  • source —— 原始内容的 URL
  • destination —— 该 chunk 所指向的旅游目的地
  • region —— 该目的地所属的英国地区

其中,destinationregion 可以通过手工定义映射关系来指定,而 source URL 则可以动态生成。

设置 ChromaDB collection

使用下面的代码来配置 ChromaDB collection。

代码清单 10.1 设置 ChromaDB collection

from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings
import getpass

OPENAI_API_KEY = getpass.getpass('Enter your OPENAI_API_KEY')

uk_with_metadata_collection = Chroma(
    collection_name="uk_with_metadata_collection",
    embedding_function=OpenAIEmbeddings(openai_api_key=OPENAI_API_KEY))

uk_with_metadata_collection.reset_collection()   #1
#1 In case it already exists

定义摄取内容和切分策略

接下来,先说明你要导入哪些内容,并定义文本切分策略来处理这些文档。下面的代码展示了如何完成这一步。

代码清单 10.2 定义摄取内容与切分策略

from langchain_community.document_loaders import AsyncHtmlLoader
from langchain_community.document_transformers import Html2TextTransformer
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_core.documents import Document

html2text_transformer = Html2TextTransformer()

text_splitter = RecursiveCharacterTextSplitter(   #1
    chunk_size=1000, chunk_overlap=100
)

def split_docs_into_chunks(docs):
    text_docs = html2text_transformer.transform_documents(
        docs)  #2
    chunks = text_splitter.split_documents(
        text_docs)

    return chunks

uk_destinations = [
    ("Cornwall", "Cornwall"), ("North_Cornwall", "Cornwall"), 
    ("South_Cornwall", "Cornwall"), ("West_Cornwall", "Cornwall"),
    ("Tintagel", "Cornwall"), ("Bodmin", "Cornwall"), 
    ("Wadebridge", "Cornwall"),
    ("Penzance", "Cornwall"), ("Newquay", "Cornwall"), 
    ("St_Ives", "Cornwall"),
    ("Port_Isaac", "Cornwall"), ("Looe", "Cornwall"), 
    ("Polperro", "Cornwall"),
    ("Porthleven", "Cornwall"),
    ("East_Sussex", "East_Sussex"), ("Brighton", "East_Sussex"),
    ("Battle", "East_Sussex"), ("Hastings_(England)", "East_Sussex"),
    ("Rye_(England)", "East_Sussex"), ("Seaford", "East_Sussex"), 
    ("Ashdown_Forest", "East_Sussex")
]

wikivoyage_root_url = "https://en.wikivoyage.org/wiki"

uk_destination_url_with_metadata = [   #3
    ( f'{wikivoyage_root_url}/{destination}', destination, region)
    for destination, region in uk_destinations]
#1 Instantiates a relatively fine-chunk splitting strategy
#2 Transforms HTML documents into clean text documents
#3 Prepares metadata to be imported: URL, UK destination, and UK region

下一步,就是把这些内容连同元数据一起导入进去。

带元数据摄取内容

按照下面的代码,对每个文档 chunk 做元数据增强。

代码清单 10.3 给 chunk 添加相关元数据

for (url, destination, region) in uk_destination_url_with_metadata:
    html_loader = AsyncHtmlLoader(url)   #1
    docs =  html_loader.load()   #2

    docs_with_metadata = [
        Document(page_content=d.page_content,
        metadata = {
            'source': url,
            'destination': destination,
            'region': region})
        for d in docs]

    chunks = split_docs_into_chunks(docs_with_metadata)

    print(f'Importing: {destination}')
    uk_with_metadata_collection.add_documents(documents=chunks)
#1 Loader for one destination
#2 Documents (chunks) related to one destination

到这里,你的 collection 就准备好了。每个文档 chunk 都已经带上了 metadata。接下来,你就可以基于 destinationregionsource 这样的关键词来给搜索结果加过滤条件,从而更精准地检索内容。

10.2.2 在带元数据的 collection 上做 Q&A

查询带元数据的内容有三种方式:

  • 显式元数据过滤器 —— 手动指定 metadata filter
  • SelfQueryRetriever —— 让 SelfQueryRetriever 自动生成 metadata filter
  • 结构化的 LLM function call —— 通过调用结构化 LLM function 来推断 metadata filter

我们依次来看看这些做法,先从显式过滤开始。

使用显式 metadata filter 查询

你可以直接利用每个 chunk 上附带的 metadata,在 retriever 中显式加入过滤条件。例如:

question =  "Events or festivals"
metadata_retriever = uk_with_metadata_collection.as_retriever(
    search_kwargs={'k':2, 'filter':{'destination': 'Newquay'}})

result_docs = metadata_retriever.invoke(question)

如果你打印 result_docs,会看到只返回了打上 'destination: Newquay' 标签的 chunk,这说明 filter 确实起作用了:

[Document(metadata={'destination': 'Newquay', 'region': 'Cornwall', 'source': 'https://en.wikivoyage.org/wiki/Newquay'}, page_content="## Do\n\n[edit]\n\n  * Cornish Film Festival. Held annually for two weeks each November around Newquay. (updated Jan 2024)\n  * 50.415741-5.0914781 Newquay Golf Club, Tower Road, TR7 1LT, ☏ +44 1637 872091, info@newquaygolfclub.co.uk. 9AM-4PM. A semi-private golf club established in 1890. Total yardage Championship: 6141, Men: 5708, and Women: 5364. £31 for non-members. (updated Apr 2019)\n\n### Beaches\n\n[edit]\n\nFistral Beach\n\nNewquay is well known as a surfer's paradise. Therefore it offers plenty of\nbeaches:"),
 Document(metadata={'destination': 'Newquay', 'region': 'Cornwall', 'source': 'https://en.wikivoyage.org/wiki/Newquay'}, page_content="## Eat\n\n[edit]\n\n### Budget\n\n[edit]\n\nThere are lots of cheap eats in the town centre.\n\n  * 50.415513-5.0868851 Harbour Rest Cafe, 2 S Quay Hill. (updated Feb 2023)\n  * 50.414042-5.0808662 Bunters, 15A East St. (updated Feb 2023)\n  * 50.413988-5.0809823 Andy's Cafe, 15 East St. (updated Feb 2023)\n  * 50.413981-5.0802984 Oceans, 1bh, 34 East St. (updated Feb 2023)\n  * 50.41337-5.0862025 Loafers Sandwich Bar, 1A Gover Ln. (updated Feb 2023)\n  * 50.418831-5.0665726 Kao Hom Thai Food, Henver Rd. (updated Feb 2023)\n  * 50.41749-5.0641777 Oceans, 1bh, 34 East St. (updated Feb 2023)\n  * 50.417024-5.0644678 The Cornish Coffee Bean, 14, Chester Court, Chester Rd. (updated Feb 2023)\n\n### Mid-range\n\n[edit]")]

如果你想换过滤条件,只需要重新实例化一个带新参数的 retriever 即可。

使用 SelfQueryRetriever 自动生成 metadata filter

你也可以使用 SelfQueryRetriever 自动生成 metadata filter。它会分析用户问题,自动推断出适合的过滤条件。当然,这背后的推断引擎本质上还是 LLM,因此它会引入额外的成本与延迟。首先,导入需要用到的库,如下所示。

代码清单 10.4 设置 metadata 字段信息

from langchain_classic.chains.query_constructor.base import AttributeInfo
from langchain_classic.retrievers.self_query.base 
↪import SelfQueryRetriever   #1
from langchain_openai import ChatOpenAI
#1 Requires pip install lark

接着,定义需要从问题中推断出来的 metadata 属性:

metadata_field_info = [
    AttributeInfo(
        name="destination",
        description="The specific UK destination to be searched",
        type="string",
    ),
    AttributeInfo(
        name="region",
        description="The name of the UK region to be searched",
        type="string",
    )
]

现在,用问题本身来设置 SelfQueryRetriever,而不再手工指定 filter:

question = "Tell me about events or festivals in the UK town of Newquay"

llm = ChatOpenAI(model="gpt-5-nano", openai_api_key=OPENAI_API_KEY)

self_query_retriever = SelfQueryRetriever.from_llm(
    llm, uk_with_metadata_collection, question, 
    metadata_field_info, verbose=True
)

然后,用这个问题调用 retriever:

result_docs = self_query_retriever.invoke(question)

打印 result_docs 后,你会发现它确实只检索到了与 Newquay 相关的 chunk,说明推断出来的 filter 是正确的:

[Document(metadata={'destination': 'Newquay', 'region': 'Cornwall', 'source': 'https://en.wikivoyage.org/wiki/Newquay'}, page_content="## Do\n\n[edit]\n\n  * Cornish Film Festival. Held annually for two weeks each November. [... REDUCED …] on the cliff above Towan Beach. Attached surf 
school and backpackers bar. £10.50 with breakfast included.\n\n### 
Mid-range\n\n[edit]"), Document(metadata={'destination': 'Newquay', 'region': 'Cornwall', 'source': 'https://en.wikivoyage.org/wiki/Newquay'}, page_content="### 
Mid-range\n\n[edit]\n\n  * 50.41326-5.0855229 Concho Lounge,  [... REDUCED …] town centre is home to a large number of pubs and bars."), Document(metadata={'destination': 'Newquay', 'region': 'Cornwall', 'source': 'https://en.wikivoyage.org/wiki/Newquay'}, page_content='* Newquay Tourist Information Centre, ☏ +44 1637 854020.\n\n## [... REDUCED …] . Leave this road near\nIndian Queens and continue on the A39 and then A392 which takes you directly\ninto the town.\n\n### By train\n\n[edit]')]

用 LLM function call 生成 metadata filter

你也可以让 LLM 把问题映射到一个预定义的 metadata 模板上,从而推断出 metadata filter。与 SelfQueryRetriever 相比,这种方式更灵活,但也需要你做更多的配置。首先,导入创建结构化查询和特定 filter 所需的库,如下所示。

代码清单 10.5 导入所需库

import datetime
from typing import Literal, Optional, Tuple, List

from pydantic import BaseModel, Field
from langchain_classic.chains.query_constructor.ir import (
    Comparator,
    Comparison,
    Operation,
    Operator,
    StructuredQuery,
)
from langchain_classic.retrievers.self_query.chroma import ChromaTranslator

DestinationSearch 这个类会把用户问题翻译成一个结构化对象,其中:

  • content_search 包含用于搜索的核心问题内容(不含过滤部分)
  • 其余字段则用于表示从问题中推断出来的 metadata filter

下面的代码展示了这种设置方式。

代码清单 10.6 强类型结构化问题对象

class DestinationSearch(BaseModel):
    """Search over a vector database of tourist destinations."""

    content_search: str = Field(
        "",
        description="""Similarity search query applied 
        to tourist destinations.""",
    )
    destination: str = Field(
        ...,
        description="The specific UK destination to be searched.",
    )
    region: str = Field(
        ...,
        description="The name of the UK region to be searched.",
    )

    def pretty_print(self) -> None:
        for field in self.__fields__:
            if getattr(self, field) is not None and getattr(
                self, field) != getattr(
                self.__fields__[field], "default", None
            ):
                print(f"{field}: {getattr(self, field)}")

从结构化查询构建 ChromaDB filter 语句

接下来,创建一个函数,把 DestinationSearch 对象转换成 ChromaDB 可以接受的 filter 语句,如下所示。

代码清单 10.7 构建 ChromaDB 专用 filter 语句

def build_filter(destination_search: DestinationSearch):
    comparisons = []

    destination = destination_search.destination  #1
    region = destination_search.region  #1

    if destination and destination != '':  #2
        comparisons.append(
            Comparison(
                comparator=Comparator.EQ,
                attribute="destination",
                value=destination,
            )
        )
    if region and region != '':  #3
        comparisons.append(
            Comparison(
                comparator=Comparator.EQ,
                attribute="region",
                value=region,
            )
        )    

    search_filter = Operation(operator=Operator.AND, 
                              arguments=comparisons)  #4

    chroma_filter = ChromaTranslator().visit_operation(
        search_filter)  #5

    return chroma_filter
#1 Gets destination and region from the structured query
#2 If the destination exists, creates an “equality” operation
#3 If the region exists, creates an “equality” operation
#4 Creates a combined search filter
#5 Transforms the filter into Chroma format

构建把问题转换成结构化查询的 query chain

现在定义 query generator chain,把用户问题转换成带有 metadata filter 的结构化查询。如下所示。

代码清单 10.8 Query generator chain

from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI

system_message = """You are an expert at converting user 
questions into vector database queries. 
You have access to a database of tourist destinations.
Given a question, return a database query optimized 
to retrieve the most relevant results.

If there are acronyms or words you are not familiar with, 
do not try to rephrase them."""
prompt = ChatPromptTemplate.from_messages(
    [
        ("system", system_message),
        ("human", "{question}"),
    ]
)
llm = ChatOpenAI(model="gpt-5-nano", openai_api_key=OPENAI_API_KEY)
structured_llm = llm.with_structured_output(
    DestinationSearch, method="function_calling")

query_generator = prompt | structured_llm

现在用前面那个问题来测试这条链:

question = "Tell me about events or festivals in the UK town of Newquay"

structured_query =query_generator.invoke(question)

打印 structured_query 后,你会看到用户问题被转换成了一个结构化对象:

DestinationSearch(content_search='events festivals', 
↪destination='Newquay', region='Cornwall')

既然结构化查询已经构建出来了,接下来就可以生成一个兼容 ChromaDB 的搜索 filter:

search_filter = build_filter(structured_query)

生成出来的 search_filter 看起来会像下面这样:

{'$and': [{'destination': {'$eq': 'Newquay'}},
  {'region': {'$eq': 'Cornwall'}}]}

然后,结合生成出来的结构化查询与 ChromaDB filter 执行向量搜索:

search_query = structured_query.content_search

metadata_retriever = uk_with_metadata_collection.as_retriever(
    search_kwargs={'k':3, 'filter': search_filter})

answer = metadata_retriever.invoke(search_query)

返回结果会与前面 SelfQueryRetriever 的输出非常接近,依然只会取回与 Newquay 相关的 chunk:

[Document(metadata={'destination': 'Newquay', 'region': 'Cornwall', 'source': 'https://en.wikivoyage.org/wiki/Newquay'}, page_content="## Do\n\n[edit]\n\n  * Cornish Film Festival. Held annually for two weeks each   [... REDUCED …]  Therefore it offers plenty of\nbeaches:"), Document(metadata={'destination': 'Newquay', 'region': 'Cornwall', 'source': 'https://en.wikivoyage.org/wiki/Newquay'}, page_content='## See\n\n[edit]\n\n  * 50.414578-5.0848411 Blue Reef Aquarium, Towan Promenade, TR7 1DU (right next to Towan beach), ☏ +44 1637 878134. Although it is small, it is well worth checking out. It has an octopus along with   [... REDUCED …]  November, 10:00-18:00. Small, but nice Japanese garden. (updated Jul 2024)\n\n## Do\n\n[edit]'), Document(metadata={'destination': 'Newquay', 'region': 'Cornwall', 'source': 'https://en.wikivoyage.org/wiki/Newquay'}, page_content="## Eat\n\n[edit]\n\n### Budget\n\n[edit]\n\nThere are lots of   [... REDUCED …] Chester Court, Chester Rd. (updated Feb 2023)\n\n### Mid-range\n\n[edit]")]

到这里为止,你一直在学习如何为向量存储生成带 metadata 增强的查询。接下来,我们会进入下一个主题:如何从自然语言问题中生成 SQL 查询,从关系型数据库里检索结构化数据。

10.3 生成结构化 SQL 查询

许多 LLM 都能够把用户问题转换成 SQL 查询,从而让 LLM 应用可以直接访问关系型数据库。虽然 LLM 在生成 SQL 方面的能力正在持续进步,但在面对复杂 schema 或数据库结构较特殊的场景时,它们仍然会遇到一些挑战。LangChain 也在不断增强 text-to-SQL 相关能力,但在真正使用时,仍然有一些常见问题值得注意。

这里有一篇很有参考价值的论文:Nitarshan Rajkumar 等人的 “Evaluating the Text-to-SQL Capabilities of Large Language Models”https://arxiv.org/pdf/2204.00498.pdf)。虽然这篇论文发布时间较早,但其中对于常见问题与解决思路的总结依然很实用。论文中的一个核心结论是:很多 hallucination——例如生成了错误的表名和列名——都可以通过 few-shot prompt 来缓解,而 few-shot prompt 中若包含目标表的 schema 和样例数据,效果尤其明显。论文中给出的一个 schema 示例大致如下:

CREATE TABLE "state" (
    "state_name" TEXT,
    "population" INT DEFAULT NULL,
    "area" DOUBLE DEFAULT NULL,
    "country_name" VARCHAR(3) NOT NULL DEFAULT '',
    "capital" TEXT,
    "density" DOUBLE DEFAULT NULL
);
/* example rows
state_name     population     area      country_name
↪     capital       density
alabama        3894000        51700.0   usa
↪              montgomery    75.319149
alaska         401800         591000.0  usa
↪              juneau       0.679865
arizona        2718000        114000.0  usa
↪              phoenix      23.842105
*/
-- Answer the following question using the above table schema:
-- {user_question}

CREATE TABLE 语句和样例行一起提供给 LLM,可以帮助它更好地理解表的结构与约束,从而减少在列名和表名上出错的概率。

10.3.1 安装 SQLite

SQLite 不需要像传统数据库那样完整安装。你只需要解压安装包,把它放到某个目录,并将该目录加入系统的 Path 环境变量即可。Windows 下的安装说明请参考附录 D;如果你使用其他操作系统,请查看 SQLite 官方文档。

10.3.2 设置并连接数据库

现在我们来创建一个名为 UkBooking 的预订数据库,用来保存英国旅游目的地、住宿和优惠信息。图 10.2 展示了 UkBooking 数据库的关系图。每张表中都显示了主键(PK)和外键(FK)列,而表与表之间的关系则通过箭头标注出来。这张图直观地展示了 destination、accommodation 和 offer 等表之间的结构与关联。

image.png

图 10.2 UkBooking 数据库的实体关系图

打开操作系统终端,进入代码目录,然后执行下面这条命令来创建 UkBooking 数据库:

C:\Github\building-llm-applications\ch10>sqlite3 UkBooking.db

这会打开 SQLite 的终端界面:

SQLite version 3.46.1 2024-08-13 09:16:08 (UTF-16 console I/O)
Enter ".help" for usage hints.
sqlite>

在 SQLite 终端中,加载用于创建并填充 UkBooking 数据库的 SQL 脚本。请确保这些文件位于 C:\Github\building-llm-applications\ch10 中;如果本地没有,可以从 GitHub 下载:

sqlite> .read CreateUkBooking.sql
sqlite> .read PopulateUkBooking.sql

为了确认数据库创建成功,可以先检查 Offer 表中的记录:

sqlite> SELECT * FROM Offer;

你应该会看到类似下面这样的输出:

1|1|Summer Special|0.15|2024-06-01|2024-08-31
2|2|Weekend Getaway|0.1|2024-09-01|2024-12-31
3|3|Early Bird Discount|0.2|2024-05-01|2024-06-30
4|4|Stay 3 Nights, Get 1 Free|0.25|2024-01-01|2024-03-31

现在,UkBooking 数据库已经准备好了,可以和 LangChain 一起使用。

接下来,回到 Jupyter Notebook,导入建立 SQL 数据库连接所需的库:

from langchain_community.utilities import SQLDatabase
from langchain_community.tools import QuerySQLDataBaseTool
from langchain_classic.chains import create_sql_query_chain
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
import getpass
import os

用下面的代码连接数据库,并列出可用表名:

db = SQLDatabase.from_uri("sqlite:///UkBooking.db")
print(db.get_usable_table_names())

你应该会看到一组表名:

['Accommodation', 'AccommodationType', 'Booking', 'Customer', 'Destination', 'Offer']

再执行一个示例查询,确认连接没有问题:

db.run("SELECT * FROM Offer;")

输出应该会显示 Offer 表中的记录:

"[(1, 1, 'Summer Special', 0.15, '2024-06-01', '2024-08-31'), (2, 2, 'Weekend Getaway', 0.1, '2024-09-01', '2024-12-31'), [... SHORTENED]

到这里,你就已经可以通过 LangChain 以编程方式访问 UkBooking 数据库了。

10.3.3 从自然语言生成 SQL 查询

既然环境已经搭好,现在就可以开始直接从自然语言问题中生成 SQL 查询了。先用下面的代码做一个简单测试:

llm = ChatOpenAI(openai_api_key=OPENAI_API_KEY, model="gpt-4.1")
sql_query_gen_chain = create_sql_query_chain(llm, db)
response = sql_query_gen_chain.invoke(
    {"question": 
     "Give me some offers for Cardiff, including the hotel name"})

打印 response,你会看到生成出来的 SQL 查询:

'```sql\nSELECT "Offer"."OfferDescription", "Offer"."DiscountRate", "Accommodation"."Name" \nFROM "Offer" \nJOIN "Accommodation" ON "Offer"."AccommodationId" = "Accommodation"."AccommodationId" \nJOIN "Destination" ON "Accommodation"."DestinationId" = "Destination"."DestinationId" \nWHERE "Destination"."Name" = 'Cardiff' \nLIMIT 5;\n```'

不过,如果你直接把这段 SQL 发给数据库执行,会报错,因为里面带有反引号代码块(```),而这些不是 SQL 语法的一部分:

db.run(response)

报错信息类似这样:

'Error: (sqlite3.OperationalError) near "```sql\nSELECT "Offer"."OfferDescription",

为了解决这个问题,你可以再借助一次 LLM,把多余的格式字符去掉,只输出一条真正可执行的 SQL 语句。下面这个链就专门用来做这件事。

代码清单 10.9 修复生成 SQL 格式的链

clean_sql_prompt_template = """You are an expert in SQLite. 
You are asked to fix badly formed SQLite queries, 
which might contain unneeded prefixes or suffixes. 
Given the following unclean SQL statement, 
transform it to a clean, 
executable SQL statement for SQLite.
Always prefix column names with the table name.
Only return an executable SQL statement which terminates 
with a semicolon. Do not return anything else.
Do not include the language name or symbols like ```.

Unclean SQL: {unclean_sql}"""

clean_sql_prompt = ChatPromptTemplate.from_template(
    clean_sql_prompt_template)

clean_sql_chain = clean_sql_prompt | llm

full_sql_gen_chain = sql_query_gen_chain | \
   clean_sql_chain | StrOutputParser()

现在,用一个示例问题测试整条链,并查看输出:

question = """Give me some offers for Cardiff, 
including the accommodation name"""

response = full_sql_gen_chain.invoke({"question": question})

print(response)

输出应该是一条干净的 SQL 语句:

"SELECT Offer.OfferDescription, Offer.DiscountRate, Accommodation.Name \nFROM Offer \nJOIN Accommodation ON Offer.AccommodationId = Accommodation.AccommodationId \nJOIN Destination ON Accommodation.DestinationId = Destination.DestinationId \nWHERE Destination.Name = 'Cardiff' \nLIMIT 5;"

这样一来,你就得到了格式正确、可直接执行的 SQL 语句。

10.3.4 执行 SQL 查询

现在我们来创建一条既能生成又能执行 SQL 查询的链。

sql_query_exec_chain = QuerySQLDataBaseTool(db=db)

sql_query_gen_and_exec_chain = full_sql_gen_chain \
    | sql_query_exec_chain | StrOutputParser()

response = sql_query_gen_and_exec_chain.invoke(
    {"question":question})

打印 response 后,你应该会看到如下输出:

"[('Early Bird Discount', 0.2, 'Cardiff Camping')]"

这样,你就可以通过一个组合链 sql_query_gen_and_exec_chain,同时完成 SQL 生成与执行。这个链也可以很方便地嵌入到更大的 RAG 工作流中,正如前面几节讨论的那样。图 10.3 通过一个可视化流程图展示了完整 SQL-RAG 工作流会是什么样子。你可以把它作为练习,自己进一步扩展。

image.png

图 10.3 RAG with SQL 工作流。LLM 先把自然语言问题转换成 SQL 查询,然后在 SQL 数据库上执行该查询。数据库返回记录后,再由 LLM 基于这些记录生成最终回答。

提示 LangChain 的 SQLDatabaseChain 类提供了一种更简化的方式,让你可以直接从用户问题生成 SQL 查询。它会结合 LLM 与数据库连接,自动构造 few-shot prompt,这和 Rajkumar 论文中建议的方法非常相似。如果你计划在自己的 RAG 系统中加入关系型数据库,那么尝试 SQLDatabaseChain 会非常有帮助。

10.4 生成语义 SQL 查询

在上一节中,你已经学会了如何从自然语言生成 SQL 查询。不过,那些查询本质上仍然是严格意义上的 SQL:它们依赖的是精确匹配和传统的关系运算。关系型数据库对记录集执行的是诸如 SELECTJOINWHEREGROUP BY 之类的操作,而过滤条件通常基于精确的字符串匹配或数值比较。

但如果你希望 SQL 搜索也能扩展成“按语义找相似结果”,也就是说,能返回那些与用户意图语义相近,而不是文本完全一致的结果,该怎么办?这就需要从标准 SQL 检索过渡到语义 SQL 搜索。本节将对这一方向做一个概览。它仍然是一个持续演进中的主题。

10.4.1 标准 SQL 查询

标准 SQL 查询依赖精确匹配。例如,如果你想查找名为 Roberto 的用户,可以这样写:

SELECT first_name, last_name FROM user WHERE first_name = ‘Roberto’

这条查询只会返回名字刚好叫 Roberto 的用户。它不会返回 Robert、Rob、Robbie、Roby、Robin、Roe、Bobby、Bob 或 Bert。

你也可以通过 LIKE 操作符把匹配放宽一点,用于做前缀或部分匹配。例如,如果你想找所有以 “Rob” 开头的名字,可以这样写:

SELECT first_name, last_name FROM user WHERE first_name LIKE ‘Rob%

这样,查询结果会包括 Roberto、Robert、Rob、Robbie、Roby 和 Robin,但依然不会返回 Roe、Bobby、Bob 或 Bert,因为这些名字里并不包含字符串 “Rob”。

10.4.2 语义 SQL 查询

随着 LLM 的兴起,一些关系型数据库已经开始支持基于 embedding 的语义搜索,而不再局限于精确文本匹配。一个典型例子是 PostgreSQL 的扩展 pgvector,它允许你使用欧氏距离或余弦距离等指标来执行向量相似度搜索。这样一来,你就可以执行一种“按语义而不是按字面文本”返回结果的查询方式。本节中,我会把它统称为 semantic SQL searchSQL similarity search

10.4.3 创建 embedding

要把传统 SQL 查询扩展成基于 pgvector 的相似度搜索,你需要先给那些要做语义搜索的列补充 vector embedding。步骤如下:

1)添加向量列
首先,在目标表中为每个需要支持相似度检索的字段添加一个 VECTOR 类型的列。例如,如果你想对 first_name 做语义搜索,可以增加一个名为 first_name_embedding 的列:

ALTER TABLE user  ADD COLUMN first_name_embedding VECTOR

2)计算 embedding
然后,为每个 first_name 计算 embedding 值。你既可以在 PostgreSQL 内部直接计算,也可以在外部通过 API 客户端(例如 LangChain)来生成。

  • 数据库内部计算 —— 如果你在 PostgreSQL 中提供了一个类似 calculate_my_embedding() 的自定义函数,那么就可以直接通过 SQL 原地更新 embedding:
UPDATE user
SET first_name_embedding = calculate_my_embedding(first_name)
  • 使用 LangChain 在外部计算 —— 如果你使用的是现成 embedding 模型(例如 OpenAI),那么可以先在数据库外部计算 embedding,再通过 pgvector 的 API 写回数据库。下面的代码清单展示了如何使用 LangChain 的 OpenAIEmbeddings 封装器来生成 first_name 的 embedding,并更新到数据库中(为简洁起见,省略了 import)。

代码清单 10.10 使用 LangChain 的 OpenAIEmbeddings 包装器

db = SQLDatabase.from_uri(
    YOUR_DB_CONNECTION_STRING)   #1
embeddings_model = OpenAIEmbeddings()   #1

first_names_resultset_str = db.run('SELECT first_name FROM user')
first_names = [fn[0] for fn in eval(
    first_names_resultset_str)]   #2

first_names_embeddings = embeddings_model.embed_documents(
   first_names)   #3
fn_emb = zip(first_names, 
    first_names_embeddings)   #4

for fn, emb in fn_emb:
    sql = f'UPDATE user SET first_name_embeddings = 
↪ARRAY{emb} WHERE first_name ="{fn}"'
    db.run(sql)
#1 Instantiates the database client and embeddings model
#2 Extracts a list of strings from the SQL result string
#3 Calculates the embedding of each first name
#4 Associates the first names with the related embeddings

完成这些步骤之后,你就能在 first_name 这类字段上启用语义搜索,让 pgvector 基于相似度而不是精确匹配来返回记录。

10.4.4 执行语义 SQL 搜索

当 embedding 准备好之后(并且你也为相应列创建了索引,以确保大数据量下仍有足够性能),就可以执行相似度搜索了:

embedded_query= embeddings_model.embed_query("Roberto")
query = (
    'SELECT first_name FROM user WHERE first_name_embeddings IS NOT NULLORDER BY first_name_embeddings <-> "{embedded_query}"'
)
db.run(query)

这条查询会按语义相似度返回 Roberto 的各种变体,例如 Roe、Bobby、Bob 和 Bert,因为结果是按 embedding 相似度排序的。

10.4.5 自动化语义 SQL 搜索

现在你已经理解了:如何生成 embedding,以及如何在 SQL 数据库中执行相似度搜索。最后一步,就是设计一个 prompt,让系统能够自动生成 SQL similarity query。这个过程与你前面看到的传统 SQL 生成方式类似。一旦你把这个 prompt 设计好、实现好、测试好,并将其放进 LangChain Expression Language(LCEL)构成的完整链中,你的 LLM 应用就可以自动在 pgvector 或其他支持 ARRAY(或类似向量数据类型)的 SQL 数据库上执行语义搜索,并把结果顺畅地交给 LLM 做最终综合。

10.4.6 语义 SQL 搜索的优势

这里展示的例子其实只是 semantic SQL 能力的冰山一角。你完全可以把语义过滤与精确匹配结合起来使用,或者同时使用多个语义过滤条件,尤其是在多表 join 查询里,这种能力会非常强大。与传统 SQL 过滤一起使用时,它能实现非常细腻、非常灵活的搜索逻辑。

后面我还会展示,如何在向量存储中结合 metadata filtering 与 semantic filtering,从而实现类似效果。不过,如果你需要在 SQL 中同时运用多个语义过滤条件,那么 SQL 的实现方式往往会提供更高的灵活度,特别适合复杂查询。

10.5 为图数据库生成查询

图数据库专门用于以图结构的形式存储、遍历和查询数据,其中节点表示实体,边表示它们之间的关系,如图 10.4 所示。它们非常适合用来构建知识图谱,因此特别适用于那些需要高级推理、推断与可解释性的专业领域。

image.png

图 10.4 数据的图表示。节点代表 Roberto、InterMilan 这样的实体,而 hasOccupationisFanOf 这样的边则表示它们之间的关系。

与关系型数据库不同,图数据库并不存在统一标准。有些图数据库使用 Resource Description Framework(RDF) 来表示数据,也就是采用 subject-predicate-object 的三元组形式。例如,在 RDF 中可以写成:

(Roberto, hasOccupation, SoftwareDeveloper)
(Roberto, isFanOf, InterMilan)
(InterMilan, playsIn, SerieA)

对应的 RDF 结构可能会像这样:

@prefix ex: <http://example.org/> .
@prefix rdf: <http://www.w3.org/1999/02/22-rdf-syntax-ns#> .

ex:Roberto rdf:type ex:Person .
ex:Roberto ex:hasOccupation ex:SoftwareDevelopment .
ex:Roberto ex:isFanOf ex:InterMilan .

ex:InterMilan rdf:type ex:SoccerTeam .
ex:InterMilan ex:playsIn ex:SerieA .

ex:SerieA rdf:type ex:SoccerLeague .

不过,并不是所有图数据库都使用 RDF。还有一些图数据库采用的是专有的图表示方式和查询语言,比如 Neo4j 使用的 Cypher,或者 Gremlin,而不是 RDF 和 SPARQL。

正如图 10.4 所示,图这种数据结构能够非常灵活、强大地表示关系,而这些关系往往很难用传统数据库建模。因此,在那些对“关系理解”和“推理”要求很高的知识密集型应用里,图数据库就显得格外合适。

图数据库从 21 世纪初就已经存在了,Neo4j 是最早的一批代表产品之一。它们虽然非常强大,但也一直以“复杂”著称。近几年,LLM 的出现让图数据库的使用门槛明显下降,它在以下几个关键方面尤其有帮助:

  • 实体与关系抽取 —— LLM 可以从非结构化文本中抽取实体、关系,甚至直接抽取出整个图结构,从而让你把这些信息直接写入图数据库。
  • 自动生成查询语句 —— LLM 可以根据自然语言问题生成复杂的 Cypher 或 SPARQL 查询。这对于经验不足的开发者来说尤其有价值,因为图查询语言的编写门槛往往不低。要实现这一点,通常需要使用 carefully crafted 的 few-shot prompt,并且最好配合像 GPT-5 这样的高准确率模型。
  • 自然语言回答生成 —— LLM 还可以把图数据库查询结果(例如 RDF 结果)重新转换成自然语言回答。通常,这一步会把原始问题、生成出来的 Cypher/SPARQL 查询,以及查询结果一起喂给一个专门的 prompt,然后由成本更低的模型(例如 GPT-5-nano)来完成自然语言综合。

图 10.5 展示了最终形成的 Knowledge Graph RAG(KG-RAG) 架构。它与基于向量存储的 RAG 其实非常相似。

image.png

图 10.5 Knowledge Graph RAG(KG-RAG)架构与向量存储型 RAG 很相似,只不过这里换成了一个 SPARQL generator。它先把自然语言问题转换成 SPARQL,再在知识图谱数据库中执行查询。取回的图数据随后会和原始问题一起送给 LLM,用于综合生成最终答案。

LangChain 支持多种图数据库,包括 Neo4j 和 Amazon Neptune。虽然本书不会对 KG-RAG 做深入展开,但我建议你进一步参考 LangChain 的官方文档和示例。

下面是一段用于生成 Cypher 查询的 prompt 模板,它来自 LangChain 的 Neo4j QA chain:

CYPHER_GENERATION_TEMPLATE = """Task:Generate Cypher 
statement to query a graph database.
Instructions:
Use only the provided relationship types and properties in the schema.
Do not use any other relationship types or properties that are not provided.
Schema:
{schema}
Note: Do not include any explanations or apologies in your responses.
Do not respond to any questions that might ask anything else 
than for you to construct a Cypher statement.
Do not include any text except the generated Cypher statement.
Examples: Here are a few examples of generated Cypher 
for particular questions:
# How many people played in Top Gun?
MATCH (m:Movie {{title:"Top Gun"}})<-[:ACTED_IN]-()
RETURN count(*) AS numberOfActors
The question is:
{question}"""

图数据库本身也在不断演进,以适应新的 LLM 驱动用例,其中一个新方向就是 knowledge graph embeddings。这种方法会为知识图谱中的实体和关系补充文本描述与 embedding,从而让语义搜索也能与传统图查询协同工作。这些技术使得你不仅可以利用图数据库本身所提供的结构化知识,还能叠加 LLM 的灵活性,构建出非常强大的 retrieval-augmented generation 方案。

注意 如果你想进一步了解 knowledge graph embedding,可以参考论文 “A Type-Augmented Knowledge Graph Embedding Framework for Knowledge Graph Completion”www.nature.com/articles/s41598-023-38857-5)。如果你想系统地学习这个方向,我也很推荐 Manning 在 2025 年出版的 Essential GraphRAG: Knowledge Graph–Enhanced RAG,作者是 Tomaž Bratanič 和 Oskar Hane。

10.6 链路由(Chain routing)

一个应用的数据内容可能分布在多种存储中,而不只是向量存储。你可能会用向量存储保存非结构化文本,用关系型数据库保存结构化数据,用文档数据库保存半结构化内容,用知识图谱数据库表示实体关系。此外,同一个应用在不同任务上还可能需要连接不同的 LLM,因为不同模型在不同场景下可能更擅长,或者在成本上更划算。因此,RAG 架构往往不会是单一路径,而会发展成一棵“分支树”,每条分支都针对特定问题或任务做过优化,如图 10.6 所示。

image.png

图 10.6 复杂 RAG 架构中的分支路径。每条路径都针对特定任务做了优化,例如回答旅游目的地问题(来自向量存储)或回答住宿优惠问题(来自关系型 SQL 数据库)。

这张图展示了一个带有多个分支的 RAG 体系结构,每个分支都专门负责处理某一类任务。比如,一条分支可能用于回答关于旅游目的地的事实性问题,而另一条分支则专门处理住宿优惠相关的问题。

假设用户问的是旅游目的地,那么你很可能会把这个问题路由到基于向量存储的 RAG 链上;如果用户问的是住宿优惠,那么你可能会把它路由到前面介绍过的 UkBooking 数据库所对应的 RAG 链上。

为了把每个问题发送到正确的链,你需要使用一个 routing chain。这条链的任务是分析用户问题,并判断哪一条链最适合处理它。下面我们就来实现这样一条 routing chain。

10.6.1 设置数据 retriever

为了简化设置过程,我们将直接复用前面已经建立好的向量存储与关系型数据库配置。先导入需要用到的库:

from typing import Literal
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
from pydantic import BaseModel, Field
from langchain_core.runnables import RunnableLambda

然后创建对应的 retriever chain:

tourist_info_retriever_chain = RunnableLambda(
    lambda x: x['question']) \
       | uk_with_metadata_collection.as_retriever(
           search_kwargs={'k':2})  

uk_accommodation_retriever_chain =  full_sql_gen_chain \
    | sql_query_exec_chain | StrOutputParser()

这两条 retriever chain 分别会把问题导向正确的数据源。接下来,我们会构建一个 router,让用户问题能够自动流向其中之一。

10.6.2 设置 query router

我们将使用 LLM 来实现一个问题路由器。这个 LLM 会分析每个问题,并根据内容判断最合适的 retriever chain。prompt 中会明确说明每个 retriever 的用途:向量存储用于一般旅游信息,关系型数据库用于住宿预订相关信息。下面代码清单中的路由函数会把 LLM 的输出绑定到一个强类型对象上,并根据问题意图把 datasource 实例化为 "tourist_info_store""uk_booking_db"

代码清单 10.11 将查询路由到正确的 retriever

class RouteQuery(BaseModel):
    """Route a user question to the most relevant datasource."""

    datasource: Literal["tourist_info_store", 
        "uk_booking_db"] = Field(
        ...,
        description="""Given a user question, 
        route it either to a tourist info vector store 
        or a UK accommodation booking relational database.""",
    )

llm = ChatOpenAI(openai_api_key=OPENAI_API_KEY, model="gpt-5-nano")
structured_llm_router = llm.with_structured_output(
    RouteQuery) #1
system = """You are an expert at routing a user question 
to a tourist info vector store 
or to an UK accommodation booking relational database.
The vector store contains tourist information about UK destinations.
Use the vector store for general tourist information questions 
on UK destinations. 
For questions about accommodation availability or booking, 
use the UK Booking database."""
route_prompt = ChatPromptTemplate.from_messages(
    [
        ("system", system),
        ("human", "{question}"),
    ]
)

question_router = route_prompt | structured_llm_router
#1 Structured router that uses LLM function calls

这样配置以后,LLM 就能根据问题内容智能地把它路由到正确的数据源,从而让回答更准确。

测试 router chain

先用一个关于住宿优惠的问题来测试 router chain,再用一个旅游信息问题来测试:

selected_data_source = question_router.invoke(
    {"question": "Have you got any offers in Brighton?"}
)

print(selected_data_source)

预期输出:

datasource='uk_booking_db'

然后再测试一个旅游问题:

selected_data_source = question_router.invoke(
    {"question": "Where are the best beaches in Cornwall?"}
)

print(selected_data_source)

预期输出:

datasource='tourist_info_store'

可以看到,router 确实正确识别出了该使用哪个数据源。

设置 retriever chooser

现在我们来实现一个函数,用来根据选中的数据源('uk_booking_db''tourist_info_store')返回正确的 retriever。具体实现如下。

代码清单 10.12 Retriever chooser 函数

retriever_chains = {
    'tourist_info_store': tourist_info_retriever_chain,
    'uk_booking_db': uk_accommodation_retriever_chain
}

def retriever_chooser(question):
    selected_data_source = question_router.invoke(
        {"question": question})

    return retriever_chains[selected_data_source.datasource]

用一个示例问题测试这个函数:

chosen = retriever_chooser("""Tell me about events 
or festivals in the UK town of Newquay""")  

print(chosen)

预期输出:

first=RunnableLambda(lambda x: x['question'])
↪ last=VectorStoreRetriever(tags=['Chroma', 'OpenAIEmbeddings'],
↪ vectorstore=<langchain_chroma.vectorstores.Chroma object at 
↪ 0x0000022799116010>, search_kwargs={'k': 2})

输出表明:函数已经根据问题意图选出了正确的 retriever chain 实例。这一机制可以确保问题总是被发送到最匹配的数据源,从而提高最终答案的准确度。

10.6.3 把 chain router 集成进完整的 RAG 链

最后一步,就是把 chain router 整合进一条完整的 RAG 链中,从而让“选择 retriever → 执行检索 → 综合生成答案”变成一个完整工作流。下面给出一个示例实现。

代码清单 10.13 用于路由、检索和综合生成的完整 RAG 链

from langchain_core.runnables import RunnablePassthrough

rag_prompt_template = """
Given a question and some context, answer the question.
If you get a structured context, like a tuple, try to 
infer the meaning of the components: 
typically they refer to accommodation offers, 
and the number is a percentage (0.2 means 20%).
If you do not know the answer, just say I do not know.

Context: {context}
Question: {question}
"""

rag_prompt = ChatPromptTemplate.from_template(rag_prompt_template) 

def execute_rag_chain(question, chosen_retriever):
    full_rag_chain = (
        {
            "context": {"question": RunnablePassthrough()} 
                | chosen_retriever,    #1
            "question": RunnablePassthrough(),     #2
        }
        | rag_prompt
        | llm
        | StrOutputParser()
    )

    return full_rag_chain.invoke(question)
#1 The context is returned by the retriever after feeding to it the rewritten query.
#2 This is the original user question.

下面我们分别用住宿查询和旅游信息查询来测试这条 RAG 链。

示例:询问住宿优惠

这个例子测试的是:当用户询问住宿相关信息时,RAG 链会如何响应。

question = """Give me some offers for Cardiff, 
including the accommodation name"""

chosen_retriever = retriever_chooser(question)

answer = execute_rag_chain(question, chosen_retriever)

预期输出:

One offer for Cardiff is the "Early Bird Discount" at Cardiff Camping, which provides a 20% discount.

示例:询问旅游信息

这个例子展示的是:当问题是一般旅游信息,而不是住宿预订时,RAG 链会如何表现。

question_2 = """Tell me about events or festivals 
in the UK town of Newquay"""

chosen_retriever_2 = retriever_chooser(question_2)

answer2 = execute_rag_chain(question_2, chosen_retriever_2)

预期输出:

In Newquay, the **Cornish Film Festival** is held annually each November. It is a notable event that celebrates film in the region. Additionally, Newquay is known for being the UK's surfing capital, with various surfing events, including the UK championships and the Boardmasters festival, taking place in the area.

在这两个例子中,RAG 链都正确地把问题路由到了合适的 retriever,完成了检索,并把上下文和原始问题一起送入 LLM,最终生成了连贯的答案。

到这里,我们对高级 RAG 技术的探索已经接近尾声。还剩下最后一个主题:retrieval postprocessing(检索后处理) 。它关注的是:在把 chunk 作为上下文送入 LLM 之前,再做一层筛选和优化,从而进一步提升回答的相关性与清晰度。

10.7 Retrieval postprocessing

经过前面这些技术的加持之后,你通常会从内容存储中拿回一组文档 chunk(或 node)。不过,在把它们直接送给 LLM 做答案综合之前,你往往还希望再做一层后处理,把不够相关的内容过滤掉,只保留高质量 chunk,这样能让 LLM 生成的回答更简洁、更准确,如图 10.7 所示。

image.png

图 10.7 Retrieval postprocessing。来自向量存储的检索 chunk 会再经过一轮过滤,去掉无关内容,只把高质量 chunk 发送给 LLM 来回答用户问题。

接下来的几个小节会介绍一些关键的后处理技术。我们先从 similarity postprocessor 开始。

10.7.1 Similarity postprocessor

如果你使用的是相似度检索器(通常是基于向量距离的语义检索器),那么一个最直接的做法,就是对相似度分数设置一个截断阈值。低于某个相似度分数的 chunk,或者高于某个距离上限的 chunk,就会被丢弃。

在 LangChain 中,你可以在执行搜索前,通过向量存储实例化一个带分数阈值的 retriever。只要把 search_type 设为 "similarity_score_threshold",并在 search_kwargs 中指定阈值即可:

score_threshold_similartity_retriever = vector_store.as_retriever(
    search_type="similarity_score_threshold",
    search_kwargs={"score_threshold": 0.6}
)

有了这个 retriever 之后,你就可以通过它的 get_relevant_documents() 方法来执行搜索:

doc_chunks = score_threshold_similartity_retriever
↪.get_relevant_documents("What are the best beaches in Cornwall?")

这样,只有相似度分数高于阈值的文档才会被送给 LLM。

10.7.2 Keyword postprocessor

另一种后处理方式,是通过关键词来过滤已经检索出来的文档 chunk。这样你就可以只保留那些包含特定术语的 chunk,或者排除掉包含某些术语的 chunk。

虽然 LangChain 没有内置的 keyword postprocessor,但你完全可以用 Python 自己实现,例如下面这种简单写法:

selected_chunks = [c for c in chunks 
             if set(c.split()).intersection(required_keywords)
            and not set(c.split()).intersection(excluded_keywords)]

这段代码的意思是:只保留那些至少命中了 required_keywords 中某些关键词、同时又不包含 excluded_keywords 中任何关键词的 chunk。

10.7.3 时间加权(Time weighting)

你可能还希望根据文档最近一次被访问的时间,对 chunk 进行排序偏好。要做到这一点,可以在每个 chunk 的 metadata 中加入一个 last_accessed_at 时间戳,并在每次访问时更新它:

[Document(page_content='this is some content of a chunk',↪metadata={'last_accessed_at': datetime.datetime(2024, 01, 02, 14, 18, ↪22, 53225), 'created_at': datetime.datetime(2023, 12, 11, 11, 21, 12, ↪55466), 'buffer_idx': 1})]

一旦这些时间戳存在,你就可以使用 TimeWeightedVectorStoreRetriever。它会综合考虑相似度分数和时间衰减因子,按“相关性 + 新近性”共同给 chunk 排序:

retriever = TimeWeightedVectorStoreRetriever(
    vectorstore=vectorstore, 
    decay_rate=1e-10, k=3
)

TimeWeightedVectorStoreRetriever 会按下面的方式修正相似度分数:

adjusted_similarity_score = similarity_score
↪ + (1.0 - decay_rate) ** hours_passed

这个修正后的分数会让那些既相关、又较新的内容排到更前面,从而让回答更“及时”。接下来我们来看一个非常关键的后处理技术。

10.7.4 RAG fusion(Reciprocal Rank Fusion)

在上一章中,我们讲过“多查询生成”:即从一个用户问题中生成多个查询,再从这些查询各自的结果中筛选出一个较优子集。我们当时利用 LangChain 的 MultiQueryRetriever 自动完成了这一流程,从而选出更相关的答案。不过,如果你想对结果排序拥有更细粒度的控制,那么你可以考虑 Reciprocal Rank Fusion(RRF) 这一方法。

RRF 由 Cormack、Clarke 和 Buttcher 在论文 “Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods”https://mng.bz/Dwe9)中详细讨论过。它用下面这个公式对检索出的文档进行重打分:

rrfscore = 1 / (rank + k)

其中:

  • rank 是文档当前在某个结果列表中的排名
  • k 是一个平滑常数,用来控制原始排名对分数的影响强度

随着每个文档在多个生成查询的结果列表中被处理,它的 RRF 分数会不断累加。等所有分数都算完之后,就按累计的 RRF 分数重新排序,只把最靠前的结果送给 LLM 做最终综合。图 10.8 能帮助你直观理解这一完整流程。

image.png

图 10.8 Reciprocal Rank Fusion 工作流。系统先从原始用户问题生成多个查询;每个查询都会检索一组结果(例如来自向量存储);然后把所有结果通过 RRF 分数重新排序,最后只把排名最靠前的结果送给 LLM 做最终答案综合。

从高层看,实现 RAG fusion 的过程包括两步:先像第 9 章那样生成多个查询,再用 RRF 算法对结果重新排序。整个检索工作流随后就可以嵌入到更大的 RAG 链里,就像前面几节展示过的那样。

生成多个查询

你可以像第 8 章介绍的那样,用一条链从一个问题中生成多个查询。为了方便,这里把相关代码再贴一遍。

代码清单 10.14 多查询生成

from langchain_core.prompts import ChatPromptTemplate

from typing import List
from langchain_core.output_parsers import BaseOutputParser
from pydantic import BaseModel, Field

multi_query_gen_prompt_template = """
You are an AI language model assistant. Your task is 
to generate five different versions of the given user 
question to retrieve relevant documents from a vector 
database. By generating multiple perspectives on the 
user question, your goal is to help
the user overcome some of the limitations of the 
distance-based similarity search. 
Provide these alternative questions separated by newlines.
Original question: {question}
"""

multi_query_gen_prompt = ChatPromptTemplate.from_template(
    multi_query_gen_prompt_template) 

class LineListOutputParser(BaseOutputParser[List[str]]):
    """Parse out a question from each output line."""

    def parse(self, text: str) -> List[str]:
        lines = text.strip().split("\n")
        return list(filter(None, lines))  


questions_parser = LineListOutputParser()

llm = ChatOpenAI(model="gpt-5", openai_api_key=OPENAI_API_KEY)

multi_query_gen_chain = multi_query_gen_prompt | llm | questions_parser

有了这套配置之后,你就可以从一个用户问题生成多个替代查询,用不同视角捕捉查询的语义,从而提高文档检索效果。

注意 这里我使用的是 GPT-5,而不是 GPT-5-mini 或 GPT-5-nano,因为它通常更擅长生成质量更高的查询,也更容易输出更准确、综合性更好的回答。

既然多个查询已经能生成出来了,下一步就是给这些结果建立排序机制。这里我们就使用 RRF 算法。

RRF 算法

整个流程的核心,就是 RRF 算法。它会对多个查询各自检索出来的文档打分,并利用 RRF 公式进行重排序。下面这段代码给出了一个具体实现。

代码清单 10.15 Reciprocal Rank Fusion 算法

def reciprocal_rank_fusion(results_groups:  #1
                           list[list], k=60):
    """ Reciprocal_rank_fusion that takes multiple groups of 
        ranked documents and an optional parameter k used in 
        the Reciprocal Rank Fusion (RRF) formula """

    indexed_results = {}  #2

    for group_id, results_group in enumerate(
        results_groups):  #3
        for local_rank, doc in enumerate(results_group):
            indexed_results[(group_id, local_rank)] = doc

    fused_scores = {}  #4

    for key, doc in indexed_results.items():  #5
        group_id, local_rank = key

        if key not in fused_scores:
            fused_scores[key] = 0  #6

        doc_current_score = fused_scores[key]        
        fused_scores[key] += 1 / (local_rank + k)  #7

    reranked_results = [ #8
        (indexed_results[key], score)
        for key, score in sorted(fused_scores.items(), 
                                 key=lambda x: x[1], reverse=True)
    ]

    return reranked_results
#1 Based on https://mng.bz/lZjM
#2 Initializes a dictionary to organize results with an index
#3 Indexes the results by (group_id, local_rank)
#4 Initializes a dictionary to hold fused scores for each unique document
#5 Iterates through the indexed results
#6 Initializes an indexed result with a score of 0 if it hasn’t been processed yet
#7 Calculates the new document score with the RRF formula
#8 Reranks the results by RRF score

设置 RAG fusion retrieval chain

有了 RRF 算法之后,我们就可以创建一条 RAG fusion retrieval chain,如下所示:

retriever = uk_with_metadata_collection.as_retriever(
    search_kwargs={'k':3})
top_three_results = RunnableLambda(
    lambda x: x[0:3])  #1

rag_fusion_retrieval_chain =multi_query_gen_chain \
    | retriever.map() | reciprocal_rank_fusion \
    | top_three_results  #2

docs = rag_fusion_retrieval_chain.invoke(
    {"question": question})  #3
len(docs)
#1 Selects the top three results
#2 Full RAG fusion retrieval chain
#3 Tests the retrieval_chain_rag_fusion chain

最后一步,就是把这条 RAG Fusion retrieval chain 再嵌入到一个更大的 RAG chain 中,构成一个端到端的“问题路由 → 检索 → 答案综合”流程。

把 RAG fusion 整合进 RAG chain

和之前我们做过的一样,把 retrieval chain 整合进更大的 RAG chain 并不复杂。为了完整起见,下面给出一段把 RAG fusion retrieval chain 接入 RAG chain 的代码。

代码清单 10.16 将 RAG fusion 整合进 RAG chain

rag_prompt_template = """
Given a question and some context, answer the question.
If you do not know the answer, just say I do not know.

Context: {context}
Question: {question}
"""

rag_prompt = ChatPromptTemplate.from_template(rag_prompt_template) 

rag_chain = (
    {
        "context": {"question": RunnablePassthrough()} |
        ↪rag_fusion_retrieval_chain,   #1
        "question": RunnablePassthrough(),   #2
    }
    | rag_prompt
    | llm
    | StrOutputParser()
)
#1 The context is returned by the retriever after feeding it to the step-back question.
#2 The original user question

现在,用一个示例问题来测试整条 RAG chain:

user_question = "Can you give me some tips for a trip to Brighton?"

answer = rag_chain.invoke(user_question)
print(answer)

预期输出:

Here are some tips for a trip to Brighton:

1. **Visit During Festivals**: If you can, plan your visit in May when the Brighton Festival and Festival Fringe take place. These events are among the most popular and showcase a variety of arts and performances.

2. **Enjoy the Beach**: Brighton boasts a beautiful stretch of shingle beach over 5 miles long. Make sure to spend some time relaxing by the sea, especially during the summer when the weather is nice.

3. **Explore Local Culture**: Brighton has a vibrant cultural scene with many activities and events happening year-round. Take the time to explore art galleries and local events.

4. **Transportation**: Brighton is well-connected by train, making it easy to get in and out of the city. Consider using public transport or biking to get around the area.

5. **Seasonal Work Opportunities**: If you're looking for temporary work while visiting, Brighton is a good spot due to its student population and seasonal job availability.

6. **Plan for All Budgets**: Whether you're looking for budget options or willing to splurge, Brighton offers a range of accommodations, dining, and entertainment options to suit different budgets.

7. **Stay Safe**: Like any city, it's important to stay aware of your surroundings and follow general safety precautions while exploring.

8. **Local Council Resources**: Check out the Brighton and Hove City Council website for additional local information and resources to enhance your trip. 

Enjoy your trip!

恭喜你!到这里,你已经走完了一整套高级 RAG 技术的系统路线,对复杂 RAG 任务已经具备了非常扎实的处理能力。

小结

  • Retrieval-Augmented Generation(RAG)架构并不局限于单一数据源检索。随着需求复杂度上升,它可以扩展到多个异构数据存储联合工作。你可以把向量存储、SQL 数据库、图数据库和 API 统一整合到同一个系统中。

  • 混合检索(hybrid search) 把 dense retrieval(embedding 语义相似度)和 sparse retrieval(BM25 关键词匹配)结合起来,再通过 Reciprocal Rank Fusion(RRF) 合并结果,从而在语义相关性和词法相关性之间取得平衡。

  • SQL 数据库 适合保存结构化数据,例如客户订单或财务记录。text-to-SQL 系统可以把自然语言问题转换成 SQL 查询,然后执行查询并返回结果。

  • 图数据库 用节点和边来建模实体及其关系。诸如“谁向谁汇报”或“两个概念之间的最短路径”这类问题,在图结构上执行会非常高效。

  • RAG 系统中可以同时使用多个数据源:向量存储负责文档内容,SQL 数据库管理事务型数据,图数据库保存实体关系——所有这些都可以通过统一的 agent 接口来访问。

  • 查询路由(query routing) 利用 LLM 分类来把问题导向合适的存储后端。例如,“What did the CEO say in Q3?” 会路由到向量存储;“How many units sold last quarter?” 会路由到 SQL 数据库。

  • RRF 会基于每个文档在各个检索结果列表中的位置来打分。它对每个排名应用 1/rank 型规则,再把多个结果列表中的得分累加,并根据总分重新排序。

  • BM25 依赖倒排索引这种数据结构,而很多向量存储本身并不原生支持。LangChain 的 BM25Retriever 可用于内存中的文档列表,但不适合扩展到数百万级文档。

  • 你可以通过 LLM 分类来实现查询路由:先写一个 prompt,描述每种数据源的用途;再让 LLM 判断当前问题该走哪个源;最后把它路由到正确的 retriever 或工具。

  • 在路由 prompt 中加入 few-shot 示例通常会更稳,例如:

    • Vector store —— What did the CEO say about revenue?
    • SQL —— How many units sold?
    • Graph —— Who reports to the VP?
  • 为空结果实现 fallback 逻辑很重要:如果主数据源没有返回内容,就尝试别的备选数据源。例如,向量存储没命中时,可以自动再查一次 SQL 数据库,寻找结构化替代信息。

  • 当你把 BM25 和向量搜索结合起来时,可以先分别从两边取 top-k,再用 RRF 合并结果,最后把合并后的结果集送给 LLM 作为上下文。这样既能保留关键词命中,也能保留语义相似度。

  • 在图数据库场景下,可以使用 Cypher 查询语言,并像 text-to-SQL 一样做 text-to-Cypher 转换。LangChain 已经提供了 Neo4j 的集成支持。

  • 要对路由准确率做评估,最好在带标签的查询集上测试。跟踪有多少问题被正确地路由到了目标数据源,再据此调整分类标准或 few-shot 示例。