3.5 语义搜索引擎构建#

在完成 Milvus 向量数据库的安装以后,下一步将开始介绍如何使用千问 Qwen3 Embedding 系列词嵌入模型来对文本进行向量化,并构建我们自己的文本语义检索系统。

由第3.1节内容可知,构建语义检索系统整体可分为读取文档、文档切块、文本向量化、存储与检索这四步,并且前三步我们已经进行了详细介绍。下面我们将以金庸先生的武侠小说作为原始文档,重点讲解最后一步:如何将文本向量存入 Milvus,并构建完整的语义检索系统。

3.5.1 读取文档并切块#

在对文档进行向量化之前,首先需要将原始文本读取到程序中,并按照一定规则进行切分。这一步的核心目标是将长文本拆分为适合 embedding 模型处理的文本块。在实际工程中,一般需要先确定两个问题:① 文档的类型(txt / pdf / markdown / html 等);② 对应的加载方式与切块策略。不同的文档类型通常需要使用不同的 Document Loader,同时还要选择合适的文本切分方式。

当然,最为复杂的便是如果原始文档是较为复杂的 PDF,或者干脆是扫描件的 PDF,那这部分的工作量相对来说就更大了,甚至可以专门部署一些 OCR 模型(例如最近大火的DeepSeek-OCR2、GLM-OCR、PaddleOCR-VL-1.5等)来做这件事。不过这并不属于本书的核心内容,所以假定拿到的文档相对来说是比较标准的。

在本示例中,我们选择了金庸先生武侠小说 TXT 文本 (一部小说一个文本)作为原始语料。这类文本的特点是:纯文本格式、体量较大、整体按章节组织(部分做了手动调整),因此可以直接使用 LangChain 中的 TextLoader 来进行加载。

如下所示便是语料的示例格式:

“金庸作品集”新序
  小说是写给人看的。小说的内容是人。
第一回 天涯思君不可忘
  春游浩荡,是年年寒食,梨花时节。白锦无纹香烂漫,玉树琼苞堆雪。静夜沉沉,浮光霭霭,冷浸溶溶月。人间天上,烂银霞照通彻。
  浑似姑射真人,天姿灵秀,意气殊高洁。万蕊参差谁信道,不与群芳同列。浩气清英,仙才卓荦,下土难分别。瑶台归去,洞天方看清绝。
第二回 武当山顶松柏长
  两人缓步上山,直走到寺门外,竟不见一个人影。
附录 陈世骧先生书函
  一九六六·四·廿二
后记
  《倚天屠龙记》是“射雕三部曲”的第三部。

从上述示例内容可以看出,每部小说的章节标题都是换行后以“第XX回”、“附录”、“后记”等关键词开头,因此我们也可以通过正则表达式来按章节内容匹配。

对于原始 TXT 文本的载入,直接使用第3.1节中介绍的 TextLoader 即可,示例代码如下:

1 def load_txt_file(file_path=None):
2     loader = TextLoader(file_path)
3     docs = loader.load() 
4     return docs

最后,第4行代码返回的 docs 便是一个仅包含一个 Document 类对象的列表,代表对应的一部小说。

上述代码运行结束后的结果类似为:

[Document(metadata={'source': 'RAGWithMe/data/jinyong/test.txt'}, page_content='“金庸作品集”新序\n\u3000\u3000小说是写给人看的。小说的内容是人。\n\u3000\u3000在中世纪的欧洲,基督教的势力及于一切,所以我们到欧美的博物院去参观,见到所有中世纪的绘画都以圣经故事为题材,表现女性的人也必须通过圣母的形象。直到文艺复兴之后,凡人的形象才在绘画和文学中表现出来,所谓文艺复兴,是在文艺上复兴希腊、罗马时代对“人”的描写,...')]

3.5.2 混合策略切块#

此时文本仍然是整本小说级别的长文本,因此接下来需要进行文本切块。当然,此时并不能简单按照固定长度切分文本,因为这会破坏原有语义结构。例如:一个段落被拆成两部分、一个完整对话被截断等。

根据第3.2节内容的介绍,更合理的做法是采用混合策略切块,即:优先按照语义结构切分(例如章节、段落),再按照固定长度进行补充切分。

(1)按段落结构切分

