使用 Qdrant 进行代码语义搜索
你也可以通过 Qdrant 语义搜索来丰富你的应用程序。在本教程中,我们将介绍如何使用 Qdrant 浏览代码库,以帮助你找到相关的代码片段。作为一个示例,我们将使用 Qdrant 本身的源代码,它主要是用 Rust 编写的。
方法论
我们希望使用自然语义查询来搜索代码库,并基于相似逻辑查找代码。你可以通过嵌入(embeddings)来设置这些任务:
- 用于自然语言处理(NLP)的通用神经编码器,在我们的案例中是
sentence-transformers/all-MiniLM-L6-v2。 - 用于代码到代码相似度搜索的专用嵌入。我们使用
jina-embeddings-v2-base-code模型。
为了让代码适配 all-MiniLM-L6-v2,我们将代码预处理为更接近自然语言的文本。Jina 嵌入模型支持多种标准编程语言,因此无需对片段进行预处理,我们可以直接使用原始代码。
基于 NLP 的搜索依赖于函数签名,但代码搜索可能会返回更小的片段,例如循环。因此,如果我们从 NLP 模型收到特定的函数签名,并从代码模型收到其部分实现,我们会合并结果并突出显示重叠部分。
数据准备
将应用程序源代码分块(Chunking)为更小的部分是一项非平凡的任务。通常,函数、类方法、结构体、枚举以及所有其他特定于语言的结构都是很好的分块候选者。它们足够大以包含有意义的信息,又足够小以至于可以被具有有限上下文窗口的嵌入模型处理。你还可以使用文档字符串(docstrings)、注释和其他元数据来丰富这些分块信息。

