本文讲如何把一份 Markdown 做成可以用问题搜索的文档库:先生成 index.faissindex.pkl,再加载这两个文件,找出与问题相关的原文。

例如,文档写着“访客可以连接无线网络”,搜索时输入“来访的人怎样上网?”,程序返回文档中的无线网络说明。这个过程叫语义检索:按意思寻找相关内容,而不只检查文字是否相同。

整个例子分成两次运行:

第一次:读取 Markdown → 切成几段 → 把每段转成向量 → 保存两个文件
第二次:加载两个文件 → 输入问题 → 找到相关片段 → 输出原文

这里的“向量”就是一组数字,例如 [0.12, -0.35, 0.08, ...]。这些数字由 Embedding 模型根据文本计算出来。模型把文本转换成数字的过程,就叫“向量化”。上面的数字只是示意,不是这份文档的实际计算结果。

1. 准备一份要搜索的文档

新建一个 markdown-faiss-demo 文件夹,在里面保存 knowledge.md

# 办公室使用说明
 
## 无线网络
 
访客可以连接名为 Guest-WiFi 的无线网络。连接后打开浏览器,在认证页面填写来访登记的手机号。网络有效期为当天,离开办公室后无需保留连接。
 
## 会议室
 
会议室通过办公日历预约。创建日程时添加会议室资源,并填写开始时间和结束时间。取消会议后同时释放会议室,避免其他人看到错误的占用状态。
 
## 设备报修
 
打印机或显示器出现故障时,可以在服务台提交维修工单。工单中填写设备编号、故障现象和所在位置。服务台受理后会通过工单更新处理进度。

这是一份虚构的办公室说明,包含三件事:上网、预约会议室、报修设备。后面始终使用这份文档。

同一个目录中还会创建两个 Python 文件:

markdown-faiss-demo/
├── knowledge.md
├── build_index.py     # 把文档处理好,保存下来
└── search.py          # 加载保存的内容,执行搜索

2. 安装示例需要的库

示例使用 Python 3.11,下面的终端命令适用于 macOS / Linux。安装 uv 后,在 markdown-faiss-demo 目录中执行:

uv venv --python 3.11
uv pip install \
  "langchain-community==0.4.2" \
  "langchain-core==1.5.1" \
  "langchain-text-splitters==1.1.2" \
  "langchain-huggingface==1.2.2" \
  "sentence-transformers==5.1.2" \
  "transformers==4.57.3" \
  "torch==2.10.0" \
  "faiss-cpu==1.15.0"

uv venv 创建名为 .venv 的 Python 环境,后面的库安装在这个环境里。运行脚本时也使用 .venv/bin/python,让安装和运行使用同一个环境。

这些库在例子中分工如下:

  • LangChain 提供读取文件、切分文本和调用模型的接口,把下面几步连接起来。
  • Embedding 模型负责把文档和问题转换成向量,相关的模型库负责加载和运行它。
  • FAISS 负责保存、搜索向量。它不负责读 Markdown,也不负责把文字变成向量。

3. 把 Markdown 保存成向量库

新建 build_index.py。下面四段 Python 代码按顺序放进这个文件,合在一起就是完整的建库脚本。

第一步:读出 Markdown 的内容

from pathlib import Path
 
from langchain_community.document_loaders import TextLoader
 
BASE_DIR = Path(__file__).resolve().parent
SOURCE_PATH = BASE_DIR / "knowledge.md"
INDEX_PATH = BASE_DIR / "vector_store"
 
documents = TextLoader(str(SOURCE_PATH), encoding="utf-8").load()
 
for doc in documents:
    doc.metadata["source"] = SOURCE_PATH.name
 
print("读取到的文档数量:", len(documents))
print(documents[0].page_content)

BASE_DIR 是脚本所在的文件夹,所以程序会读取旁边的 knowledge.md。文件需要保存为 UTF-8 编码。

TextLoader(...).load() 读取文件,得到一个列表,列表里的对象叫 Document。可以把 Document 看成“正文加上来源信息”:

Document
├── page_content:整份 Markdown 的文字
└── metadata:{"source": "knowledge.md"}

page_content 中存着原文,包括 # 办公室使用说明 这样的标题。metadata 是附加信息,这里用 source 记录文件名,方便搜索后显示内容来自哪里。

此时只是把文件读进 Python,还没有生成向量,也没有创建任何索引文件。

上面的代码输出文档数量 1,然后打印整份 Markdown。

第二步:把整份文档切成几个片段

继续在 build_index.py 后面添加:

from langchain_text_splitters import RecursiveCharacterTextSplitter
 
splitter = RecursiveCharacterTextSplitter(
    chunk_size=120,
    chunk_overlap=20,
    separators=["\n\n", "\n", "。", "!", "?", " ", ""],
)
 
chunks = splitter.split_documents(documents)
 
print("切片数量:", len(chunks))
for number, chunk in enumerate(chunks, start=1):
    print(f"\n--- 片段 {number} ---")
    print(chunk.page_content)