进一步,首先按段落结构切分,示例代码如下:

 1 def text_splitter_regex(docs, header_patterns=None):
 2     if header_patterns is None:
 3         header_patterns = [
 4             (1,r"^(序言|附录|释名|后记|“金庸|第[一二三四五六七八九十百千0-9]+回|\
 5             									第[一二三四五六七八九十百千0-9]+章).*$")]
 6     text_splitter = RegexTextSplitter(header_patterns)
 7     all_split_objs = []
 8     for doc in docs:
 9         print(f"正在切块文件: {doc.metadata}")
10         all_splits = text_splitter.split_text(doc.page_content)
11         for i in range(len(all_splits)):
12             all_splits[i].metadata['source'] = doc.metadata['source']
13             data = all_splits[i]
14             print(f"\t数据源信息:{data.metadata}")
15         all_split_objs += all_splits
16     return all_split_objs

在上述代码中,第1行 docs 是一个列表,每个元素代表一个 Document 类对象,即一部小说的所有内容。第4~5行便是定义能够匹配小说中标题的正则表达式。第8~10行是根据正则表达式来对每一部小说进行段落结构提取,其中第10行 all_splits 表示每一部小说(一个Document 类对象)被按段落结构再次划分成多个Document 类对象。第11~12行则是把原始每部小说的来源信息加入到 all_splits 中的每个Document 类对象里。第15行则是将所有文章的所有段落结构对应的Document 类对象放到一个列表中。

正在切块文件: {'source': 'RAGWithMe/data/jinyong/金庸-雪山飞狐精校版.txt'}
	数据源信息:{'level_1': '“金庸作品集”新序', 'source': 'RAGWithMe/data/jinyong/金庸-雪山飞狐精校版.txt'}
	数据源信息:{'level_1': '第一回', 'source': 'RAGWithMe/data/jinyong/金庸-雪山飞狐精校版.txt'}
	...
正在切块文件: {'source': 'RAGWithMe/data/jinyong/金庸-射雕英雄传精校版.txt'}
	数据源信息:{'level_1': '“金庸作品集”新序', 'source': 'RAGWithMe/data/jinyong/金庸-射雕英雄传精校版.txt'}
	数据源信息:{'level_1': '第一回 风雪惊变', 'source': 'RAGWithMe/data/jinyong/金庸-射雕英雄传精校版.txt'}
	数据源信息:{'level_1': '第二回 江南七怪', 'source': 'RAGWithMe/data/jinyong/金庸-射雕英雄传精校版.txt'}
	...
正在切块文件: {'source': 'RAGWithMe/data/jinyong/金庸-笑傲江湖精校版.txt'}
	数据源信息:{'level_1': '“金庸作品集”新序', 'source': 'RAGWithMe/data/jinyong/金庸-笑傲江湖精校版.txt'}
	数据源信息:{'level_1': '第一回 灭门', 'source': 'RAGWithMe/data/jinyong/金庸-笑傲江湖精校版.txt'}
	数据源信息:{'level_1': '第二回 聆秘', 'source': 'RAGWithMe/data/jinyong/金庸-笑傲江湖精校版.txt'}
	...

在上面的输出结果中,每一行"数据源信息"都代表着一个 Document 类对象。

(2)按语义进行切分

在按段落及章节切分完成以后,下一步我们再按语义对每个章节内的内容进行切分,形成我们最终需要对其进行向量化转换的 Chunk 块。这里,直接使用第3.2节中介绍的 RecursiveCharacterTextSplitter 模块来进行切分,同时再结合上面的切分过程整体示例代码如下:

 1 def get_all_chunks():
 2     jinyong_dir = os.path.join(data_info.DATA_DIR, 'jinyong')
 3     all_files = os.listdir(jinyong_dir)
 4     all_file_objects = []
 5     # for file in all_files: 
 6     for file in ['金庸-射雕英雄传精校版.txt']: 
 7         file_path = os.path.join(jinyong_dir, file)
 8         all_file_objects += load_txt_file(file_path)
 9         print(f"正在加载文件: {file_path}")