解析代码库
虽然我们的示例使用 Rust,但你可以在任何其他语言上使用我们的方法。你可以使用兼容 语言服务器协议(LSP)的工具来解析代码。你可以使用 LSP 构建代码库的图结构,然后提取分块。我们使用了 rust-analyzer 来完成这项工作。我们将解析后的代码库导出为 LSIF 格式,这是代码智能数据的标准。接下来,我们使用 LSIF 数据来导航代码库并提取分块。详细信息请参阅我们的 代码搜索演示。
然后,我们将分块导出为 JSON 文档,其中不仅包含代码本身,还包含代码在项目中位置的上下文。例如,请查看 common 模块中 IsReady 结构体的 await_ready_for_timeout 函数描述。
{
"name":"await_ready_for_timeout",
"signature":"fn await_ready_for_timeout (& self , timeout : Duration) -> bool",
"code_type":"Function",
"docstring":"= \" Return `true` if ready, `false` if timed out.\"",
"line":44,
"line_from":43,
"line_to":51,
"context":{
"module":"common",
"file_path":"lib/collection/src/common/is_ready.rs",
"file_name":"is_ready.rs",
"struct_name":"IsReady",
"snippet":" /// Return `true` if ready, `false` if timed out.\n pub fn await_ready_for_timeout(&self, timeout: Duration) -> bool {\n let mut is_ready = self.value.lock();\n if !*is_ready {\n !self.condvar.wait_for(&mut is_ready, timeout).timed_out()\n } else {\n true\n }\n }\n"
}
}
你可以在我们 Google Cloud Storage 存储桶中的 structures.jsonl 文件 中查看解析为 JSON 的 Qdrant 结构。下载它并将其用作我们代码搜索的数据源。
wget https://storage.googleapis.com/tutorial-attachments/code-search/structures.jsonl
接下来,加载文件并将每一行解析为字典列表。
import json
structures = []
with open("structures.jsonl", "r") as fp:
for i, row in enumerate(fp):
entry = json.loads(row)
structures.append(entry)
代码到自然语言的转换
每种编程语言都有其自己的语法,这不是自然语言的一部分。因此,通用模型可能无法直接理解代码。然而,我们可以通过去除代码特有的内容并包含额外的上下文(如模块、类、函数和文件名)来规范化数据。我们采取了以下步骤:
- 提取函数、方法或其他代码结构的签名。
- 将驼峰命名法(camel case)和蛇形命名法(snake case)名称拆分为独立的单词。
- 提取文档字符串、注释和其他重要的元数据。
- 使用预定义的模板从提取的数据中构建句子。
- 删除特殊字符并将它们替换为空格。
作为输入,预期为具有相同结构的字典。定义一个 textify 函数来进行转换。我们将使用 inflection 库来转换不同的命名约定。
pip install inflection
安装所有依赖项后,我们定义 textify 函数。
import inflection
import re
from typing import Dict, Any
def textify(chunk: Dict[str, Any]) -> str:
# Get rid of all the camel case / snake case
# - inflection.underscore changes the camel case to snake case
# - inflection.humanize converts the snake case to human readable form
name = inflection.humanize(inflection.underscore(chunk["name"]))
signature = inflection.humanize(inflection.underscore(chunk["signature"]))
# Check if docstring is provided
docstring = ""
if chunk["docstring"]:
docstring = f"that does {chunk['docstring']} "
# Extract the location of that snippet of code
context = (
f"module {chunk['context']['module']} "
f"file {chunk['context']['file_name']}"
)
if chunk["context"]["struct_name"]:
struct_name = inflection.humanize(
inflection.underscore(chunk["context"]["struct_name"])
)
context = f"defined in struct {struct_name} {context}"
# Combine all the bits and pieces together
text_representation = (
f"{chunk['code_type']} {name} "
f"{docstring}"
f"defined as {signature} "
f"{context}"
)
# Remove any special characters and concatenate the tokens
tokens = re.split(r"\W", text_representation)
tokens = filter(lambda x: x, tokens)
return " ".join(tokens)
现在我们可以使用 textify 将所有分块转换为文本表示。
text_representations = list(map(textify, structures))
这就是 await_ready_for_timeout 函数描述的样子。
Function Await ready for timeout that does Return true if ready false if timed out defined as Fn await ready for timeout self timeout duration bool defined in struct Is ready module common file is_ready rs
摄取流水线
接下来,我们将构建一个用于向量化数据的流水线,并为这两种嵌入模型设置语义搜索机制。
构建 Qdrant 集合
我们使用带有 fastembed 扩展的 qdrant-client 库与 Qdrant 服务器交互并在本地生成向量嵌入。让我们安装它:
pip install "qdrant-client[fastembed]"
当然,我们需要一个正在运行的 Qdrant 服务器进行向量搜索。如果你需要的话,可以使用 本地 Docker 容器 或者通过 Qdrant Cloud 部署。你可以使用其中任何一种来学习本教程。配置连接参数:
QDRANT_URL = "https://my-cluster.cloud.qdrant.io:6333" # https://:6333 for local instance
QDRANT_API_KEY = "THIS_IS_YOUR_API_KEY" # None for local instance
然后使用该库创建一个集合:
from qdrant_client import QdrantClient, models
client = QdrantClient(QDRANT_URL, api_key=QDRANT_API_KEY)
client.create_collection(
"qdrant-sources",
vectors_config={
"text": models.VectorParams(
size=client.get_embedding_size(
model_name="sentence-transformers/all-MiniLM-L6-v2"
),
distance=models.Distance.COSINE,
),
"code": models.VectorParams(
size=client.get_embedding_size(
model_name="jinaai/jina-embeddings-v2-base-code"
),
distance=models.Distance.COSINE,
),
},
)
我们新创建的集合已准备好接收数据。让我们上传嵌入:
import uuid
# Extract the code snippets from the structures to a separate list
code_snippets = [
structure["context"]["snippet"] for structure in structures
]
points = [
models.PointStruct(
id=uuid.uuid4().hex,
vector={
"text": models.Document(
text=text, model="sentence-transformers/all-MiniLM-L6-v2"
),
"code": models.Document(
text=code, model="jinaai/jina-embeddings-v2-base-code"
),
},
payload=structure,
)
for text, code, structure in zip(text_representations, code_snippets, structures)
]
# Note: This might take a while since inference happens implicitly.
# Parallel processing can help.
# But too many processes may trigger swap memory and hurt performance.
client.upload_points("qdrant-sources", points=points, batch_size=64)
在内部,qdrant-client 使用 FastEmbed 隐式地将我们的文档转换为向量表示。上传的点位可立即用于搜索。接下来,查询集合以查找相关的代码片段。
查询代码库
我们使用其中一个模型来搜索集合。从文本嵌入开始。运行以下查询:“How do I count points in a collection?”(我该如何统计集合中的点数?)。查看结果。
query = "How do I count points in a collection?"
hits = client.query_points(
"qdrant-sources",
query=models.Document(text=query, model="sentence-transformers/all-MiniLM-L6-v2"),
using="text",
limit=5,
).points
现在,查看结果。下表列出了模块、文件名和分数。每一行都包含一个指向签名的链接,即来自文件的代码块。
| 模块 | 文件名 | 分数 | 签名 |
|---|---|---|---|
| 目录 | point_ops.rs | 0.59448624 | pub async fn count |
| operations | types.rs | 0.5493385 | pub struct CountRequestInternal |
| collection_manager | segments_updater.rs | 0.5121002 | pub(crate) fn upsert_points<'a, T> |
| collection | point_ops.rs | 0.5063539 | pub async fn count |
| map_index | mod.rs | 0.49973983 | fn get_points_with_value_count<Q> |
看来我们能够找到一些相关的代码结构。让我们尝试用代码嵌入做同样的事情。
hits = client.query_points(
"qdrant-sources",
query=models.Document(text=query, model="jinaai/jina-embeddings-v2-base-code"),
using="code",
limit=5,
).points
输出
| 模块 | 文件名 | 分数 | 签名 |
|---|---|---|---|
| field_index | geo_index.rs | 0.73278356 | fn count_indexed_points |
| numeric_index | mod.rs | 0.7254976 | fn count_indexed_points |
| map_index | mod.rs | 0.7124739 | fn count_indexed_points |
| map_index | mod.rs | 0.7124739 | fn count_indexed_points |
| fixtures | payload_context_fixture.rs | 0.706204 | fn total_point_count |
虽然不同模型检索到的分数不可比较,但我们可以看到结果是不同的。代码和文本嵌入可以捕获代码库的不同侧面。我们可以使用这两个模型来查询集合,然后从单次批量请求中合并结果,以获得最相关的代码片段。
responses = client.query_batch_points(
collection_name="qdrant-sources",
requests=[
models.QueryRequest(
query=models.Document(
text=query, model="sentence-transformers/all-MiniLM-L6-v2"
),
using="text",
with_payload=True,
limit=5,
),
models.QueryRequest(
query=models.Document(
text=query, model="jinaai/jina-embeddings-v2-base-code"
),
using="code",
with_payload=True,
limit=5,
),
],
)
results = [response.points for response in responses]
输出
| 模块 | 文件名 | 分数 | 签名 |
|---|---|---|---|
| 目录 | point_ops.rs | 0.59448624 | pub async fn count |
| operations | types.rs | 0.5493385 | pub struct CountRequestInternal |
| collection_manager | segments_updater.rs | 0.5121002 | pub(crate) fn upsert_points<'a, T> |
| collection | point_ops.rs | 0.5063539 | pub async fn count |
| map_index | mod.rs | 0.49973983 | fn get_points_with_value_count<Q> |
| field_index | geo_index.rs | 0.73278356 | fn count_indexed_points |
| numeric_index | mod.rs | 0.7254976 | fn count_indexed_points |
| map_index | mod.rs | 0.7124739 | fn count_indexed_points |
| map_index | mod.rs | 0.7124739 | fn count_indexed_points |
| fixtures | payload_context_fixture.rs | 0.706204 | fn total_point_count |
这只是一个如何使用不同模型并合并结果的示例。在现实场景中,你可能需要进行一些重排序(reranking)和去重(deduplication),以及对结果进行额外的处理。
代码搜索演示
我们的 代码搜索演示 使用了以下过程:
- 用户发送一个查询。
- 两个模型同时向量化该查询。我们得到两个不同的向量。
- 两个向量被并行使用以查找相关的片段。我们预期从 NLP 搜索中获得 5 个示例,从代码搜索中获得 20 个示例。
- 一旦我们检索到两个向量的结果,我们将按照以下场景之一将它们合并:
- 如果两种方法返回的结果不同,我们优先选择来自通用模型(NLP)的结果。
- 如果搜索结果之间存在重叠,我们会合并重叠的片段。
在截图中,我们搜索 flush of wal。结果显示了从两个模型合并而来的相关代码。注意 621-629 行中突出显示的代码,那是两个模型达成一致的地方。