为什么要切片?因为搜索“显示器坏了找谁处理?”时,需要的是设备报修说明。如果整份文档只生成一条向量,搜索只能选中整份文档,无法单独选出其中的报修段落。切开以后,每个片段都有自己的向量,可以分别参与搜索。

RecursiveCharacterTextSplitter 是这里使用的切片器。它尝试沿着空行、换行、标点等位置拆分文本,几个参数的意思是:

  • chunk_size=120:每个片段最多保留 120 个字符,包含文字、标点和换行等。
  • chunk_overlap=20:切分时尝试让相邻片段保留一部分重复内容,减少句子被拆开后丢失上下文的情况。重复长度不保证正好是 20。
  • separators:依次尝试的分隔位置。"\n\n" 表示空行,"\n" 表示换行;最后的 "" 表示找不到合适位置时按字符拆分。

本例按字符数计量长度。它不是每隔 120 个字符固定切一刀,也不是按模型的 Token 数切分。Token 是模型把文本拆开后使用的单位,不等于一个汉字或一个单词。递归切片器说明

这份 Markdown 会切成 3 段:

片段包含的内容字符数
1文档标题、无线网络标题及说明92
2会议室标题及说明73
3设备报修标题及说明72

例如第二段的 page_content 是:

## 会议室
会议室通过办公日历预约。创建日程时添加会议室资源,并填写开始时间和结束时间。取消会议后同时释放会议室,避免其他人看到错误的占用状态。

chunks 仍然是 Document 列表,只是从“一个对象保存整份文件”,变成了“三个对象各保存一段文字”。每段也保留了 source: knowledge.md

这里三个片段在空行处分开,没有重叠。120 和 20 是为这份短文设置的示例值,不是所有 Markdown 都适用的固定值。切片器也不会理解标题与代码块的含义,处理其他文档时,可以先看打印出的片段有没有把需要放在一起的内容拆散。

第三步:用 Embedding 模型生成向量

继续添加:

from langchain_huggingface import HuggingFaceEmbeddings
from langchain_community.vectorstores import FAISS
 
embeddings = HuggingFaceEmbeddings(
    model_name="Alibaba-NLP/gte-multilingual-base",
    model_kwargs={"device": "cpu", "trust_remote_code": True},
    encode_kwargs={"normalize_embeddings": True},
    query_encode_kwargs={"normalize_embeddings": True},
)
 
vector_store = FAISS.from_documents(chunks, embeddings)
 
print("向量数量:", vector_store.index.ntotal)
print("每条向量的维度:", vector_store.index.d)

这两段代码做的是不同的事:

HuggingFaceEmbeddings(...) 加载模型,得到可以把文字转换成向量的对象 embeddings。这一步还没有把 chunks 传给模型。

FAISS.from_documents(chunks, embeddings) 才开始处理文档:把每个片段的正文交给模型,得到向量,再把向量放入 FAISS,同时保留它对应的原文。

在这个例子里:

无线网络片段 → 模型计算 → 一组 768 个数字
会议室片段   → 模型计算 → 一组 768 个数字
设备报修片段 → 模型计算 → 一组 768 个数字

“768 维”就是每组有 768 个数字,与文档有多少行、多少段没有关系。3 个片段各生成一条向量,因此输出是:

向量数量: 3
每条向量的维度: 768

代码中的 device="cpu" 表示用 CPU 运行模型。normalize_embeddings=True 会按比例调整每条向量中的数值,让距离比较不受向量整体大小的影响;它不会改变一条向量包含多少个数字。文档和问题都采用这个设置。模型用法与输出维度见 GTE 模型说明

首次运行需要联网下载模型,可能在这一步等待一段时间。这个模型还使用自定义 Python 代码,所以设置了 trust_remote_code=True;它表示允许加载和执行模型作者提供的代码,使用前需要确认来源可信。Hugging Face 自定义模型说明

第四步:保存到磁盘

最后添加:

vector_store.save_local(str(INDEX_PATH))
print("保存位置:", INDEX_PATH)

vector_store 原本在当前 Python 程序的内存中。save_local() 把它保存到 vector_store 文件夹,让另一个程序以后也能加载。

到这里,build_index.py 已包含读取、切片、向量化、保存四段代码。在示例目录执行:

.venv/bin/python build_index.py

运行结束后,目录里会出现:

vector_store/
├── index.faiss
└── index.pkl

这就是从 Markdown 生成这两个文件的过程。“建立索引”或“建库”,在本例中指的就是把文档处理成这样一套可供检索的数据。

本例使用新建的示例目录。save_local() 会覆盖输出目录中的同名文件;保留旧索引时,可以先把 INDEX_PATH 改成 BASE_DIR / "vector_store_v2",把新结果保存到另一个目录。

4. 这两个文件为什么都要保留

模型负责计算向量,save_local() 负责保存文件。文件分成两份,是因为搜索所需的数字和最终返回的原文都要保留:

文件保存的内容查询时的作用
index.faissFAISS 向量索引,本例中包含 3 条文档向量找到与问题向量最接近的向量
index.pkl文档片段、来源信息,以及向量与文档的对应关系根据搜索结果找回那段原文

例如,FAISS 搜索到的是“会议室”那条向量。程序还需要知道这条向量对应哪段文字,才能把会议室说明打印出来。这个对应关系和片段正文保存在 index.pkl 中。

LangChain 在代码中把保存文档的对象称为 docstore,把向量位置与文档 ID 的对应关系称为 index_to_docstore_idsave_local() 把这两个对象一起写进 index.pklLangChain FAISS 实现

因此,这两个文件要成套保存,不能从两次建库结果中各取一个。它们不包含 Embedding 模型权重;查询时还需要加载模型,把问题转换成向量。

index.pkl 中保留了片段正文,分享它也可能把原文一起分享出去。向量化不是加密。

5. 加载文件,输入问题,找回原文

新建 search.py,与 build_index.py 放在同一个目录,写入下面的完整代码。

这里的模型名称和设置与建库脚本相同。如果建库时填写了本地模型路径,查询脚本也填写同一个路径。

allow_dangerous_deserialization=True 允许读取 Pickle 文件。由于 Pickle 可以在加载时执行代码,这个设置只用于本例自己生成、确认未被他人修改的文件,不能直接用于来源不明的索引。Python Pickle 说明

from pathlib import Path
 
from langchain_community.vectorstores import FAISS
from langchain_huggingface import HuggingFaceEmbeddings
 
BASE_DIR = Path(__file__).resolve().parent
INDEX_PATH = BASE_DIR / "vector_store"
 
embeddings = HuggingFaceEmbeddings(
    model_name="Alibaba-NLP/gte-multilingual-base",
    model_kwargs={"device": "cpu", "trust_remote_code": True},
    encode_kwargs={"normalize_embeddings": True},
    query_encode_kwargs={"normalize_embeddings": True},
)
 
vector_store = FAISS.load_local(
    str(INDEX_PATH),
    embeddings,
    allow_dangerous_deserialization=True,
)
 
query = input("输入问题:").strip()
if not query:
    raise ValueError("问题不能为空")
 
results = vector_store.similarity_search(query, k=1)
 
for doc in results:
    print("来源:", doc.metadata["source"])
    print(doc.page_content)

在示例目录运行:

.venv/bin/python search.py

模型和索引加载完成后,终端出现 输入问题:。输入“来访的人怎样上网?”并按回车,程序会输出:

来源: knowledge.md
# 办公室使用说明
 
## 无线网络
访客可以连接名为 Guest-WiFi 的无线网络。连接后打开浏览器,在认证页面填写来访登记的手机号。网络有效期为当天,离开办公室后无需保留连接。

查询最关键的一行是:

results = vector_store.similarity_search(query, k=1)

query 是刚输入的问题,k=1 表示最多返回一个片段。这一行内部依次完成:

“来访的人怎样上网?”
  → 用 Embedding 模型把问题转换成向量
  → FAISS 比较问题向量与已有的 3 条文档向量
  → 找到最接近的一条
  → 根据对应关系取出那条向量的 Document
  → 返回片段正文与来源

所以,results 是一个 Document 列表,doc.page_content 仍然是原来的 Markdown 片段。这里没有让聊天模型重新组织答案,也没有重新对整份 Markdown 做向量化。

程序输出后结束。再次运行 search.py,还可以用下面两个问题检查另外两段:

输入的问题对应的原文
开会之前怎么订房间?会议室通过办公日历预约……
显示器坏了找谁处理?打印机或显示器出现故障时,可以在服务台提交维修工单……

如果找不到 index.faissindex.pkl,说明建库还没完成,或者两个脚本的 INDEX_PATH 指向了不同目录。先运行建库脚本,并确认输出目录中有两个配套文件。

6. 文档修改后,搜索结果为什么没有变化

查询脚本读取的是上次保存的索引,不会重新读取 knowledge.md。因此,把文档中的“办公日历预约”改成其他预约方式后,只运行 search.py,仍然会搜到旧内容。

让修改进入搜索结果,需要再运行 build_index.py:重新读文档、切片、计算向量并保存,然后重新启动 search.py。如果新结果保存到了另一个目录,查询脚本的 INDEX_PATH 也要改成那个目录。

模型配置也与已保存的索引有关。文档向量是建库时的模型算出来的,查询时不能随意换成另一个 Embedding 模型;即使两个模型都输出 768 维,也不能据此认定可以混用。更换 Embedding 模型时,这个例子采用重新建库的方式,让文档和问题使用同一套配置。

最后,能返回片段不等于文档里一定有答案。比如输入“明天会下雨吗?”,这个非空索引仍会选出距离最近的一段。k=1 只限制返回数量,没有判断这段内容是否足够相关。