10     print(f"文件加载完毕,一个 {len(all_file_objects)} 个,开始对文件进行切块。")
11     all_split_objs = text_splitter_regex(all_file_objects)
12     text_splitter_rec = RecursiveCharacterTextSplitter(
13         chunk_size=500,  chunk_overlap=100, add_start_index=True)
14     all_chunk_objs = text_splitter_rec.split_documents(all_split_objs)[:300] 
15     chunk_lens = [len(doc.page_content) for doc in all_chunk_objs]
16     print(f"Chunk 块切分完成,一共 {len(all_chunk_objs)} 个,"
17           f"字符最小长度为: {min(chunk_lens)}, 最大长度为: {max(chunk_lens)}, 总字符为: {sum(chunk_lens)}")
18     return all_chunk_objs

在上述代码中,第5行是遍历给定目录下的所有文件,一共15个总计约900万字,我们在测试时可以使用一个文件就行(大约100万字),即使用第6行。第6~11行便是上面介绍的按章节段落进行切分及返回的结果。第12~14行则是按照语义来对前一步的章节段落进行切分,且最大块长度为500个字符,其中第14行这里开发测试时可仅使用前300个 chunk 调试,约122496万字。最终,第18行代码返回的便是所有文章先按章节段落切分,再对章节按语义切分后的 Chunk 块,即 all_chunk_objs 中的每个元素对应的都是某一篇文章中某个章节下的一个块,也是一个 Document 类对象。

上述代码执行完毕以后,输出最后一个Document 类对象(all_chunk_objs[-1])会得到类似如下输出结果,:

page_content='柯镇恶沉吟道:“那姓杨的孩子是男孩?他叫杨康?”尹志平道:“是。”柯镇恶道:“那么他是你师弟了?”尹志平道:“是我师兄。弟子虽年长一岁,但杨师哥入门比弟子早了两年。”
江南六怪适才见了他的功夫,郭靖实非对手,师弟已是如此,他师兄当然是更加了得,这一来身上都不免凉了半截;而己方的行踪丘处机知道得一清二楚,张阿生的逝世他也已知晓,更感到己方已全处下风。
柯镇恶冷冷地道:“适才你与他过招,是试他本事来着?”尹志平听他语气甚恶,心惶恐,忙道:“弟子不敢!”柯镇恶道:“你去对你师父说,江南六怪虽然不济,醉仙楼之会决不失约,叫你师父放心吧。我们也不写回信啦!”
尹志平听了这几句话,答应又不是,不答应又不是,十分尴尬。他奉师命北上投书,丘处机确是叫他设法查察一下郭靖的为人与武功。长春子关心故人之子,原是一片好意,但尹志平少年好事,到了蒙古斡难河畔之后,不即求见六怪,却在半夜里先与郭靖交一交手,考较一下他的功夫。这时见六怪神情不善,心生惧意,不敢多呆,向各人行了个礼,说道:“弟子告辞了。”' metadata={'level_1': '第五回 弯弓射雕', 'source': 'RAGWithMe/data/jinyong/金庸-射雕英雄传精校版.txt', 'start_index': 9378}
Chunk 块切分完成,一共 300 个,字符最小长度为: 173, 最大长度为: 499, 总字符为: 122496

3.5.3 向量库及 Embedding 模型初始化#

在完成文本切块之后,接下来需要完成三个关键步骤:① 向量库及 Embedding 模型的初始化;② 使用 Embedding 模型将文本转换为向量并存储到 Milvus 向量数据库;③ 测试向量数据是否成功入库。

首先需要初始化 Milvus 向量库。在这里可以直接使用 LangChain 封装的 Milvus 模块来完成。相比直接使用原生的 MilvusClientMilvus 模块能更好的完成后续基于 LangChain 框架的操作。

具体地,我们可以通过定义下面这个函数来完成:

1 def init_vector_store(uri=None,collection_name=None):
2     embeddings = DashScopeEmbeddings(model="text-embedding-v4")
3     vector_store = Milvus(embedding_function=embeddings,
4         connection_args={"uri": uri}, auto_id=True,
5         collection_name=collection_name,collection_description="jinyong",
6         index_params={"index_type": "FLAT", "metric_type": "L2"})
7     return vector_store

在上述代码中,第2行是实例化一个 Embedding 模型,相关使用示例和原理可以参见第2.4节和第2.4节内容,其中model指定为 "text-embedding-v3""text-embedding-v4" 在注册百炼时90天内均有100万免费 Token 额度。第3~6行则是实例化一个 Milvus 数据库客户端实例化类对象,里面封装了原生 MilvusClient模块的逻辑,相关参数含义可参见第3.3节内容,整体内部流程梳理参见第3.6节内容。