现在你看到了语义代码智能的实际应用。
结果分组
你可以通过按有效负载(payload)属性对搜索结果进行分组来改进搜索结果。在我们的案例中,可以按模块对结果进行分组。如果我们使用代码嵌入,可以看到来自 map_index 模块的多个结果。让我们对结果进行分组,并假设每个模块只取一个结果:
results = client.query_points_groups(
collection_name="qdrant-sources",
using="code",
query=models.Document(text=query, model="jinaai/jina-embeddings-v2-base-code"),
group_by="context.module",
limit=5,
group_size=1,
)
输出
| 模块 | 文件名 | 分数 | 签名 |
|---|---|---|---|
| field_index | geo_index.rs | 0.73278356 | fn count_indexed_points |
| numeric_index | mod.rs | 0.7254976 | fn count_indexed_points |
| map_index | mod.rs | 0.7124739 | fn count_indexed_points |
| fixtures | payload_context_fixture.rs | 0.706204 | fn total_point_count |
| hnsw_index | graph_links.rs | 0.6998417 | fn num_points |
使用分组功能,我们可以获得更多样化的结果。
总结
本教程演示了如何使用 Qdrant 导航代码库。如需端到端实现,请查看 代码搜索 Notebook 和 code-search-demo。你还可以查看 运行中的代码搜索演示版本,该演示展示了通过 Web 界面搜索 Qdrant 代码库的功能。