3.5.4 文本向量入库#

在完成向量库初始化以后,就可以将之前切分好的文本块写入向量数据库。因为是基于 LangChain 框架,所以我们只需要通过一个方法就可以实现文本转向量,再将向量入库的整个过程,示例代码如下:

1 def add_docs_to_collection(docs, vector_store):
2     ids = vector_store.add_documents(documents=docs)
3     return ids

在上述代码中,第1行docs 便是上面 get_all_chunks() 函数处理后返回的结果 all_chunk_objsvector_store 则是对应 init_vector_store() 函数返回的 Milvus 类对象。第2行代码则是添加 Chunk 块到向量库中。

所以,在执行这一行代码时实际上完成了两件事情:① 调用 Embedding 模型,将文本转换为向量;② 将向量及对应文本存储到 Milvus 中,其中每一条数据包含文本内容、文本向量、metadata 信息和向量 ID等内容。

至此,我们的向量数据库就构建完成了。

3.5.5 测试与搜索#

在完成向量入库之后,我们可以通过一下简单的方式来查看对应集合的字段信息,以及进行简单地搜索来测试向量数据库是否工作正常。

(1)集合查看

这里,我们使用 Milvus 原生的 MilvusClient 来加载数据库并输出集合的信息,示例代码如下:

1 client = MilvusClient(uri=uri)
2 client.load_collection(collection_name=collection_name)
3 res = client.describe_collection(collection_name=collection_name)
4 print(f"describe collectionres: {res}")

上述代码运行后输出结果为:

{ 'collection_name': 'LangChainCollection','auto_id': True, 'num_shards': 0, 'description': 'jinyong',
	'fields': [{'field_id': 100, 'name': 'text','description': '', 'type': < DataType.VARCHAR: 21 > ,
		'params': {'max_length': 65535}}, 
 {'field_id': 101,'name': 'pk', 'description': '', 'type': < DataType.INT64: 5 > , 'params': {},
		'auto_id': True, 'is_primary': True}, 
 {'field_id': 102, 'name': 'vector', 'description': '', 'type': < DataType.FLOAT_VECTOR: 101 > ,
		'params': {'dim': 1024}}, 
 {'field_id': 103, 'name': 'level_1', 'description': '', 'type': < DataType.VARCHAR: 21 > ,
		'params': {'max_length': 65535}}, 
 {'field_id': 104, 'name': 'source', 'description': '', 'type': < DataType.VARCHAR: 21 > ,
		'params': {'max_length': 65535}}, 
 {'field_id': 105, 'name': 'start_index', 'description': '', 'type': < DataType.INT64: 5 > ,
		'params': {}}],
	'functions': [], 'aliases': [], 'collection_id': 0, 'consistency_level': 0,
	'properties': {}, 'num_partitions': 0, 'enable_dynamic_field': False, 'enable_namespace': False}

从上述输出结果可以看到,集合 LangChainCollection 中一共有6个字段,分别是 text 用于保存原始 Chunk 块,对应 Document 对象中的 page_content 内容;pk 自增主键;vector 用于保存 Chunk 块对应的向量;level_1 用于保存章节标题,对应Document 对象 metadata中的 level_1 内容;sourcestart_index 字段同理。

(2)查看索引

进一步,可以通过如下代码来查看集合的索引名称和信息:

1 index_name = client.list_indexes(collection_name)
2 index_info = client.describe_index(collection_name, index_name[0])
3 print(f"index_name: {index_name}") 
4 print(f"index_info: {index_info}")

上述代码运行后输出结果为:

index_name: ['vector']
index_info: {'index_type': 'FLAT', 'metric_type': 'L2', 'dim': '1024', 'field_name': 'vector', 'index_name': 'vector', 'total_rows': 0, 'indexed_rows': 0, 'pending_index_rows': 0, 'state': 'Finished'}

(3) 数据查询

最后,我们可以通过如下语句来查询集合中的实体和数量:

1 res = client.query(collection_name=collection_name, filter="pk == 464859367376683008",  # 必须有条件
2                    output_fields=["source","vector", "text", "start_index", "level_1"])
3 print(f"query: {res}")
4 res = client.query(collection_name=collection_name, filter="",
5                    output_fields=["count(*)"])
6 print(f"一共有数据: {res} 条")

上述代码运行后输出结果类似为:

query: data: ["{'pk': 464859367376683008, 'level_1': '“金庸作品集”新序', 'source': 'RAGWithMe/data/jinyong/金庸-射雕英雄传精校版.txt', 'start_index': 0, 'text': '小说是写给人看的。小说的内容是人。\\n小说写一个人、几个人、一群人、或成千成万人的性格和感情。他们的性格和感情从横面的环境中反映出来,从纵面的遭遇中反映出来,从人与人之间的交往与关系中反映出来。长篇小说中似乎只有《鲁滨逊飘流记》...', 'vector': [-0.0650412589, 0.01044313237, -0.0374443903, ....]}"], extra_info: {}
一共有数据: data: ["{'count(*)': 300}"], extra_info: {}

3.5.6 基于原生向量库检索#

在完成上述测试工作以后,接下来就可以来根据输入文本区向量库里检索与之相关的内容。这里介绍两种方式,一种是基于原生向量库检索,另一种是基于统一检索接口检索。

(1)基于原生向量库检索

基于原生向量库检索指的是通过 Milvus() 类对象中的 similarity_search() 方法来完成向量检索过程,示例代码如下所示:

 1 def similarity_search(vector_store, query):
 2     results = vector_store.similarity_search(query=query, k=3)
 3     for i, doc in enumerate(results):
 4         print(f"\n排名第 {i + 1} 的答案为:")
 5         print(f"\t 内容: {doc.page_content}")
 6         print(f"\t 来源: {doc.metadata}")
 7 
 8     results = vector_store.similarity_search_with_score(query=query, k=3)
 9     for i, item in enumerate(results):
10         doc, score = item
11         print(f"\n排名第 {i + 1} 的答案为(相似度评分={score}):")
12         print(f"\t 内容: {doc.page_content}")
13         print(f"\t 来源: {doc.metadata}")

这里需要注意的是,不同的距离度量方式,第10行中相似度含义不一样。例如这里因为我们在上面构建索引的时候 指定了 "metric_type": "L2",所以这里相似度返回的是距离值,距离越小越相似。

上述代码运行后输出结果类似为:

排名第 1 的答案为:
内容: 武侠小说虽说是通俗作品,以大众化、娱乐性强...
来源: {'level_1': '“金庸作品集”新序', 'pk': 464859367376683013, 'source': 'RAGWithMe/data/jinyong/金庸-射雕英雄传精校版.txt', 'start_index': 1856}

排名第 2 的答案为:
内容: 柯镇恶听到这里,皱着的眉头稍稍舒...
来源: {'level_1': '第五回 弯弓射雕', 'pk': 464859367376683306, 'source': 'RAGWithMe/data/jinyong/金庸-射雕英雄传精校版.txt', 'start_index': 8982}

排名第 3 的答案为:
内容: 翻版本不付版税,还在其...
来源: {'level_1': '“金庸作品集”新序', 'pk': 464859367376683016, 'source': 'RAGWithMe/data/jinyong/金庸-射雕英雄传精校版.txt', 'start_index': 2754}


排名第 1 的答案为(相似度评分=0.8554366230964661):
内容: 武侠小说虽说是通俗作品,以大众化、娱乐性强...
来源: {'level_1': '“金庸作品集”新序', 'pk': 464859367376683013, 'source': 'RAGWithMe/data/jinyong/金庸-射雕英雄传精校版.txt', 'start_index': 1856}

排名第 2 的答案为(相似度评分=0.8602303266525269):
内容: 柯镇恶听到这里,皱着的眉头稍稍舒...
来源: {'level_1': '第五回 弯弓射雕', 'pk': 464859367376683306, 'source': 'RAGWithMe/data/jinyong/金庸-射雕英雄传精校版.txt', 'start_index': 8982}

排名第 3 的答案为(相似度评分=0.8788247108459473):
内容: 翻版本不付版税,还在其...
来源: {'level_1': '“金庸作品集”新序', 'pk': 464859367376683016, 'source': 'RAGWithMe/data/jinyong/金庸-射雕英雄传精校版.txt', 'start_index': 2754}

从上述结果可以看出,similarity_search() 方法做了三件事:① 把 query 转换为向量;② 在向量数据库中进行相似度搜索;③ 返回最相似的文本块。

(2)基于统一检索接口检索

从上面代码可以看出, 类 VectorStore 的实例化对象 vector_store 的职责主要是存储向量并进行相似度搜索,所以 VectorStore 的本质是“数据存储 + 向量检索引擎”。然而,在 LangChain 框架中,检索来源不一定都是依赖于向量数据库,例如关键词搜索引擎、外部 API、数据库和文件系统等,因此 LangChain 提供了一套统一的检索抽象层(Retrieval Interface)来解决。

因此,在后续的 RAG 系统开发中,我们也将看到像 “用户问题 → Retriever → 检索相关文档 → Prompt 模板 → LLM → 生成答案”这样的典型流程结构。

具体地,对于第3.1节中的示例,还可以通过如下标准化方式对用户提问进行相关语料的检索:

1 def retriever(vector_store, queryies):
2     retriever = vector_store.as_retriever(
3         search_type="similarity", search_kwargs={"k": 2})
4     result = retriever.batch(inputs=queryies)
5     print(result)
6 queries = ["郭靖是谁?", "郭靖这个人物在这部小说当中是谁?它的出生背景是什么?"]
7 retriever(vector_store=vector_store, queryies=queries)

在上述代码中,as_retriever() 的作用便是将 VectorStore 封装成一个符合 LangChain 标准接口的 Retriever,使其能够被 RAG、Chain、Agent 等组件直接使用。

上述代码运行后输出结果类似为:

[Document(metadata={'level_1': '第五回 弯弓射雕', 'pk': 464836204334678307, 'source': 'RAGWithMe/data/jinyong/金庸-射雕英雄传精校版.txt', 'start_index': 6652}, page_content='...当晚郭靖睡到中夜,忽听得帐外有人轻轻拍了三下手掌,他坐起身来,只听得有人以汉语轻声道:“郭靖,你出来。”郭靖微感诧异,听声音不熟,揭开帐幕一角往外张望,月光下只见左前方大树之旁站着一人。\n郭靖出帐近前,只见那人宽袍大袖,头发打成髻子,不男不女,面貌看来年纪尚轻。原来这人是个道士,郭靖却从没见过中土的道士,问道:“你是谁?找我干什么?”那人道:“你是郭靖,是不是?”郭靖道:“是。”那人道:“你那柄削铁如泥的短剑呢?拿来给我瞧瞧!”身子微晃,蓦地欺近,发掌便往他胸口按去。\n郭靖见对方没...'),
Document(metadata={'level_1': '第四回 黑风双煞', 'pk': 464836204334678245, 'source': 'RAGWithMe/data/jinyong/金庸-射雕英雄传精校版.txt', 'start_index': 6678}, page_content='..柯镇恶耳音锐敏之极,听到“郭靖”两字,全身大震,立即提缰回马,问道:“孩子,你姓郭?你是汉人,不是蒙古人?”郭靖道:“是啊!”柯镇恶大喜,急问:“你妈妈叫什么名字?”郭靖道:“妈妈就是妈妈。”柯镇恶搔头,问道:“你带我去见你妈妈,好么?”郭靖道:“妈妈不在这里。”柯镇恶听他语气中似乎含敌意,...温言道:“你爹爹呢?”郭靖道:“我爹爹给坏人害死了,等我长大了。郭靖又摇了摇头,柯镇恶道:“害死你爹爹的坏人叫什么名字?”郭靖咬牙切齿地道:“他……名叫段天德!”\n原来李萍身处荒漠绝域之地,知道随时都会遭遇不测...')],

Document(metadata={'level_1': '第四回 黑风双煞', 'pk': 464836204334678245, ......),
Document(metadata={'level_1': '第五回 弯弓射雕', 'pk': 464836204334678307, ......)]]

这里值得一提的是,从最后检索的结果来看,第二个问题 “郭靖这个人物在这部小说当中是谁?它的出生背景是什么?” 排序第二的答案,要明显好于第一个问题排序第一的答案。原因之一在于,我们可以明显看出第二个问题的描述比第一个更清晰,因此 RAG 中的一个关键步骤就是对用户的提问进行重写。

到此,对于如何从零开始构建一个语义检索系统就介绍完了,在下一章内容中我们将开始正式买入 RAG 开发的学习过程。